这一篇要解决的问题是:当 Runtime 已经能保存能力以后,谁来安装这些能力,谁来撤销这些能力,以及撤销时为什么不能一锅端。为了把这个问题讲清楚,我们会先把“安装逻辑”从调用现场里提出来,再把“安装”和“撤销”绑定到一起,最后让每个 Plugin 都有自己的 cleanup scope。
1. 这一篇要解决的问题
上一篇结束时,Context 已经能够 provide、get、has 和 remove。这足以表达“能力存在”这件事,但还不够表达“能力是由谁装进去的,以及它应该在什么时候被撤掉”。如果调用方始终直接写:
ctx.provide('greeter', ...)那么“定义这个能力长什么样”和“把它安装进当前 Runtime”这两件事就被混在了一起。更进一步说,一旦系统开始需要卸载能力,调用方还得自己知道应该调用哪些 remove(),这会让撤销逻辑四处散落。
所以这一篇真正要解决的是三个连在一起的问题:
谁来安装能力?
安装造成的改变怎样撤销?
为什么撤销时必须区分不同 Plugin 的边界?这对应 mini-cordis 里的 v0.4、v0.5 和 v0.6。
这一篇的主线可以先压缩成下面这条路径:
直接 provide
↓
Plugin 安装单元
↓
effect 绑定 cleanup
↓
scope 隔离每个 Plugin 的撤销边界2. 上一阶段卡在哪里
如果 Runtime 只有 Service Registry,那么它其实还只是一张更聪明的 Map。调用方自己构造对象、自己起名字、自己决定什么时候 provide,系统内部并没有一个“安装单元”的概念。这样一来,能力的安装逻辑既不能复用,也很难单独讨论所有权。
即便进一步加入 ctx.dispose(),如果它只是把整个 Context 上的所有 cleanup 一次性清掉,也仍然不够。因为真实场景里我们往往不是想“把整个世界一起销毁”,而是想“只卸载这个 Greeter Plugin,保留 Logger 和其他模块继续运行”。一旦没有边界,撤销逻辑就会立刻失去精度。
因此,这一阶段的问题不只是“能不能撤销”,而是:
Runtime 必须知道一组改变是由谁带来的,才能在未来只撤销它,而不误伤其他东西。
3. 这一篇引入什么机制
第一步是把“安装逻辑”单独抽出来,定义一个最小的 Plugin:
export type Plugin = (ctx: Context) => void这意味着调用方不再直接操作安装细节,而是通过:
ctx.plugin(greeterPlugin)把“怎样往 Context 里装东西”这件事交给 Plugin 自己。
第二步是引入 effect()。它的核心想法很简单:setup 和 cleanup 最好从一开始就写在一起。
ctx.effect(() => {
ctx.provide('greeter', ...)
return () => {
ctx.remove('greeter')
}
})这一步非常关键,因为它第一次把“做出改变”和“如何撤销这个改变”绑成了一个整体,而不是把撤销责任留给未来的调用方去补。
第三步是引入 Scope。effect() 产生的 cleanup 不能永远都记到同一个全局列表里,否则 dispose() 只能粗暴地全撤。MiniCordis 的做法是让每次 plugin() 安装时都临时打开一个新的 scope,把这个 Plugin 注册的 cleanup 都记进去,最后返回一个只负责撤销这一组 cleanup 的 dispose()。
于是 Runtime 的模型从:
Context
└── cleanups[]变成:
Context
└── currentScope
└── plugin-specific cleanups4. 代码里真正加了什么
从代码上看,这一阶段最关键的是三个方法:plugin()、effect() 和一套 scope 切换机制。最终它们会收束成类似下面的结构:
type Cleanup = () => void
type EffectSetup = () => Cleanup | void
effect(setup: EffectSetup): void {
const cleanup = setup()
if (cleanup) {
this.currentScope.push(cleanup)
}
}
plugin(fn: Plugin): () => void {
const parentScope = this.currentScope
const scope: Cleanup[] = []
this.currentScope = scope
fn(this)
this.currentScope = parentScope
let disposed = false
const dispose = () => {
if (disposed) return
disposed = true
while (scope.length) {
scope.pop()!()
}
}
parentScope.push(dispose)
return dispose
}这里真正重要的不是代码具体长什么样,而是背后的语义:Plugin 不等于某个 Service,本质上它是“对 Context 施加一组改变的安装单元”;而 effect() 则保证这些改变一开始就带着自己的撤销方式。Scope 再把这些撤销动作按 Plugin 边界隔离开来,于是 Runtime 第一次能够精确地说:“只把这一组改动收回去。”
这一步也为后面很多机制提前埋好了路。因为一旦有了“当前活跃 scope”这个概念,后面的 Event listener、依赖激活和 Child Context 级联清理,其实都可以落回到同一个模型上。
5. 这一篇的结论
这一篇最重要的结论是:从 Service Registry 走到真正的 Runtime,中间最关键的一步不是再加几个 Map,而是建立“安装单元”“可逆 effect”和“按 Plugin 边界隔离 cleanup”这三个概念。只有这样,Runtime 才能不只是保存能力,还能真正管理能力的所有权和生命周期。
下一篇会在这个基础上继续推进。只要再加入一个“模块之间不应该直接互相认识,但又要互相感知”的需求,现有的 effect 和 scope 机制就会自然长成 Event 系统。