客户端

在简介一节中我们提到,客户端层暴露了一个供人类使用的用户界面。 客户端由两部分组成:一个只读的简单 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 编写, 并通过 Gulp 与 Babel 转译成 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 提供了两个帮助你操作的函数。 这两个函数是 extend 和 override ,可以通过 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 请求, 那就得用 getById 和 all 这两个方法了。例如:

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 来添加或修改客户端的路由。路由对象的键名是一个唯一的名字, 而值则是有 path 和 component 两个属性的对象。注意,修改路由只能在初始化的时候完成。

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

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

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

译者:@oott123