多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
>[danger] **弃用提醒:** > *由于看云对于免费用户的限制愈发严苛,本文档已经迁移至语雀。本文档将不做维护。* > **语雀地址**:[https://www.yuque.com/a632079/nodebb](https://www.yuque.com/a632079/nodebb) ***** # 插件制作 ## 导言 ![](https://img.kancloud.cn/5e/7b/5e7b324468c0054624ab480505ca84bf_1920x941.png) NodeBB 支持插件系统,你可以通过编写插件扩展功能。在开始编写插件之前,我想你一定对它的实现很感兴趣。 和 WordPress 类似, NodeBB 的插件系统是基于钩子(Hook)模型实现的 。这种方式能使插件在受限制的情况下修改钩子提供的数据,或在触发钩子时执行某些方法。 我们可以在[这里](https://github.com/NodeBB/NodeBB/wiki/Hooks/)找到所有受支持的钩子,了解到钩子大概的作用。 [TOC] ## 钩子系统 我们先简单了解下定义: **钩子系统**,也称钩子编程(hooking),简称作挂钩,是计算机程序设计术语,指通过拦截软件模块间的函数调用、消息传递“讯息传递 (软件)”)、事件传递来修改或扩展操作系统、应用程序或其他软件组件的行为的各种技术。处理被拦截的函数调用、事件、消息的代码,被称为**钩子**(hook)。 在导言中我们简单介绍了钩子的机制,让我们借用 Vue.js 的生命周期流程图更深入的认识这个机制。 ![](https://img.kancloud.cn/0e/64/0e64dd574bbe77765a89a2178057bf91_1200x3039.png) 没错, 像 `created`,`mounted`,`updated`,`destroyed` 这些事件产生的回调处理便称为钩子。钩子能够十分方便的在生命周期的各个环节中注入,变更数据,亦或阻断流程的继续。相比较直接变动程序的核心文件,覆盖式的引入插件程序, 钩子更能提供安全保障。 我们可以这么理解: 将核心程序理解为电脑的话,那么插件便是电脑的元器件。直接修改核心程序,或覆盖式的插件引用,就相当于直接拆开电脑更换零件。如果你技艺高超,处理娴熟, 当然时没什么问题。可谁也不能保障你不犯错吧?更换零件一旦失误, 电脑就无法启动。要修复电脑,得做很多反复的调试工作。十分麻烦。而钩子,就相当于外置的 USB。通过钩子模型编写的插件便相当于 显卡坞, 外置硬盘这类元件,如果出现问题了,我们可以很方便的找到造成问题的元件,并针对性的进行修复。并且 USB 元件相对来说也更难对电脑造成难以调试的无法启动问题。 用一句话概括: **权限越大,责任越大。** 为了平衡程序员水平参差不齐的问题, 钩子模型的初衷是限权;是目前最常用的黑盒模型,是规避越权,维护核心稳定的常用手段。 ### NodeBB 中的实现 在 NodeBB 中钩子分为两大类: **服务端钩子** 和 **客户端钩子**。 顾名思义, **服务端钩子**即 NodeBB 主程序生命周期中暴露给第三方程序能使用的接口。 **客户端钩子**即 NodeBB 在用户浏览器的生命周期中暴露给第三方程序的接口。 一个完整的 NodeBB 钩子的定义是这样的: `hookType:module/event.action`。例如:`filter:post.save`,`action:auth.overrideLogin` * `hookType` 即钩子的类型, 它大致分为 4 类, 我们将在稍后对其详细讲解。 * `module/event` 即触发的模块或事件, 例如回复/帖子模块:`post`,用户组模块:`groups` 。 * `action` 很好理解,就是模块/事件所对应的操作。例如:`post` 下的 `save` 操作, `groups` 下的 `get` 操作。 理解钩子的定义,在今后查找钩子时,能起到很大的帮助。 我们再梳理一下服务端钩子和客户端钩子的区别。 服务端钩子需要在 `plugin.json` 中注册侦听器, 然后在侦听器中实现处理。 客户端钩子指的是在 `plugin.json` 下 **scripts** 或 **acpScripts**中注册的库可用的钩子。它通常是一个事件,以下为一个使用例子: ``` $(window).on('action:ajaxify.end', function(event, data) { console.log(data); // 查看 NodeBB 传输进来的数据。 }); ``` 对于客户端钩子我们还应知道: * 客户端钩子通常是一个 `window` 事件或者是 `socket.io` 事件 * `window `事件我们通常使用 jQuery 监听。它通常有两个参数(domEvent,挂钩传入的数据)。例如:`$(window).on('action:ajaxify.end', function(e, data) {}` * `socket.io` 事件需要我们使用 `socket.io` 进行处理(这些挂钩通常需要我们在 NodeBB 源码中寻找)。以下为一个示例: ``` // 摘自:https://github.com/NodeBB/NodeBB/blob/master/public/src/client/chats/messages.js#L48 socket.emit('modules.chats.edit',{   roomId:roomId,   mid:mid, message:msg, }, function(err){ if(err){ inputEl.val(msg); inputEl.attr('data-mid',mid) messages.updateRemainingLength(inputEl.parent()) return app.alertError(err.message) } }) ``` * 客户端库中,NodeBB 全局定义了(暴露给 `window`): `define`(require.js), `$`(jQuery),`socket`(socket.io), `ajaxify`, `app` * 很多时候, 我们需要通过 AMD 来完成操作。如: ``` // 摘自:https://github.com/NodeBB/nodebb-plugin-write-api/blob/master/public/js/admin.js#L4 define('admin/plugins/write-api', ['settings'], function(Settings) { const Admin = {} Admin.init = function() { Admin.initSettings() $('#newToken-create').on('click', Admin.createToken) $('#masterToken-create').on('click', Admin.createMasterToken) $('table').on('click','[data-action="revoke"]', Admin.revokeToken) $('.user-tokens input[readonly], .master-tokens input[readonly]').on('click', function() { // Select entire input text this.selectionStart = 0 this.selectionEnd=this.value.length }) } }) ``` ### NodeBB 中的钩子类型 NodeBB 中将钩子类型划分为 4 类: **filters(过滤器)**,**actions(行为)**,**static(静态)** 以及 **response(响应)**。 **过滤器(Filters)** 是最常用的钩子类型。它作用于内容。如果你想修改在 NodeBB 生命周期中流通的数据(例如: 上传请求中包含的数据, 获取特定页面时返回特定的内容), 他会十分有用。举个简单例子, 过滤器能够使帖子中所有的 `[163Music][/163Music]` 标签替换为网易云的播放框架。同样, 他也可以修改页面中特定的样式。 例如, 为 NodeBB 添加夜间模式。 **行为(Actions)** 钩子会在特定操作执行后触发。如果你想在某些操作执行后,执行一些发放,那么该钩子会十分有用。例如,在用户发表回复后, 发邮箱通知版主。甚至,你还可以通过它记录分析,为新用户发送欢迎邮件。 **静态(Static)** 钩子和 **行为** 挂钩类似,他会在特定操作后触发。它与 **行为** 挂钩的差异在于:它相当于通知,他会立即处理接下来的事务。而 **行为** 挂钩会挂起流程,直到插件操作结束后再恢复流程。 **响应(Response)** 钩子是串行执行的。 他在其中一个侦听器(listener)响应前,和 **行为** 钩子类似。但是一旦有一个侦听器发送响应, 所有之后的插件侦听器会被丢弃。响应钩子常用于错误处理,或页面重定向。**响应**钩子这样的结构设计是用于规避冲突。 >[info] 以上是对于钩子类型的解释。 > 在这提一点, **响应** 钩子的实质就是 express 路由的生命周期。基本定义为:`route.method('/path', fn(request, response, next),. ..,fn(request, response))`。而 **响应** 钩子,即 express 中间件的 **response** 参数 编写插件的第一步,就是确认你想实现的功能依赖的注入点(钩子)是否存在。如果不存在, NodeBB 是十分欢迎你[提交申请](https://github.com/NodeBB/NodeBB/issues),以便在下一个 NodeBB 发行版中可以实现。 P.S:这需要的时间代价很高=,=。 当然,没钩子,咱们也可以自己草个钩子出来嘛。但考虑到本指南的受众的水平,直接修改核心代码风险代价很高,我们暂且不谈。 > 附:[NodeBB 钩子列表](https://github.com/NodeBB/NodeBB/wiki/Hooks/) ## 特殊钩子(服务端钩子) 自动生成的挂钩中有许多常用的但是作用模糊的挂钩。他们通常为:1. 十分有用且常用的,2. 含义模糊且不能从上下文推断作用的,我们将他们列在下方: ### 页面构建钩子 除了在某些操作上会触发的钩子,每当页面加载时(直接访问页面或转换页面)都会触发一组钩子: * `filter:<template>.build` 根据要渲染的页面, 在特定页面触发。例如,`/recent` 路由要渲染 `recent.tpl`,那么,插件可以监听 `filter:recent.build` 钩子,以便在该页面渲染时处理响应。 * 请注意,该钩子不能保证只在一个路由触发。如果有多个路由渲染同一个模板,那么,该钩子都会被触发。例如:SSO 插件都会调用`deauth.tpl`来响应解绑请求,所以任何一个SSO处理解绑请求(渲染`deauth.tpl`),都会触发 `filter:deauth.build` 钩子 * `filter:router.page` 在任何路由都会触发, 且优先级最先(例如, 比模板渲染更早)。 * `filter:middleware.render` 该钩子在渲染页面时触发。这意味着它也是在每个页面都可以被触发(当然是可渲染的,即调用 `res.render` 的页面)。这个钩子在为每个页面添加额外数据, 或更改数据时十分有用。 ### 部件渲染钩子 `filter:widget.render:<widget>` 插件要想定义一个部件的话, 必须要让 NodeBB 知道部件中包含了什么(例如: HTML 和 其他内容)。这是在渲染部件时触发的挂钩处理的。因此,如果设定了一个名为`myWidget`的部件,需要侦听挂钩 `filter:widget.render:myWidget` 来指定部件的内容。 欲了解更多有关编写组件的内容, 可以关注我们稍后将学习的: 组件制作章节。 ## 配置 NodeBB 的每个插件都必须包含一个叫做 `plugin.json` 的配置文件,下面是一个例子: ```json { "id": "nodebb-plugin-myplugin", "url": "插件的仓库地址", "library": "./my-plugin.js", "staticDirs": { "images":"public/images" }, "less": [ "assets/style.less" ], "hooks": [ {"hook":"filter:post.save","method":"filter"}, {"hook":"action:post.save","method":"emailme"} ], "scripts": [ "public/src/client.js" ], "acpScripts": [ "public/src/admin.js" ], "languages": "path/to/languages", "templates": "path/to/templates" } ``` 请注意并不是所有字段都是必要的,但我们通常建议你定义所有字段,以规避错误。 * `id` 是插件的唯一标识。NodeBB会使用 `id` 来引入你的插件。NodeBB 会尝试通过`id`来请求 npm 以获取更新。如果你的插件未来会发布到 npm 上的话,请确保 `id` 与 `package.json` 中的 `name` 字段一致。 * `library` 字段是指定 NodeBB 插件入口的相对路径。 如果插件激活了的话,NodeBB会尝试加载 `library` 字段定义的入口文件。 * `staticDirs` 字段是一个对象表。它可以把插件目录的文件(相对位置)映射到 NodeBB 的 `./public/plugins/{你的插件ID}`下, 即URL `http://你的NodeBB地址/assets/plugins/{你的插件ID}`下。 * 例如: 在样例中的配置下,他会将 `/path/to/your/plugin/public/images` 映射到 NodeBB 目录 `./public/plugins/nodebb-plugin-myplugin/images`下 * `less` 字段是一个路径数组(插件文件夹的相对路径)。NodeBB 的预编译器会在`./nodebb build`时将 less 文件编译为 css 文件,并全局载入。 * `hooks` 是一个带有一组对象的数组。 该对象用于告知 NodeBB, 插件需要哪些钩子,并用哪些方法作为侦听器。以下为该对象的定义: * `hook` 是你需要使用的钩子的标识, * `method` 是你在 `library` 入口文件中暴露出来的侦听器方法。(这也意味着入口文件必须暴露一个对象) * `priority` 是侦听器的优先级。他将决定多个插件同时调用同一个钩子时,调用的先后顺序。默认值为:10 * `scripts` 字段和 `less` 字段类似,定义了一个路径数组。预编译器会在编译时将该字段下的 js 文件编译并优化, 并作用于全局(浏览器/客户端脚本)。 * `acpScripts` 字段和 `scripts` 字段十分相似。但 `scripts` 作用于社区全局(除了控制面板页面),而 `acpScripts` 只作用于 控制面板页面(Admin Control Panel, 简称:ACP) * `modules` 字段则允许你定义AMD风格的第三方库载入 NodeBB 全局,以便插件中的客户端 JS 使用。我们将在稍后详细介绍该字段的作用。 * `languages` 字段则允许你配置插件/主题的 i18n(国际化)支持的文件夹。请使用类似 `/path/to/your/plugin/languages/zh-CN/yourplugin.json` 的文件作为你的核心语言文件。 * `templates` 字段允许你定义一个模板文件夹。该文件夹下包含插件所有的模板目录。建议你使用 `/path/to/templates/yourplugin/` 以及 `/path/to/templates/admin/yourplugin/` 作为你的核心目录。 ## 编写插件 插件的核心时 `library` 文件。当插件启用时,该文件会被 NodeBB 自动加载。 该入口文件暴露的每个侦听器方法都应包含确切数量的参数,具体取决于你需要调用的钩子类型。 * **过滤器** 钩子会提供给你一个包含所有类型的参数。如果你使用回调形式的话,它还支持 cb 参数。以下是一个例子: ```javascript const plugin = {} plugin.FilterListenerCb = function (data, cb) { // 回调形式的侦听器(不再推荐) // 处理一些任务... cb(new Error('这是一个错误')) // 触发错误 cb(null, data) // 交给下一个侦听器处理 } plugin.FilterListenerAsync = async function (data) { // 异步方法形式的侦听器(推荐) // 处理一些任务 throw new Error('这是一个错误') // 触发错误 return data // 交给下一个侦听器处理 } ``` * **行为** 类型的钩子并没有一个通用的参数数目, 参数数目取决于钩子的实现。你可以在 钩子列表 中确认钩子所包含的参数数目。 ### 一个侦听器例子 例如, 我们要写一个方法侦听 `action:post.save` 钩子, 我们得在`plugin.json` 文件的 `hooks` 字段中加入如下的内容: ```json { "hook": "action:post.save", "method": "myMethod" } ``` 而我们在入口文件大概这么写: ```javascript const plugin = { plugin: async function(data) { // 对 data 做一些处理 return data } } module.exports = plugin ``` ### 使用 NodeBB 标准库增强插件功能 该部分我们不过多叙述, 正如我们在前一章节所讲。我们只需要这样,便可使用标准库方法: ``` var user = module.parent.require('./user') async () => { const isUserExist = await user.exists('foobar') } ``` ## 安装插件 在大多数情况下, 插件都应在 npm 上发布,并且应以 `nodebb-plugin-`作为前缀。这样,用户可以很方便得通过 npm 安装你提供的插件。 请注意: NodeBB 将无法发现你的插件, 如果你的插件没有添加 `nodebb-plugin-` 前缀。 ### 在 NodeBB 包管理器(nbpm)中列出你的插件 所有运行的 NodeBB 都可以从 NodeBB 包管理器得到一份可下载插件的清单。NodeBB 包管理器(NodeBB Package Manager)可以缩写为 nbpm。 当你提交插件到 npm 后, nbpm 将自动从 npm 引索。当然,只有你在定义 `package.json` 文件下的 compatibility 字段后, 才会出现在可下载清单中。 要使你的插件出现在可下载清单中, 只需要在你的 `package.json` 文件中添加一个名为 `nbpm` 字段的对象,并在该对象中添加`compatibility`字段。该字段的值为你插件兼容的 NodeBB 版本范围。 你可能不知道你的插件兼容的范围,所以最好的方法便是使用你开发的 NodeBB 版本作为兼容范围。例如, 你正在使用 NodeBB v1.13.0 作为开发环境, 那么你的 nbpm 应该这么配置: ``` { ... "nbbpm": { "compatibility": "^1.13.0" } } ``` 要允许你的插件运行在不同的 NodeBB 版本中(通常是高版本兼容低版本), 你应这么配置: ``` { ... "nbbpm": { "compatibility": "^1.12.0 || ^1.13.0" } } ``` 该字段允许任何有效的 semver 字符串, 你可以在该网站校验你的值:[http://jubianchi.github.io/semver-check/](http://jubianchi.github.io/semver-check/) ### 软连接插件 在发布插件前, 我们通常需要进行多次测试。 [软连接](https://yarnpkg.com/en/docs/cli/link#toc-yarn-link-in-package-you-want-to-link)方式为我们提供了一个便捷的方式,使你的 插件 能方便得链接 到 NodeBB 的 `node_module` 目录下。 在你的插件目录下执行: ``` $ yarn link ``` 然后, 在你 NodeBB 目录下执行 ``` $ yarn link 你的插件名称 ``` 重启 NodeBB,然后,在 ACP 中激活你的插件。执行: ``` $ ./nodebb build && ./nodebb dev ``` 开始调试! ## 添加自定义钩子 在插件中,你可以使用和 NodeBB 相同的挂钩模型。例如, 你可以这样定义一个钩子: ```javascript const Plugins = module.parent.require('./plugins') const plugin = { myMethod: async function (data) { // 处理 data... const result = await plugins.fireHook('filter:myplugin.mymethod', {postData: data}) // 处理 result... } } ``` ## 测试 使用以下指令,进入 NodeBB 调试模式: ``` $ ./nodebb dev ``` ## 禁用插件 你可以简单得通过 ACP 禁用插件。但是, 如果你的 NodeBB 崩溃, 无法进入 ACP 禁用插件得话, 你可以简单得通过命令行禁用所有插件: ``` $ ./nodebb reset -p ``` 此外, 你也可以禁用单个插件: ``` $ ./nodebb reset -p nodebb-plugin-name ``` 或者 ``` $ ./nodebb reset -p name ``` ## 引用第三方(AMD)库 插件能通过 `plugin.json` 的 `scripts` 字段定义要在客户端(浏览器)使用的 JS 库。可有时,你可能需要依赖于一个非项目编写的 JS 库。通常,这些脚本以AMD风格编写(fp,现在大多是 UMD),并且可以由诸如 require.js 之类的模块加载器加载使用。但是,NodeBB通常无法加载他们,因为他们也没有按名称定义(大写问号脸,分明是找借口引用嘛)。 你可能会看到如下的错误: ``` Uncaught Error: Mismatched anonymous define() module ... ``` 如[帮助文档](https://requirejs.org/docs/errors.html#mismatch)中所述,这是 AMD 很常见的一个错误。换句话说,因为我们把 `scripts` 和 `acpScripts` 字段中定义的所有 JS 文件都混淆优化了, 所以模块加载器无法确定引用的上下文。 因此,在 NodeBB 中,我们提供了一种方法,以允许你引入第三方 AMD 库。编辑 `plugin.json`,添加如下的内容: ``` { ... "modules": { "jquery.js": "/path/to/jquery.js" }, ... } ``` 而在客户端 JS 库中, 你可以这样使用 require.js 调用库: ```javascript require(['jquery'], function ($) { $('.someClass').addClass('someotherclass'); }) ``` 请注意,这是一个故意的例子。jQuery 在 NodeBB 中全局可用。 ## 扩展: 如何利用主题来扩展插件功能 NodeBB 中主题系统的实际上是一个允许替换 Express 渲染(render)模板路径的机制。在某些情况下,暴露的钩子不足以完成对于特定页面内容的修改。这时,我们可以借助于子主题功能来替换特定的模板达到目的。 请注意: 1. 主题系统不同于插件系统同时只能激活一个主题。我们**不建议**将其作为“插件”发布供用户使用。但,这对于闭源项目快速开发十分有用。 2. 子主题系统需要依赖于特定的主题。当然其关系可以嵌套。这意味着:你可以利用多个子主题不断嵌套,以同时实现各自的功能。例如: `主题:夜间模式` -> `主题:仿 MiUI 社区` -> `主题:persona` ## 使用工具包快速开发 在稍后的章节,我们会讲解如何通过工具包快速开发一个群发贴的插件。 >[info] 编写: a632079 维护: PA Team 审核: PA Team 最后更新: 2019.12.09