>[danger] **棄用提醒:**
> *由于看云對于免費用戶的限制愈發嚴苛,本文檔已經遷移至語雀。本文檔將不做維護。*
> **語雀地址**:[https://www.yuque.com/a632079/nodebb](https://www.yuque.com/a632079/nodebb)
*****
# 插件制作
## 導言

NodeBB 支持插件系統,你可以通過編寫插件擴展功能。在開始編寫插件之前,我想你一定對它的實現很感興趣。
和 WordPress 類似, NodeBB 的插件系統是基于鉤子(Hook)模型實現的 。這種方式能使插件在受限制的情況下修改鉤子提供的數據,或在觸發鉤子時執行某些方法。
我們可以在[這里](https://github.com/NodeBB/NodeBB/wiki/Hooks/)找到所有受支持的鉤子,了解到鉤子大概的作用。
[TOC]
## 鉤子系統
我們先簡單了解下定義:
**鉤子系統**,也稱鉤子編程(hooking),簡稱作掛鉤,是計算機程序設計術語,指通過攔截軟件模塊間的函數調用、消息傳遞“訊息傳遞 (軟件)”)、事件傳遞來修改或擴展操作系統、應用程序或其他軟件組件的行為的各種技術。處理被攔截的函數調用、事件、消息的代碼,被稱為**鉤子**(hook)。
在導言中我們簡單介紹了鉤子的機制,讓我們借用 Vue.js 的生命周期流程圖更深入的認識這個機制。

沒錯, 像 `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)使用說明