幽灵从哪里来
回到小周的第一个坑。旧天气插件卸掉以后,它挂的定时任务还在每小时跑一次。不是小周不认真,是写法本身在给人挖坑。
大多数插件系统让你写两个函数:装的时候调一个,卸的时候调另一个。装的时候开了三样东西,卸的时候就得记得关三样。两份清单分开写、分开改,全靠人脑对齐。今天加了一个定时器,忘了在另一个函数里补一行,幽灵就诞生了。论文把这叫作「创建和清理被拆开,违反了关注点的就近原则」。
论文的办法:做一件事,交一张回执
论文的第一个思想叫可逆效应:每一个对外部的改动,在发生的当场就附带一个撤销办法,交给运行时保管。
注意两个词。当场:撤销办法和改动写在同一处,不可能只写一半。运行时保管:撤销办法不交给作者自己记,而是由框架收着,到时候框架来执行。
// 装的时候function activate() { db = openDatabase() bot.on('message', onMessage) timer = setInterval(check, 60000)}// 卸的时候,另写一份function deactivate() { bot.off('message', onMessage) db.close()// 少了 clearInterval(timer),幽灵诞生}export function apply(ctx) {ctx.effect(() => { const db = openDatabase() return () => db.close() // 回执}) ctx.on('message', onMessage) ctx.setInterval(check, 60000)}// 没有卸载函数。// on、setInterval 都走 ctx,// 回执由框架自动登记。看不懂代码没关系,看两边的形状就够了:左边有两个函数,右边只有一个。
回执串:倒着撕
一个插件装上时往往要做好几件事。运行时把它们的回执按顺序串成一串,论文叫累加器,本书叫回执串。卸载时只做一件事:从最新的那张开始,倒着一张张执行。
倒着撕不是随便定的。后做的事经常要用先做的事:先开连接,再在连接上挂监听。如果先关连接,后面那些还没撤的东西就会撞上一个已经关掉的连接。倒序,正好让每件事在撤销时,它依赖的东西都还在。
半路失败也一样好办。装到第三步出了错,回执串里只有前两张,运行时就只撤这两张。不会多撤,也不会漏撤。
拼起来,还是可撤销的
回执串有一个很省心的性质:两个可撤销的东西拼在一起,还是可撤销的。

一整串,可以当一张用
一个插件可以在自己里面再装子插件。子插件有自己的回执串;从父插件的角度看,这一整串只算一张回执。
卸掉父插件时,撕到这一张,就把子插件整串倒着撕完,再接着撕父插件自己的。嵌套多少层都一样,没有一层需要额外写清理代码。
这也是 Koishi 的新手作者不写卸载代码也能干净退场的原因 p.69:只要事情是经过 ctx 做的,回执就自动在串上。
它不保证什么
可逆效应很强,但有两个边界,论文自己写得很清楚。
第一,撤销办法写得对不对,框架不检查。你交了一张回执,框架就照着执行;回执本身写错了,框架发现不了。这是作者的义务 p.59。
第二,只有经过 ctx 的事才有回执。插件绕开 ctx,自己偷偷改了一个全局变量,框架根本不知道这件事发生过,自然也撤不了。这正是第 06 章要讲的「一扇门」。至于已经发给用户的消息、写到磁盘上的文件,那是另一种撤不回,第 10 章再说。
撤销办法不许另写:做事的当场交出来,由运行时保管,卸载时倒着执行。
请检查并重构这个项目里所有「注册类」操作(事件监听、定时器、命令、路由、全局状态写入): 1. 每个注册操作都在同一处返回一个撤销函数,不要另写集中式的卸载函数; 2. 用一个列表按顺序收集这些撤销函数,卸载时按相反顺序依次调用; 3. 如果安装进行到一半失败,只调用已经收集到的撤销函数; 4. 改完后,列出原来漏掉撤销的地方。
