技术·Cordis 从零开始 · 第 3·2026-08-19·6 分钟

第二篇:从日志需求出发,理解 Event 和 Lifecycle

这一篇要解决的问题是:当一个模块做完自己的事以后,其他模块也想感知这件事,但双方又不应该直接耦合时,系统该怎么组织。为了把这个问题讲清楚,我们会先手写一个最小事件系统,再看为什么事件本身并不是最难的部分,真正棘手的是 listener 的创建和清理最终一定会把我们带到 Lifecycle

1. 这一篇要解决的问题

现在增加一个需求:每次调用 greet 之后,都记录一行日志:

text
[LOG] greeted Alex

同时再加一个约束:Greeter 和 App 都不应该知道 Logger 的存在。原因很简单,因为未来也许还会有 AnalyticsAlertTracingMetrics 等旁路模块。如果 App 逐渐写成下面这样:

ts
greeter.greet()
logger.log()
analytics.track()
metrics.record()
alert.check()

那么随着功能增加,App 会知道越来越多的模块,耦合也会迅速变强。因此,我们需要一种不要求发送方认识接收方的通信方式。

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

text
打完招呼以后还要通知别人

不能直接写死 Logger

引入 Event 做解耦通信

listener 最终并入 Lifecycle
事件与生命周期关系图
事件与生命周期关系图

2. Vanilla:自己实现一个最小事件系统

在不使用框架的情况下,最直接的做法是维护一个监听器数组:

ts
type GreetListener = (name: string) => void

const greetListeners: GreetListener[] = []

function onGreet(fn: GreetListener) {
  greetListeners.push(fn)
}

function offGreet(fn: GreetListener) {
  const i = greetListeners.indexOf(fn)
  if (i >= 0) greetListeners.splice(i, 1)
}

function emitGreet(name: string) {
  for (const fn of greetListeners) fn(name)
}

App 在打招呼后发出事件:

ts
function app() {
  console.log('[App]', greeter.greet('Alex'))
  emitGreet('Alex')
}

Logger 订阅这个事件:

ts
function logger(name: string) {
  console.log(`[LOG] greeted ${name}`)
}

onGreet(logger)

这样一来,App 只负责发事件,Logger 只负责监听事件,两者之间不再需要直接引用。到这里为止,解耦已经成立了。

3. Vanilla:监听器由谁清理?

不过,事件系统很快会引出一个比“能不能发事件”更关键的问题:谁来负责清理监听器?我们虽然写了:

ts
offGreet(logger)

但没有任何机制能保证我们将来一定会调用它。假设 Logger 某天被卸载了,如果监听器还留在 greetListeners 数组里,就会留下一个幽灵 listener。问题的关键不在于数组实现得够不够优雅,而在于资源的创建和资源的清理是分离的。我们在某个地方调用了:

ts
onGreet(logger)

就必须在未来另一个时刻记得调用:

ts
offGreet(logger)

一旦调用者忘记清理,系统本身并不会主动发现。随着事件和模块越来越多,这种遗漏就会变成长时间运行系统里的资源泄漏和重复触发问题。

4. Cordis:把 listener 放进生命周期

在 Cordis 中,先声明 Event 类型:

ts
declare module 'cordis' {
  interface Context {
    greeter: Greeter
  }

  interface Events {
    greet(name: string): void
  }
}

App 改成:

ts
function app(ctx: Context) {
  console.log('[App]', ctx.greeter.greet('Alex'))
  ctx.emit('greet', 'Alex')
}

Logger 改成:

ts
function logger(ctx: Context) {
  ctx.on('greet', (name) => {
    console.log(`[LOG] greeted ${name}`)
  })
}

安装也很直接:

ts
await ctx.plugin(logger)

从表面看,Cordis 只是把事件操作收敛成了:

text
ctx.emit(...)
ctx.on(...)

但它真正重要的地方不在于 API 更短,而在于 ctx.on() 创建出来的 listener 属于当前 Plugin 的生命周期。也就是说,这个监听器不是一个散落在全局数组里的资源,而是跟着 Logger Plugin 一起被管理:

text
Logger Plugin

     ├── ctx.on('greet', ...)


   dispose


listener 自动清理

因此,我们不再需要自己显式维护 offGreet(logger)。这正是 Lifecycle 第一次真正变得具体的地方。

5. 这一篇的结论

这一篇最重要的认识是:一个最小事件系统并不难自己写,真正困难的是把“资源创建”和“资源清理”绑定到同一条生命周期里。Cordis 的价值不是帮你少写一个监听器数组,而是把 Plugin 创建出来的 listener 自动纳入生命周期管理。从这里开始,我们讨论的重点已经不只是“怎么通信”,而是“谁创建资源、谁负责回收资源”。

下一篇会把问题再往前推一步。当 Greeter 不再独立,而是开始依赖一个会动态出现和消失的 Clock 时,系统就不只需要处理事件,还需要开始处理依赖的可用状态。