客户端

简介一节中我们提到,客户端层暴露了一个供人类使用的用户界面。 客户端由两部分组成:一个只读的简单 HTML 页面和一个可交互的单页 JavaScript 应用。两者都从 JSON API 读取数据。

路由

论坛所有的默认路由都在 Flarum\Forum\ForumServiceProvider 中注册。每个路由有一个对应的 Action (动作)类, 这个类本质上就是控制器。每个动作接受一个 PSR-7 请求,并返回一个响应。

Flarum\Forum\Actions\ClientAction 是动作类的基类,它会构建一个 ClientView 实例,并将这个实例回应给客户端。 ClientView 是一个高度可定制的、包括前端 JavaScript 应用所需要的所有资源文件的主模板。例如你可以:

  • setTitle() 设置文档标题。
  • setContent() 设置页面的 SEO 内容(在 <noscript> 标签中显示的内容)。
  • setDocument() 设置页面中需要预加载的 API 响应。
  • addHeadString()addFootString() 在页面的头部或尾部添加 HTML 内容。

新的论坛路由可以通过 RegisterForumRoutes 事件来注册。例如:

use Flarum\Events\RegisterForumRoutes;

$events->listen(RegisterForumRoutes::class, function (RegisterForumRoutes $event) {
    $event->get(
        '/tags', // 路由 URI
        'forum.tags', // 路由的唯一名字
        'Flarum\Tags\Forum\TagsAction' // 响应请求的动作类(可选)
    );
});

定义响应请求的动作类(第三个参数)是可选的。如果没有指定,Flarum 会用默认的空白 ClientAction 来响应。

JavaScript 应用

Flarum 最好的一部分就是运行飞快的交互式 JavaScript 前端应用。Flarum 的前端采用一个和 React 很像的轻量级的渲染框架 Mithril 构建。 下面的内容假设你对 Mithrill 很熟悉,并且了解一些 React 的思想。

编译资源文件

Flarum 的前端 JavaScript 应用采用 ES6 编写, 并通过 GulpBabel 转译成 ES5。 前端大量使用 ES6 模块, 并转译成 System.js 格式。扩展的 JavaScript 也应当如此编写。

Flarum 为你生成的扩展框架包含了所有制作扩展所需的必要工具。 切换到 js/forum 目录,执行下面的命令就可以编译你扩展的 JavaScript 文件。

gulp watch

编译后的 JavaScript 文件会输出到 js/forum/dist/extension.js。这个文件必须注册到客户端的资源管理器中,否则不会加载。 另外,还需要注册一个加载器,这样在前端 JS 应用启动的时候才会执行你的扩展代码。 你可以在 BuildClientView 事件中这么做(自动生成的框架中已经包含了这部分代码):

use Flarum\Events\BuildClientView;

$events->listen(BuildClientView::class, function (BuildClientView $event) {
    $event->forumAssets([
        __DIR__.'/../../js/forum/dist/extension.js'
    ]);

    $event->forumBootstrapper('extension-name/main');
});

运行时扩展(Monkey Patching)

在加载器中注册了你的 main 模块后,前端应用会在开始执行真正的逻辑前运行你在 js/forum/src/main.js 中写的代码。 这样,你的扩展就有机会修改你想要改的东西了。

简介一节中我们曾一笔带过的提到我们可以通过 monkey-patching 替换函数。Flarum 提供了两个帮助你操作的函数。 这两个函数是 extendoverride ,可以通过 flarum/extend 导入。调用这两个函数时,只需传入你想要修改的对象(通常来说是某个类的原型)、你想要修改的属性和一个回调函数即可。

extend 函数中,回调函数是在原来的方法 运行完之后 被调用的。它会把原来的执行结果传入你的回调函数,这样你可以修改返回值。

override 函数中,回调函数会 替换原来的方法。它会将原来的方法传入,如果你需要,可以在回调中随时调用它。

下面的这两个例子演示了这两个函数的使用方式:

import {extend, override} from 'flarum/extend';

class Foo {
  aMethod() {
    return {
      color: 'red'
    };
  }

  bMethod() {
    return 'Gday';
  }
}

const foo = new Foo();
foo.aMethod(); // {color: 'red'}
foo.bMethod(); // 'Gday'

extend(Foo.prototype, 'aMethod', value => {
  value.color = 'yellow';
});

override(Foo.prototype, 'bMethod', original => {
  return original() + ' mate';
});

foo.aMethod(); // {color: 'yellow'}
foo.bMethod(); // 'Gday mate'

在运行时扩展方法的时候,你需要确认你扩展或者替换的方法和原来的方法签名(参数的数量和类型、返回值的类型)一致。

组件

组件是一些构建 Fluarm 的 UI 的可重用片段。你可以在 这里 找到所有的组件。

组件是在 Mithril 的组件系统 上用 React 的方式实现的。 一个组件必须继承 flarum/Component 并实现 view() 方法(默认的扩展工具会将 JSX 转义成 Mithril 的 m() 函数)。

class WelcomeMessage extends Component {
  view() {
    return (
      <div className="WelcomeMessage">
        <h1>Hello, {this.props.name}!</h1>
      </div>
    );
  }
}

一个组件可以通过 config() 方法和它的根 DOM 元素互动。这个方法和 Mithril 的 config API 几乎是一样的实现, 只有一点不同:它没有传入第一个参数(element)。你可以用 $() 来操作这个元素,就像用一个 jQuery 对象一样。

config(isInitialized, context) {
  if (isInitiailized) return;

  this.$().on('click', () => alert('clicked'));
}

对象列表(ItemList)

ItemList 类是一个通过名字来收集和整理对象的数组。例如:

import ItemList from 'flarum/utils/ItemList';

const items = new ItemList();

items.add('foo', 'Foo');
items.add('bar', 'Bar');
items.add('qux', 'Qux', 10); // 更高优先级的对象会排在前面

delete items.foo; // 删除一个对象

items.bar.content = 'Baz'; // 改变一个对象的内容

items.toArray(); // ['Qux', 'Baz']

许多组件和其它的实用工具会返回一个 ItemList 的实例(通常来说它们的方法名字以 Items 结尾)。 例如, CommentPost 组件的 headerItems() 方法就返回一个 ItemList 的实例,其中包含每条帖子的头部。 你可以用运行时扩展来修改它们:

import CommentPost from 'flarum/components/CommentPost';

extend(CommentPost.prototype, 'headerItems', function(items) {
  items.add('location', '这个用户住在' + this.props.post.user().location());
});

应用全局变量

app 全局变量是 flarum/ForumApp 的实例,你可以通过这个变量来访问整个应用级别的组件实例和实用工具。

存储(Store)

存储(Store) 是一个和 Flarum 后端 API 交互的对象,可以通过 app.store 来访问。

存储会将在 API 响应中的资源和 Models 关联起来。Models 会让访问对象和关系变得容易起来。 例如,主题的 model 的一部分代码是这样的:

class Discussion extends mixin(Model, {
  title: Model.attribute('title'),
  startTime: Model.attribute('startTime', Model.transformDate),
  startUser: Model.hasOne('startUser'),
  posts: Model.hasMany('posts'),

要通过 API 获得一个主题的 model,用 store 的 find 方法就行了。find 方法会返回一个 promise。例如:

app.store.find('discussions', 1).then(discussion => {
  discussion.id(); // "1"
  discussion.title(); // "Discussion title"
  discussion.startUser(); // flarum/models/User 对象
});

你也可以用 find 方法进行更加复杂的查询,例如:

app.store.find('discussions', {
  sort: '+time',
  page: {limit: 5},
  filter: {q: 'test'}
});

find 方法会无条件地发起一个 API 请求。如果你想要从 store 的本地缓存中获取 model 避免发起 API 请求, 那就得用 getByIdall 这两个方法了。例如:

const discussion = app.store.getById('discussions', 1);
const discussions = app.store.all('discussions');

Model 的属性和关系可以用 save 来存储到服务器,也可以用 delete 来删除。这两个方法都会返回一个 promise。例如:

discussion.save({
  title: '新标题',
  relationships: {
    tags: [] // Tag model 的数组
  }
});
discussion.delete();

新的数据库记录也可以用 createRecord 方法来创建。例如:

const discussion = app.store.createRecord('discussions', {
  title: '新的主题'
});
discussion.save();

路由

你可以通过 app.routes 来添加或修改客户端的路由。路由对象的键名是一个唯一的名字, 而值则是有 pathcomponent 两个属性的对象。注意,修改路由只能在初始化的时候完成。

app.routes.tag = {path: '/tag/{slug}', component: TagPage.component()};

你可以用 app.route() 来生成一个路由对应的 URL。例如:

<a href={app.route('tag', {slug: 'blog'})} config={m.route}>博客</a>

译者:@oott123