插件开发
约 2200 字大约 7 分钟
2026-09-15
三种形态,选一个就行
| 形态 | 写法 | 适用场景 |
|---|---|---|
| 云崽插件 | export class X extends plugin | 从 TRSS-Yunzai / Yunzai 迁移过来的插件,零改动 |
| JiuLi 类形态 | export class X extends JiuLiPlugin | 新写的插件,想要类型提示与渲染 / 数据目录辅助 |
| JiuLi 函数式 | definePlugin({ apps: [command(...)] }) | 不喜欢 this,偏好函数式 |
三种写法加载进同一套注册表与分发流程,可以混用。旧插件不用改一个字, 新插件能用上分发钩子、onLoad/onUnload、useConfig 这些新增能力。
生成骨架
node jiuli c my-plugin # 生成 plugins/example/my-plugin.js(类形态)
node jiuli c my-plugin --dsl # 函数式 DSL
node jiuli c my-plugin --dir # 生成独立目录 plugins/my-plugin/(含 resources/ 与 package.json)目录约定
plugins/ 下每个子目录是一个插件包:
plugins/
├─ example/ 你自己的插件(平铺 .js,一个文件一个功能)
├─ system/ adapter/ 框架内置插件与适配器(随仓库)
├─ demo/ 示例插件(三种写法的参考实现)
└─ my-plugin/ 第三方 / 独立插件包
├─ package.json 有依赖时才需要(pnpm workspace 会自动装)
├─ index.js 入口
├─ apps/ 拆分的命令文件(由 index.js 引入)
├─ model/ 业务逻辑
├─ resources/ 渲染模板与静态资源
└─ defSet/ 默认配置加载规则
- 只扫描
plugins/下的目录,目录里的.js才算插件(放在plugins/根下的散文件不会被加载) - 目录里有
index.js→ 只加载它,其余文件由它自己import - 目录里没有
index.js→ 加载该目录下的所有.js(不递归)
一个文件可以导出多个插件类(export class A、export class B 都会被注册)。
热重载
内核监听整个 plugins/ 目录,以下变化约 2 秒内自动生效:
- 新增 / 修改 / 删除
.js(包括index.js与apps/、lib/深层文件) - 新建插件目录
重载会把该插件的整张模块图重新求值 —— 不只是 index.js,它 import 进来的 apps/*.js、lib/*.js 也都换成新实例。所以目录型插件改深层文件同样生效,不需要重启。
整图重载依赖 Node 的同步模块钩子(
module.registerHooks,Node 22.15+)。 更老的 Node 上只有入口文件会重新求值,目录型插件(index.js+apps/+lib/)改深层 文件后仍需#重启;启动日志里会有一行提示,#版本能看到当前 Node 版本。
重载与卸载时会清理该插件注册的 express 路由与 WebSocket 路径,所以自带 Web 面板的插件 (如 hl-icqq-web、hl-douyin-plugin)反复重载也不会出现「路由重复注册」。
重载后模块初始化失败(循环依赖撞 TDZ)
重载不只是"再跑一遍代码",还会让整张模块图的求值顺序变化。循环依赖下的顺序本来就是 脆弱的,所以下面这种写法冷启动可能没事、一重载就炸:
// model/db/BaseModel.js
import { Data } from '#index' // 而 #index 又与 base.js、gsCfg.js 互成环
Data.createDir('/data/db', 'root') // 模块顶层就用了环上的导出ReferenceError: Cannot access 'Data' before initialization
at plugins/xxx/model/db/BaseModel.js?jiuli_reload=2:5:1这类失败发生在模块求值里,可能被插件自己的 Promise.allSettled 吞掉,表现为「日志里一堆 ERROR,但该插件的指令全没了」。框架会认出这种「重载导致的模块初始化失败」并自动重启恢复 (新进程的求值顺序回到冷启动那份;两分钟内只自动重启一次,bot.yaml → reload_restart_on_error: false 可关掉)。
根治:拆掉环,或把顶层副作用挪进函数、改成惰性执行:
import { Data } from '#index'
function ensureDir() { Data.createDir('/data/db', 'root') } // 运行期再执行关掉监听:config/config/bot.yaml → file_watch: false。
#重载 只用于自动机制覆盖不到的情况:
| 用法 | 作用 |
|---|---|
#重载 | 补漏:把磁盘上有、但还没加载的插件加载进来(刚 clone、刚补装依赖) |
#重载 <关键词> | 强制刷新:重载名字里含该关键词的插件,例如 #重载 miao-plugin(同时解除它的重载熔断) |
哪些改动会重载
- 入口、
apps/、lib/、model/这些代码目录里的.js:改了立即重载。 - 插件私有配置 / 数据目录(
config/、data/、resources/、temp/)里的.js: 只有"看起来是插件模块"(有插件类,或带rule/apps/task/accept/handler/fnc/reg这些结构)才重载;纯export const a = false这种配置 / 数据不重载。 要让私有配置生效:发#重载 <插件名>或#重启。 - 删除嵌套文件(
apps/x.js)不会卸载整个插件,只重载一次;只有入口 (目录插件的index.js、文件型插件自己的.js)被删才算插件被删。 - 模板 / 图片等非
.js资源不触发重载,渲染层自带缓存失效。
插件不要在自己的模块顶层写回自己目录下的文件(miao 的
config/cfg.js曾经这么干): 写文件 → 触发重载 → 加载时又写一遍,2 秒一轮的自激循环,每轮都会永久占住一张模块图 (ESM 缓存不释放旧模块图),几十秒就能把内存打爆。 框架对此有两道闸:结构判定(上面第二条)和自激改写跳过 —— 若某文件的修改时间落在 该插件自己的求值窗口内(即"刚加载时自己写的"),这次重载直接跳过并打一条 ERROR。 此外还有熔断:同一插件在窗口内自激重载达到perf.reload_limit(默认 5 次 / 60 秒)就 暂停它的重载监听(只暂停它,其它插件不受影响),修好根因后#重载 <插件名>恢复。 人工在编辑器里改文件不计入熔断,开发者连着存十几次代码也不会被暂停。 正确做法:把写文件挪到运行期(指令或定时任务里),或改存成config/、data/下的.json/.yaml等非.js文件。 已知边界:异步延迟写回(加载完过几秒再写)不在"求值窗口"里,自激跳过抓不到, 由全局频率告警(perf.reload_global_limit)暴露出来 —— 看到就按上面的办法改。
模块级资源要自己交接
整图重载意味着模块顶层的代码会再跑一遍,所以资源要分两类处理:
| 资源建在哪 | 怎么清理 |
|---|---|
插件实例上(onLoad() 里建、挂在 this 上) | 写在 onUnload() 里,框架在卸载 / 重载前调用 |
模块顶层(setInterval / fs.watch / 数据库句柄 / 浏览器 / 全局监听 / 自己 listen 的服务) | 在入口里把「交还资源」的函数挂到 globalThis,新的一份加载时先 await 它 |
// index.js(目录型插件入口)
if (typeof globalThis.__myPluginHandoff === 'function') {
try { await globalThis.__myPluginHandoff() } catch (e) { logger.warn('上一个模块图交还资源失败', e) }
}
startWatcher() // 模块顶层建的资源
globalThis.__myPluginHandoff = () => stopWatcher() // 下一次重载先收它用 globalThis 而不是模块级变量:模块级变量在重新求值后已经清零,新的一份看不见旧的握着 什么。不交接的话,每热重载一次就多一份 —— 定时任务跑两遍、文件变更处理两次、浏览器进程 留在系统里。
onUnload()目前只在「该文件里最后一次注册的插件类实例」上调用(目录型插件即index.js里最后注册的那个类)。同一文件导出多个类时只有最后一个会收到回调, 清理进程级资源请用上面的入口交接。
自带 Web 面板的插件
用 Bot.express / Bot.wsf 注册的路由与 WebSocket 路径会记在当前正在加载的那个插件 名下,卸载 / 重载时按这份账清理。约定只有三条:
- 在加载期注册(模块顶层、
init()/onLoad()里)—— 这是账能对上的前提。 不要在重载后又手动补注册一份,也不要自己去改app._router.stack,那样框架清不掉旧层 - 自己
listen的独立服务要自己在交接函数里close():框架只管Bot.express上的路由 与Bot.wsf里的路径,插件自己http.createServer(...).listen(端口)起的服务不在记账范围 内,不关就会在重载后报EADDRINUSE - 换端口 / 换路径这类只在导入期读一次的配置,改了要重载(或
#重启)才生效
最小示例
import { JiuLiPlugin } from 'jiuli'
export default class Hello extends JiuLiPlugin {
constructor() {
super({ name: '你好', rule: [{ reg: '^#你好$', fnc: 'hello' }] })
}
async hello(e) {
await e.reply('你好呀')
}
}接着看:
