>[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
- 序
- 赞助
- 导言
- 安装
- 通过操作系统
- Windows + Mongodb/Redis
- Ubuntu/Debian + Redis/Mongodb
- CentOS + Redis
- CentOS + Mongodb
- FreeBSD/OpenBSD + Redis
- Arch Linux + Redis
- OSX + Redis
- 通过云服务
- 通过主机面板安装
- AppNode
- CPanel
- 宝塔
- 使用
- FAQ
- 高级
- 运行 NodeBB
- 配置 Config.json
- 配置 Nginx
- 配置 MongoDB
- 更新 NodeBB
- 设置 Widgets
- 安装 Yarn
- 更新 MongoDB
- 数据库备份与恢复
- 重置管理员密码
- 让 NodeBB 支持搜索
- 优化
- 优化配置,提升NodeBB处理能力
- Google字体库 -> 360公共前端库
- Google字体库 -> 中科大镜像
- 海外VPS提升NodeBB访问速度
- 通过 NodeBB API 自动发帖
- 开发
- 准备
- 常用方法 & 变量
- 插件制作
- 使用工具包编写一个插件
- 主题制作
- 使用工具包编写一个主题
- 部件制作
- 国际化
- 钩子(hook)使用说明