Cordis 源码解读:用 Proxy 与原型链写成的「时空可组合」元框架

从 Koishi 的底座出发,拆解「时空可组合」背后的依赖注入、生命周期与响应式重载

Posted by iceyao on Friday, August 14, 2026

引言:给应用装上一个插件化内核

如果你写过中等规模的 Node.js 应用,大概率经历过这三件事:插件之间的依赖靠字符串 token 传递,类型丢了;定时器、事件监听、文件句柄要靠人肉记忆去清理,卸载时漏一个就是线上 bug;改个配置要重启进程。

Cordis 想一次性解决这些问题。它的自我定位是一句不太好翻译的话:

A Meta-Framework of Spatiotemporal Composability.

作者 Shigma 是 Koishi 的作者——国内最流行的跨平台聊天机器人框架,而 Cordis 正是 Koishi v4 的底座。所谓 meta-framework,是说它不提供任何业务能力:没有 HTTP、没有数据库、没有调度器,它只提供组装业务能力的范式——插件化、依赖注入、生命周期管理。我把它读完之后的印象是:这是一个把"副作用"和"上下文"两个概念推到极致的框架,全篇只有不到 3000 行核心代码,但设计密度极高。

本文基于 v4.0.0-rc.8 的源码仓库逐层拆解。读完之后你应该能回答三个问题:插件是怎么装上去又卸干净的?ctx.db 这种"没定义过的属性"为什么能安全访问?替换一个服务,依赖它的插件为什么能自动重启?

技术架构概述

仓库是一个 yarn workspaces monorepo,结构出乎意料地克制:packages/core 是唯一的核心包,其余八个包全部是插件或工具。core 的运行时依赖只有两个:cosmokit(基础工具库)和 @standard-schema/spec(配置校验的标准接口)。

Cordis 分层架构总览

核心层由五个服务组成,全部挂在同一个 Context 对象上:

  • Fiber:生命周期状态机 + effect 副作用回收,一个插件实例对应一个 Fiber;
  • Registry:插件注册表,负责插件去重与 @Inject 依赖声明;
  • Reflect:服务注册表,同时也是 Context 这个 Proxy 的陷阱处理器;
  • Events:事件系统,提供五种分派模式;
  • Logger:日志缓冲与 exporter 机制。

值得注意的是 core 的零 I/O 原则:日志输出、配置加载、热更新,这些真正跟环境打交道的能力全部以插件形式存在(@cordisjs/plugin-logger-console@cordisjs/plugin-loader@cordisjs/plugin-hmr)。这意味着核心可以被嵌入任何运行时——浏览器、CLI、测试环境——而不必携带 Node 专属的依赖。这是元框架的一种自我克制:能力可以长在外面,范式必须留在核心

核心模块功能详解

Context:一个 Proxy 包裹的原型链对象

Context 的构造函数是整个框架的起点,只有十几行,但信息量极大(packages/core/src/context.ts):

constructor() {
  this[symbols.isolate] = Object.create(null)
  this[symbols.intercept] = Object.create(null)
  const self = new Proxy<this>(this, ReflectService.handler)
  this.root = self
  this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
  this.reflect = new ReflectService(self)
  this.registry = new RegistryService(self)
  this.events = new EventsService(self)
  this.logger = new LoggerService(self)
  return self
}

两件事值得注意。第一,构造器返回的不是 this,而是 this 的 Proxy——所有属性访问都要经过 ReflectService.handler 的拦截,后文的 DI 就发生在这些陷阱里。第二,Context 提供了三个派生方法,全部基于 Object.create

  • extend(meta):复制一份上下文,只替换传入的元数据,O(1) 完成;
  • isolate(name, label):给服务名绑定一个新的 Symbol,创造独立作用域;
  • intercept(name, config):在原型链上叠加一层配置,供子上下文覆盖插件配置。

没有模块树、没有容器嵌套,作用域就是原型链上的一层。这个决定贯穿全文,我们后面还会回来。

Registry:一个插件 = 一个 Fiber

ctx.plugin() 接受三种形态的插件:普通函数、构造器、带 apply 方法的对象。注册时按函数引用去重——同一个插件函数多次注册,会共用一份 runtime,但每次注册都生成一个新的 Fiber(packages/core/src/registry.ts):

plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
  const callback = this.resolve(plugin)
  this.ctx.fiber.assertActive()
  let runtime = this._internal.get(callback)
  if (!runtime) {
    runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
    this._internal.set(callback, runtime)
  }
  const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
  const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>
  wrapped.then = (onFulfilled, onRejected) => fiber.await().then(onFulfilled, onRejected)
  return wrapped
}

最后两行是一个很贴心的设计:ctx.plugin() 的返回值既是一个 Fiber,又是一个 Promise——await ctx.plugin(foo) 可以直接等到插件加载完成,失败时把加载错误抛出来。这个"awaitable Fiber"的接口让插件的异步启动变得像普通函数调用一样自然。

@Inject 装饰器支持类级和方法级两种用法。方法级的实现尤其巧妙:它注册一个初始化钩子,在插件回调执行前,用一个新的子 Fiber 把方法"包"起来运行——于是方法里通过 this.ctx 访问到的,是带着注入配置的独立上下文。

Fiber:生命周期与 effect 系统

Fiber 是 Cordis 的心脏。每个插件有一个 Fiber,每个 Fiber 有一张状态机:

Fiber 生命周期状态机

状态机的驱动力是 effectctx.effect() 接受一个回调,回调的返回值就是清理函数;任何挂在 effect 里的东西,都会在 Fiber 卸载时被逆序回收。这比 React 的 useEffect 走得更远:返回值可以是函数、可迭代对象、异步可迭代对象,甚至 Promise(packages/core/src/fiber.ts):

effect(execute: () => Effect, label = 'anonymous'): any {
  this.assertActive()
  const disposables: Disposable[] = []
  const dispose = () => {
    let task!: void | Promise<void>
    for (const dispose of disposables.splice(0).reverse()) {
      if (task) task = task.then(dispose)
      else {
        const result = dispose()
        if (isObject(result) && 'then' in result) task = result as any
      }
    }
    return task
  }
  // ...收集执行结果、处理异步 effect、返回带 then 的 wrapper
}

清理函数按注册的逆序执行,异步清理会被依次 await。这意味着你可以放心地在一个 effect 里同时开定时器和注册事件监听,卸载时它们一定按正确顺序关闭。ctx.on()ctx.provide()ctx.plugin() 本质上都是 ctx.effect() 的封装——事件监听随插件生命周期自动注销,这是它对比裸 EventEmitter 最实质的进步。

Reflect:Proxy 陷阱里的服务注册表

ReflectService 同时承担两个角色:服务注册表(store)和 Context 的 Proxy 处理器。它的 get 陷阱是 DI 的核心逻辑:访问 ctx.db 时,先查当前 fiber 的 store,没有就沿 fiber.parent.fiber 链向上找,同时校验 isolate 符号是否一致;全部落空时,抛出一个错误——并且改写错误的 stack 首行,让它看起来像原生属性访问失败:

Error: cannot get property "db" without inject

服务注册 ctx.provide() 同样是一个 effect:提供者在位期间,服务存在于 store 中;提供者卸载,服务随之消失,并触发 notify()notify 会遍历 registry 里所有 fiber,筛出注入了该服务的插件,逐一调用 _checkImpl()_refresh()——这就是响应式重载的起点。

另外,ctx.onctx.pluginctx.providectx.logger 这些"Context 自带方法"其实不存在于 Context 上,而是通过 mixin() 从五个服务转发来的 accessor。整个框架的 API 表面,全部是这一层反射的产物。

Events:五种分派模式

Cordis 的事件系统提供五种分派方式,覆盖了从"通知"到"拦截"的全部协作语义:

五种事件分派模式

  • emit:同步广播,不关心结果;
  • parallel:并发执行,Promise.allSettled 后聚合失败为 AggregateError
  • serial:异步顺序执行,返回第一个真值;
  • bail:同步顺序执行,返回第一个真值,典型用途是权限校验;
  • waterfall:Koa 风格的洋葱模型,监听器手动调用 next(),可用于配置更新的拦截链。

框架自身也重度使用这套事件系统——internal/plugininternal/updateinternal/get 等内部事件让插件能介入框架的每一步执行。loader 插件就是靠监听 internal/update 把运行时的配置变更写回 YAML 文件的。

Logger:exporter 模式

LoggerService 是一个可调用服务——ctx.logger 本身可读、可调(ctx.logger('name') 返回一个带名字的 Logger)。日志消息先进入内置的 1000 条环形缓冲,再分发给各个 exporter;控制台输出只是其中一个 exporter。logger 的另一个细节是名字哈希配色:插件名经过哈希映射到 16 或 256 色调色板,同一插件的日志在终端里永远是同一种颜色,方便肉眼过滤。消息还携带了 WeakRef<Fiber>,打印时不会阻止 Fiber 被回收。

关键设计决策与亮点

读源码时,最值得停下来想的是下面几个决策。它们彼此咬合,构成了 Cordis 的完整世界观。

一切皆 effect:副作用显式化

传统框架里,副作用的生命周期和插件的生命周期是割裂的:插件卸载函数里要手动列出所有清理动作,漏一个就是泄漏。Cordis 把两者统一了:所有副作用都必须以 effect 的形式注册,卸载 = 逆序执行清理函数。对比 Vue 3 的 effect scope、React 的 useEffect,Cordis 把它们推到了框架层面——不只是组件,而是整个应用的所有资源。

原型链派生 + 符号隔离

上下文派生:原型链 + 符号隔离

派生上下文用 Object.create,这带来两个直接收益:一是 O(1) 的派生成本,创建子作用域几乎免费;二是同名服务可以在不同作用域共存——isolate 给服务名绑定了不同的 Symbol 作为 store 的 key,两个插件可以在各自的 isolate 作用域里 provide 同一个名字的 db,互不干扰。对比 NestJS 的模块树和字符串 token,这套机制更轻,代价是作用域关系需要靠约定而非显式结构来表达。

属性拦截式 DI:类型安全的语法糖

ctx.db 而不是 ctx.get('db')——Proxy 让依赖注入看起来像原生属性访问。类型从哪来?答案是 TypeScript 的模块增强(module augmentation):每个服务插件在自己的包中声明 declare module 'cordis' { interface Context { db: Database } },所有插件就都能获得 ctx.db 的类型提示。字符串 token 的 DI 容器里那种"运行时找不到依赖、类型还完全正确"的尴尬,在这里被属性访问 + 模块增强抹平了。

epoch 响应式重载:替换服务 = 自动重启

这是"时空可组合"里时间维度的实现,也是我认为最有价值的设计:

服务变更 → 响应式重载

每个 Fiber 维护一个 epoch 字符串,由它所有注入服务的 uid 拼接而成。任何服务被重新 provide 时,notify() 会找到所有依赖者,重新计算 epoch;epoch 变了,Fiber 就自动执行 _unload()_reload(),用新服务重新跑一遍插件回调。卸载和重载通过 inertia Promise 串行化,避免并发变更下的状态错乱。服务还可以提供 check 函数,返回 false 时依赖者直接失活——loader 就是用它来实现"配置文件中禁用的插件不加载"。

getTraceable:跨上下文调用的 this 重绑定

服务方法被其他 Fiber 调用时,this.ctx 指向谁?Cordis 的答案是 getTraceable:从服务表取出的值先包一层 Proxy,代理会把 tracker.property(通常是 ctx)重绑定到调用方的上下文。于是同一个服务对象,在不同作用域调用时看到不同的 ctx——日志自动带上调用方插件名,注入的服务自动按调用方作用域解析。这就是"空间"维度:同一份代码,在不同的时空上下文里运行

配置拦截链 + Standard Schema 校验

ctx.intercept(name, config) 在原型链上叠加配置,Service.resolveConfig 沿链向上合并(支持自定义 Config.merge)。子上下文可以覆盖插件的配置而不影响其他作用域;配置更新走 internal/update 的 waterfall 事件链,最终以 restart() 收尾。所有配置在生效前经过 Standard Schema 校验,失败抛出带路径的 ValidationError(如 - invalid type (at timeout)),配置错误的定位成本极低。

工程细节:长堆栈、可调用服务、HMR 依赖图

几个小但见功力的细节:composeError 把外层调用栈拼进异步错误,跨 await 的报错不再丢失上下文;createCallable + joinPrototype 让服务对象同时是函数(ctx.logger('name'))而原型链不乱;hmr 插件用 Node 模块加载器的 ModuleJob.linked 递归构建依赖图,把改动文件分为 accepted / declined / stashed 三类——依赖了被拒绝模块的文件只能整机重载,能热更的则原地替换插件。测试文件里的覆盖率和这些机制的配合,说明作者把 DX 当成了一等公民。

当然,这套设计有代价:Proxy 的心智负担不小,属性访问失败的时机从"编写时"推迟到了"运行时";inertia 链条上的状态调试起来需要经验;v4 目前还是 rc 版本,API 明确标注"may change without notice"。

部署与运行说明

把 Cordis 跑起来的最低要求很轻:Node.js ≥ 22、ESM-only,核心包零 I/O 依赖。最小可运行示例只有三行:

import { Context } from 'cordis'

const ctx = new Context()
ctx.plugin(function (ctx) {
  ctx.on('ready', () => ctx.logger.info('hello cordis'))
})

实际项目推荐用 create-cordis 脚手架初始化,产出标准的 Cordis 工程;monorepo 本身用 yarn 4 管理(packageManager: yarn@4.14.1),通过 yakumo 编排各包的构建与测试:

yarn build   # yakumo esbuild + yakumo tsc:先打包再出类型
yarn test    # yakumo vitest,覆盖率报告见 test:html / test:json

生产环境通常配合 @cordisjs/plugin-loader:插件与配置写在 YAML 里,CORDIS_SHARED 环境变量在进程间共享启动信息,改配置后 loader 监听文件变更、走 internal/update 完成热更新。如果目标是"写一个 Koishi 插件",那么你已经在写 Cordis 插件了——Koishi 的插件生态与 Cordis 完全同构。

总结

回过头看,Cordis 的全部设计可以浓缩成三句话:

  1. 一切皆 effect——副作用显式化,卸载时逆序、串行、可等待地回收;
  2. 上下文是原型链——派生 O(1),作用域靠 Symbol 隔离,配置靠链上叠加;
  3. 服务变更即重载——epoch 变了就卸载重来,进程不用重启,代码不用写迁移。

它未必适合所有项目:小型应用引入它会显得重,Proxy 魔法也抬高了团队的学习成本。但如果你正在设计插件系统、bot 框架,或者被"副作用清理"和"配置热更新"折磨过,这份不到 3000 行的核心代码是一份值得逐行读一遍的教材。至少对我而言,读完它之后,“卸载"这个词从一段手写的 dispose() 函数,变成了一种可以交给框架的、确定的计算。

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付