技术·MiniCordis:从零实现一个插件化 Runtime · 第 3 篇·2026-08-27·8 分钟

第二篇:从直接 provide 到 Plugin、Effect 和 Scope

这一篇要解决的问题是:当 Runtime 已经能保存能力以后,谁来安装这些能力,谁来撤销这些能力,以及撤销时为什么不能一锅端。为了把这个问题讲清楚,我们会先把“安装逻辑”从调用现场里提出来,再把“安装”和“撤销”绑定到一起,最后让每个 Plugin 都有自己的 cleanup scope。

1. 这一篇要解决的问题

上一篇结束时,Context 已经能够 provide、get、has 和 remove。这足以表达“能力存在”这件事,但还不够表达“能力是由谁装进去的,以及它应该在什么时候被撤掉”。如果调用方始终直接写:

ts
ctx.provide('greeter', ...)

那么“定义这个能力长什么样”和“把它安装进当前 Runtime”这两件事就被混在了一起。更进一步说,一旦系统开始需要卸载能力,调用方还得自己知道应该调用哪些 remove(),这会让撤销逻辑四处散落。

所以这一篇真正要解决的是三个连在一起的问题:

text
谁来安装能力?
安装造成的改变怎样撤销?
为什么撤销时必须区分不同 Plugin 的边界?

这对应 mini-cordis 里的 v0.4、v0.5 和 v0.6。

这一篇的主线可以先压缩成下面这条路径:

text
直接 provide
   ↓
Plugin 安装单元
   ↓
effect 绑定 cleanup
   ↓
scope 隔离每个 Plugin 的撤销边界
Plugin 与 Scope 总览图
Plugin 与 Scope 总览图

2. 上一阶段卡在哪里

如果 Runtime 只有 Service Registry,那么它其实还只是一张更聪明的 Map。调用方自己构造对象、自己起名字、自己决定什么时候 provide,系统内部并没有一个“安装单元”的概念。这样一来,能力的安装逻辑既不能复用,也很难单独讨论所有权。

即便进一步加入 ctx.dispose(),如果它只是把整个 Context 上的所有 cleanup 一次性清掉,也仍然不够。因为真实场景里我们往往不是想“把整个世界一起销毁”,而是想“只卸载这个 Greeter Plugin,保留 Logger 和其他模块继续运行”。一旦没有边界,撤销逻辑就会立刻失去精度。

因此,这一阶段的问题不只是“能不能撤销”,而是:

Runtime 必须知道一组改变是由谁带来的,才能在未来只撤销它,而不误伤其他东西。

3. 这一篇引入什么机制

第一步是把“安装逻辑”单独抽出来,定义一个最小的 Plugin:

ts
export type Plugin = (ctx: Context) => void

这意味着调用方不再直接操作安装细节,而是通过:

ts
ctx.plugin(greeterPlugin)

把“怎样往 Context 里装东西”这件事交给 Plugin 自己。

第二步是引入 effect()。它的核心想法很简单:setup 和 cleanup 最好从一开始就写在一起。

ts
ctx.effect(() => {
  ctx.provide('greeter', ...)
  return () => {
    ctx.remove('greeter')
  }
})

这一步非常关键,因为它第一次把“做出改变”和“如何撤销这个改变”绑成了一个整体,而不是把撤销责任留给未来的调用方去补。

第三步是引入 Scope。effect() 产生的 cleanup 不能永远都记到同一个全局列表里,否则 dispose() 只能粗暴地全撤。MiniCordis 的做法是让每次 plugin() 安装时都临时打开一个新的 scope,把这个 Plugin 注册的 cleanup 都记进去,最后返回一个只负责撤销这一组 cleanup 的 dispose()。

于是 Runtime 的模型从:

text
Context
  └── cleanups[]

变成:

text
Context
  └── currentScope
       └── plugin-specific cleanups

4. 代码里真正加了什么

从代码上看,这一阶段最关键的是三个方法:plugin()、effect() 和一套 scope 切换机制。最终它们会收束成类似下面的结构:

ts
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 系统。