在这里插入图片描述

上一章我们聊了插件怎么"挂"上去——通过 inject 声明需要什么服务,框架帮它把依赖凑齐。这一章反过来,问一个更扎手的问题:

一个插件被卸掉了,它之前注册的那些东西——事件监听、工具、定时器——会自己消失吗?

答案可能让你意外:不会。除非你明确告诉框架怎么收尾。

而整套热重载(改完代码保存,页面不用刷新就能生效)之所以能工作,全押在"能收尾"这一条上。


3.1 先看看"不管收尾"会出什么事

想象一个场景。你写了一个插件,干了三件事:

  1. 往工具箱里放了一个叫 read_file 的工具
  2. 监听一个叫 tool/result 的事件,每次工具运行完就记一笔
  3. 每隔 5 秒把统计结果打印到日志里

代码大概长这样:

// 反面教材 —— 这三行都没有"撤销"的机制
export function apply(ctx) {
  toolRegistry.push(readFileTool)              // ① 往全局工具箱里塞东西
  eventBus.on('tool/result', countIt)          // ② 挂一个事件监听器
  setInterval(() => console.log(stats), 5000)  // ③ 开一个定时器
}

现在你改了一行代码,保存文件。热重载触发了:

  • 旧插件被卸载 —— 但卸载的时候啥也没干,因为这三行没有一行提供了"怎么撤销"
  • 新插件被加载 —— 又把这三行跑了一遍

现场变成了什么样子?

  • 工具箱里现在有 两份 read_file。模型调用工具时看到重复项,而且第一份指向的是已经作废的旧代码——那份代码还在内存里,因为工具箱还拽着它不放。
  • 事件上挂了 两个 监听器。每次工具返回结果,"统计数字加一次"这个操作被执行了两遍。你会以为工具被调用了两次。
  • 定时器有 两个 在跑。日志每 5 秒出两条,两条数字还不一样——因为它们各自记各自的账。

再改一次代码,就变成三份。而从头到尾,没有任何报错

为什么这类 bug 特别难查

它只在"重新加载过"之后才出现。你在本地改了十几次代码,发现数字开始不对劲;同事从他那边从头启动一次,一切正常。你关掉重开,也正常。

这个故障绑定的是"你做过哪些操作"的历史,而不是绑定"当前代码长什么样"。复现不出来,就修不掉。

这不是假设。有人在 Cordis 里挂了一个插件,apply 里就一行裸的 setInterval,没有做任何包装。等它跑了一会儿,调用卸载方法把插件卸掉,然后继续观察——卸载之后的 100 毫秒里,这个定时器又触发了 5 次。插件已经"不在了",但它留下的定时器还在跑。

为什么框架拦不住?

简单说:框架只管"经过它登记"的东西。你把定时器的句柄直接丢给 setInterval,这个句柄从头到尾没经过 ctx,框架不知道有这么个东西存在,所以卸载的时候根本不知道要去停它。

这就好比你用图书馆的系统借了书(系统有记录),和你自己偷偷把书塞进包里带出去(系统没记录)——关门的时候,系统只会追回它有记录的那批书。


3.2 ctx.effect():拿资源的时候,顺手把"怎么还"也交代了

Cordis 给的解法很简单:在获取任何"框架管不着"的资源时,用 ctx.effect() 包起来,同时告诉框架"将来怎么释放它"。

function heartbeat(ctx: Context) {
  ctx.effect(() => {
    // 获取资源:开一个定时器
    const timer = setInterval(() => console.log('tick'), 200)
    
    // 返回释放函数:告诉框架怎么关掉这个定时器
    return () => {
      clearInterval(timer)
      console.log('定时器已清理')
    }
  })
}

这段代码跑起来的效果是:

  1. 插件加载时:打印"tick"(每 200 毫秒一次)
  2. 插件卸载时:打印"定时器已清理",然后 tick 停止

结构就两层:

  • 外层(effect 体):加载时立刻执行,负责"拿资源"
  • 内层(return 的函数):卸载时由框架调用,负责"还资源"

打个比方

你去图书馆借书。借书的时候,管理员把书给你,同时在系统里记一笔——“谁、什么时候、借了什么书”。闭馆的时候,系统自动发通知催还,不需要你主动去记"我该还书了"。

这里的关键区别是:你几乎不需要主动调用释放函数。框架会在插件卸载时替你调用。教程的原话是:对于生命周期和插件绑在一起的资源,你绝不需要自己调用 disposer。

什么东西需要包 ctx.effect()

判据很直接:Cordis 不认识的东西

  • 定时器(setInterval / setTimeout)—— 不认识
  • 网络连接(WebSocket、数据库连接)—— 不认识
  • 文件监听(fs.watch)—— 不认识
  • 你自己维护的一个全局数组或 Map —— 不认识

而 Cordis 自己的 API(事件监听、工具注册、服务提供)—— 它认识,所以已经帮你处理好了。


3.3 哪些操作已经自带"撤销"了(新手最容易白费力气的地方)

看到这里,很多人会紧张过头,把每一行注册都包一层 ctx.effect()

千万别。 Cordis 的内置注册 API 自己已经带撤销机制了。

举个例子:你什么都不用写

// 这样就行了 —— 不需要额外包 effect
ctx.on('tool/result', countIt)

在 Cordis 源码里,ctx.on() 内部已经调用了 ctx.fiber.effect(),把移除监听器的函数登记上去了。卸载插件时,监听器会被自动移除。

哪些是"自带撤销"的?

你写的代码卸载时框架自动做什么
ctx.on('事件', 监听器)移除这个监听器
ctx.plugin(子插件)子插件一起卸载
ctx.provide('服务名', 实现)注销这个服务
ctx.tools.register(工具)从工具箱里移除这个工具
ctx.systemPrompt.section(...)撤下这段提示词

拿 3.1 那个反面教材来说,三行里有两行根本不需要你操心

  • eventBus.on(...) —— 在真 Cordis 里应该写成 ctx.on(...),自带撤销
  • toolRegistry.push(...) —— 在真 Cordis 里应该写成 ctx.tools.register(...),自带撤销

只有 setInterval 那一行,框架不认识,需要你手动包 ctx.effect()

最常见的两个错误

错误一:重复包装。

// 多此一举
ctx.effect(() => ctx.on('x', f))

这不会报错,但让读代码的人以为 ctx.on 需要手动清理,于是他在别的地方也照抄,徒增理解成本。

错误二:自己另找地方清理。

// 有害
const listeners = []
listeners.push(ctx.on('x', f))
process.on('exit', () => listeners.forEach(...))

插件卸载(而不是进程退出)才是常态。热重载、改配置、换服务提供方,都会触发卸载但进程不退。你挂在 process.on('exit') 上的清理根本不会执行。

一个让人安心的数据

在 Cordis 整个仓库里数了一遍:

  • 自带撤销的注册 API 被调用了约 401 处
  • 需要手写 ctx.effect() 的约 165 处

也就是说,大约 71% 的注册动作,插件作者一个字都不用多写。框架把"可逆"做进了 API 本身——正确的写法同时也是最省事的写法。

剩下那 29% 是"纪律"——定时器、连接、文件监听这些,得自己包。哪个插件忘了,热重载就在哪个插件上悄悄失效,不报错。


3.4 一个插件实例的"生命周期":六种状态

每个被加载的插件实例,框架里都有一个对应的"句柄",叫做 fiber

同一个插件被加载三次,就有三个 fiber,各自独立。卸载其中一个,不影响另外两个。

一个 fiber 有六种状态,可以想象成一条流水线:

等待中 → 加载中 → 运行中 → 卸载中 → 已销毁
                 ↘ 失败
状态什么意思接下来发生什么
等待中插件刚创建,但它要的服务还没凑齐等依赖到齐了 → 开始加载
加载中正在执行插件的 apply 函数跑完了 → 进入运行中;抛异常 → 回滚后进入失败
运行中正常工作依赖没了 / 被卸载 → 开始卸载
失败启动时抛了异常,或配置没通过校验修好配置后可以重试
卸载中正在执行释放函数释放完了:还要用 → 重新加载;彻底移除 → 已销毁
已销毁彻底没了出不去了,不能重启

"运行中"是怎么判断的?

有意思的是,fiber 的"状态"并不是某个变量存着的,而是实时推算出来的

  • 如果句柄被清空了 → 已销毁
  • 如果记着一个错误 → 失败
  • 如果依赖都凑齐了 → 运行中
  • 否则 → 等待中

"依赖都凑齐了"是怎么判断的?框架把插件 inject 的每个服务名,和"当前是谁在提供这个服务"的编号拼成一个字符串。比如一个插件 injectgreeter,当前提供 greeter 的是编号为 #3 的 fiber,那 epoch 就是 "greeter:3"

如果 #3 被卸载了,换成了 #7 来提供 greeter,epoch 变成 "greeter:7"——变了。依赖这个服务的所有插件就会被发现,然后自动卸载、重载。这就是上一章说的"依赖持续追踪"的全部实现。

插件卡住了怎么办?

三种"没反应",三种查法:

  • 卡在"等待中":缺服务。检查它 inject 了什么,谁应该提供。
  • 卡在"失败":启动时抛了异常。去日志里找错误信息。
  • 卡在"卸载中":某个释放函数一直不结束。往下看 3.5 节。

还有一个信号:如果你在调用 ctx.effect()ctx.on() 时看到 cannot create effect on inactive context,说明你手里的 ctx 属于一个已经被卸载的插件。最常见的成因是把 ctx 存进了闭包,在延迟回调里用它注册东西。


3.5 释放顺序:倒着启动,但一起跑

前面说框架会在卸载时替你调用释放函数。但有个细节容易踩坑。

先看源码怎么写的

Cordis 把所有释放函数存在一个列表里。卸载时,它做两件事:

  1. 把列表倒过来 —— 最后注册的,最先被启动
  2. Promise.all 同时启动它们 —— 所有释放函数一起跑,不等谁先结束

一个具体例子

假设你按 A、B、C 的顺序注册了三个 effect。各自的释放函数耗时不同:A 要 120 毫秒,B 要 60 毫秒,C 是同步的(瞬间完成)。

卸载时打上时间戳,看到的是:

C 启动  0ms      ← 最后注册的,最先启动
B 启动  0ms      ← 三个都在同一刻启动
A 启动  0ms
B 完成  60ms     ← 60ms 的先结束
A 完成  120ms    ← 120ms 的后结束

关键两件事:

  1. 启动顺序是逆序:C 先,B 其次,A 最后
  2. 它们是同时启动的:B 不等 A,A 也不等 B。总耗时取决于最慢的那个(120ms),不是三个加起来

这个坑长什么样?

你写了一个插件,做了两件事:

  1. 先建立数据库连接
  2. 再开一个写入器,每秒往数据库写一笔数据

卸载时,你以为"逆序释放" = 先停写入器、再关连接。但实际上两个释放函数同时启动。如果"关连接"是同步操作、瞬间完成,而"停写入器"要等最后一笔数据写完,那结果就是:连接先断了,最后一笔数据落在一个已经关掉的连接上——报错。

怎么解决?

如果几个清理步骤必须按顺序执行,把它们放在同一个释放函数里,在里面依次 await

// 正确写法:两个步骤串行
ctx.effect(() => {
  const conn = createDbConnection()
  const writer = createWriter(conn)
  
  return async () => {
    await writer.stop()    // 第一步:先停写入器
    await conn.close()     // 第二步:再关连接
  }
})

同一个 effect 内部的多个 await 是串行的。不同 effect 之间的释放才是并发的。

还有一条硬规矩:释放必须真正停稳,而不是只发一个"你该停了"的信号就返回。 如果只发信号不等结果,就会留下孤儿进程。


3.6 热重载凭什么能工作?

热重载看起来像魔法:你改一行代码保存,页面上的功能就更新了,不用刷新、不用重启。

其实拆开看就两件事:

  1. 旧插件卸载干净(这一章的内容)—— 所有注册、监听、定时器都收回
  2. 新插件按依赖加载(上一章的内容)—— 新实例自动等它要的服务就绪

热重载插件本身做的事情极其简单:

监听到文件变化 → 卸载旧插件 → 加载新插件

就这三步。没有更复杂的魔法。

一句话记住: 热重载不是框架送给你的额外礼物,它是"卸载能卸干净"加上"加载遵循依赖"这两条性质的必然结果。

卸载会递归,而且会等

如果一个插件挂载了子插件,子插件又挂了孙插件:

父插件
  └── 子插件
        └── 孙插件

调用父插件的卸载,框架会:

  1. 先清理父插件自己的 effect
  2. 再递归卸载子插件
  3. 子插件再递归卸载孙插件
  4. 等所有层级的清理都完成,才返回

实测:三层结构,每一层都打一条日志,调用最外层的卸载并 await 之后,三条日志全部出现了。没有一层被漏掉。

注意:日志的打印顺序可能让你意外

实测看到的顺序是:父先打印,然后子,然后孙。你可能会想:“3.5 节不是说逆序释放吗?那应该是孙先、子后、父最后才对。”

原因是:父插件有两个释放动作——① 卸载子插件(异步,要等子清理完),② 清理自己的定时器(同步,瞬间完成)。两者同时启动,同步的那个瞬间就打印完了,异步的那条链还在往下走。所以看到的是"父先打印",但父的异步卸载实际上还在进行。

这再次验证了 3.5 节的规则:逆序的是启动,不是完成。

那如果释放函数永远不结束呢?

这是 Cordis 的一个设计选择,也同时是一个争议点。

源码里,_unload()Promise.all() 等所有释放函数完成,没有任何超时机制——整个 fiber.ts 文件 754 行,一个定时器都没有。

这意味着:只要有一个释放函数永远不结束(比如等一个永远不会回来的网络响应),整个卸载过程就会永远卡住。热重载卡住、配置更新卡住、依赖它的其他插件重启也卡住。

官方文档的要求是"dispose 必须达到完全停稳,而不仅仅是请求停止"——这是对插件作者的要求。但它没有回答:插件做不到的时候,框架该怎么办?

  • 设超时强行往下走 → 可能留下只清理了一半的现场
  • 无限等下去 → 可能把整棵树拖死

两种做法都有道理,也都有翻车的案例。目前 Cordis 选了"无限等"这一边。你可以自己去比较 Kubernetes(选了超时)和 systemd(也选了超时)的做法,形成自己的判断。


本章小结

  • 不可逆的注册在热重载之后会叠加,不是被替换。工具表里多一份、监听器多一个、定时器多一个,而且不报错。这类 bug 和操作历史绑定,复现不出来。
  • 解法是 ctx.effect():获取资源的同时,把"怎么释放"也交给框架。但多数时候你不需要亲自写——Cordis 自己的 API 已经自带撤销了。大约 71% 的注册动作是框架保证的。
  • 每个插件实例有一个 fiber,六种状态是实时推算出来的。卡住的时候,先看它停在了哪个状态。
  • 释放函数逆序启动,但并发推进。要顺序执行,就把多个步骤放进同一个释放函数里。
  • 热重载不是魔法,是"卸载能卸干净"加上"加载遵循依赖"的推论。哪个插件破坏了可逆性,热重载就在那儿静默失效。
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐