Cordis 从入门到实战:插件卸载后,别留下一地鸡毛

从 Context、Service、Fiber、Effect 讲起,再看响应式依赖、类型化事件和热重载如何串在一起。

你修改了一行代码,保存。

热重载成功,控制台重新输出了一次日志。

再保存一次,日志开始输出两遍。

再保存一次,三遍。

恭喜,你养出了三个僵尸定时器。

而且,定时器通常只是最容易被发现的那个。旧插件注册的事件监听器、Socket、文件 Watcher、路由和服务可能还留在进程里。表面上代码已经更新,旧版本其实并没有完全退出。

很多插件系统首先解决的是:

怎样把插件装进来?

Cordis 还追问了一句:

插件被拔掉时,怎样保证它真的把东西收拾干净?

从这个问题入手,Cordis 就容易理解了。

Cordis 官方称它为"时空可组合性的元框架"。说法有点学术,落到代码里却很具体:服务可以动态出现或消失;插件会随依赖自动启停;插件产生的副作用会在卸载时回滚。

Cordis 是 Koishi 可逆插件体系的基础,也被 DeepSeek Harness 用作底层插件运行时。在 DeepSeek Harness 中,模型适配器、工具注册表、会话存储乃至 Agent 循环本身都可以作为插件参与组合。

Cordis 还在快速开发,官方仓库也明确说明 API 尚未稳定。用于真实项目时,建议锁定版本,并以该版本的类型声明和文档为准。本文示例使用 npm 包 cordis;如果开发 DeepSeek Harness 插件,导入路径通常改为 @deepseek-ai/cordis。


一、插得进去不算插件,拔得出来才算

先看一个很普通的插件:

ts 复制代码
function apply() {
  setInterval(runTask, 1000)

  eventBus.on('message', handleMessage)

  toolRegistry.set('search', searchTool)
}

加载它并不难。

难的是卸载。

你必须记得:

ts 复制代码
clearInterval(timer)
eventBus.off('message', handleMessage)
toolRegistry.delete('search')

项目小时,这不过是几行清理代码。规模一上来,遗漏就很常见:

  • 某个监听器在工具函数深处注册,插件入口根本不知道它的存在;
  • 某个子插件创建了定时器,父插件卸载时忘了通知它;
  • 数据库服务已经被替换,业务插件仍然持有旧实例;
  • 热重载多次后,一个请求被重复处理;
  • 插件加载到一半抛错,前面已经创建的资源没有回滚;
  • 插件之间的启动顺序被迫写进配置文件。

Cordis 给每项注册行为都记一笔账:

text 复制代码
这个监听器属于谁?
这个定时器属于谁?
这个服务是谁提供的?
谁依赖这个服务?
插件离场时,需要撤销哪些操作?

这些账都记在同一个运行实例上,也就是 Fiber。

%%{init: {"theme":"base","themeVariables":{"background":"#FFFFFF","primaryColor":"#E8F1FF","primaryTextColor":"#172033","primaryBorderColor":"#2563EB","secondaryColor":"#F3E8FF","secondaryTextColor":"#172033","secondaryBorderColor":"#7C3AED","tertiaryColor":"#ECFDF5","tertiaryTextColor":"#172033","tertiaryBorderColor":"#059669","lineColor":"#475569","textColor":"#172033","edgeLabelBackground":"#FFFFFF","actorBkg":"#E8F1FF","actorBorder":"#2563EB","actorTextColor":"#172033","signalTextColor":"#172033","labelBoxBkgColor":"#ECFDF5","labelTextColor":"#172033","noteBkgColor":"#FFF7ED","noteTextColor":"#172033","noteBorderColor":"#EA580C","activationBkgColor":"#F3E8FF","activationBorderColor":"#7C3AED","fontFamily":"JetBrains Mono, monospace"}}}%% flowchart LR P["Plugin\n插件定义"] -->|"ctx.plugin()"| F["Fiber\n运行实例"] F --> C["Context\n上下文"] F --> E["Effects\n副作用集合"] F --> S["Child Fibers\n子插件"] C --> DB["database 服务"] C --> LOG["logger 服务"] C --> EVT["类型化事件"] E --> T["Timer"] E --> W["Watcher"] E --> L["Listener"] E --> SOCK["Socket"] D["fiber.dispose()"] --> R["逆序回滚 Effects"] R --> X["卸载子插件\n移除服务\n注销监听器\n释放资源"] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class P,C,D blue class F,S,R purple class E,T,W,L,SOCK green class DB,LOG,EVT,X orange

图 1:Cordis 会把插件的服务、监听器、子插件和外部资源绑定到同一个 Fiber。

Cordis 论文从两个维度解释这套机制:

空间可组合性讨论的是:插件在当前 Context 中能看到哪些服务,某项服务是否需要隔离,以及不同子树能否使用不同实现。

时间可组合性讨论的是:插件造成的改动能否撤销,卸载后系统能否回到加载前的状态。

论文使用"可逆 Effect"和"响应式 Coeffect"这两个术语。读代码时,可以先把它们理解为:

Context 决定插件能看到什么,Fiber 记录插件改动了什么。


二、先认清五个概念

理解 Cordis,绕不开下面五个概念。

概念 可以把它想成 主要职责
Plugin 插件说明书 描述一项可加载功能
Context 主板与系统总线 查找服务、发送事件、挂载插件
Service 主板上的能力接口 向其他插件公开具名 API
Fiber 插件的一次运行实例 保存状态、依赖、配置与清理逻辑
Effect 可回滚的系统改动 在 Fiber 卸载时撤销副作用

Plugin 和 Fiber 最容易混淆。

text 复制代码
Class 之于 Object
约等于
Plugin 之于 Fiber

Plugin 是定义,Fiber 是某次实际运行。

同一个插件可以在不同 Context 下加载多次,也可以使用不同配置加载多次。每次加载都会得到一个独立 Fiber,各自管理自己的依赖和副作用。ctx.plugin() 接受函数、类或带 apply() 的对象,并返回对应 Fiber。

用主板打个比方:

text 复制代码
Context  = 主板
Service  = USB、PCIe、网口等接口
Plugin   = 扩展卡的设计
Fiber    = 真正插进机器里的那张卡
Effect   = 卡连接的电线、风扇、灯和外部设备
dispose  = 拔卡,并把相关电线一并拆掉

三、跑起第一个 Cordis 插件

创建项目:

bash 复制代码
mkdir cordis-lab
cd cordis-lab

npm init -y
npm install cordis
npm install --save-dev typescript tsx @types/node

npm pkg set type=module
npm pkg set scripts.dev="tsx src/main.ts"

mkdir src

创建 src/main.ts:

ts 复制代码
import {
  Context,
  type Plugin,
} from 'cordis'

const helloPlugin: Plugin = {
  name: 'hello',

  apply(ctx) {
    console.log('Hello from Cordis!')

    return () => {
      console.log('helloPlugin 已卸载')
    }
  },
}

const ctx = new Context()

const fiber = ctx.plugin(helloPlugin)

// 等待本次加载完成,并把启动错误重新抛出。
await fiber.await()

console.log('插件加载完成')

// dispose() 会等待插件清理结束。
await fiber.dispose()

运行:

bash 复制代码
npm run dev

输出:

text 复制代码
Hello from Cordis!
插件加载完成
helloPlugin 已卸载

这段代码依次做了四件事:

%%{init: {"theme":"base","themeVariables":{"background":"#FFFFFF","primaryColor":"#E8F1FF","primaryTextColor":"#172033","primaryBorderColor":"#2563EB","secondaryColor":"#F3E8FF","secondaryTextColor":"#172033","secondaryBorderColor":"#7C3AED","tertiaryColor":"#ECFDF5","tertiaryTextColor":"#172033","tertiaryBorderColor":"#059669","lineColor":"#475569","textColor":"#172033","edgeLabelBackground":"#FFFFFF","actorBkg":"#E8F1FF","actorBorder":"#2563EB","actorTextColor":"#172033","signalTextColor":"#172033","labelBoxBkgColor":"#ECFDF5","labelTextColor":"#172033","noteBkgColor":"#FFF7ED","noteTextColor":"#172033","noteBorderColor":"#EA580C","activationBkgColor":"#F3E8FF","activationBorderColor":"#7C3AED","fontFamily":"JetBrains Mono, monospace"}}}%% sequenceDiagram participant App as 应用 participant Ctx as Context participant Fiber participant Plugin App->>Ctx: ctx.plugin(helloPlugin) Ctx->>Fiber: 创建运行实例 Fiber->>Plugin: apply(ctx) Plugin-->>Fiber: 返回 disposer Fiber-->>App: ACTIVE App->>Fiber: dispose() Fiber->>Plugin: 执行 disposer Fiber-->>App: 清理完成

图 2:Plugin 的加载与卸载过程。

Cordis 会把插件启动函数返回的清理函数保存为一个 Effect。fiber.dispose() 不只是修改状态,还会等待插件完成清理,包括异步清理。


四、Cordis 插件有三种写法

1. 函数插件

ts 复制代码
import type { Context } from 'cordis'

function heartbeat(ctx: Context) {
  console.log('heartbeat 启动')

  return () => {
    console.log('heartbeat 卸载')
  }
}

函数插件最轻量,适合注册事件、工具、定时任务和中间件。

2. 对象插件

ts 复制代码
import type { Context } from 'cordis'

const reporterPlugin = {
  name: 'reporter',
  inject: ['logger'],

  apply(ctx: Context) {
    ctx.logger('reporter').info('reporter 启动')
  },
}

对象形式适合集中声明:

  • name
  • inject
  • Config
  • apply
  • 其他插件元数据

3. Service 类插件

ts 复制代码
import {
  Service,
  type Context,
} from 'cordis'

class ClockService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'clock')
  }

  now() {
    return Date.now()
  }
}

Service 子类本身也是插件。使用它的主要目的不是"改用类来写",而是向 Context 暴露一项有固定名称的能力,例如:

ts 复制代码
ctx.clock.now()

一般可以从函数插件开始。插件需要向外提供一组可调用的方法时,再考虑 Service。Cordis 支持函数、Service 构造器和 { apply } 对象三种入口形式。


五、Context 不只是一个普通对象

几乎所有 Cordis API 都从 ctx 开始:

ts 复制代码
ctx.plugin(...)
ctx.effect(...)
ctx.on(...)
ctx.emit(...)
ctx.parallel(...)
ctx.serial(...)
ctx.waterfall(...)
ctx.provide(...)
ctx.get(...)
ctx.extend(...)
ctx.isolate(...)
ctx.intercept(...)

Context 看起来像普通对象:

ts 复制代码
ctx.database
ctx.logger
ctx.greeter

实际上,Context 是代理对象。读取 ctx.greeter 时,Cordis 会通过服务解析器,在当前作用域中查找名为 greeter 的实现。

这意味着:

ts 复制代码
ctx.database

并不一定是一个全局单例。

不同子 Context 完全可以解析到不同的 database 实现。Context 既是依赖容器,也是服务作用域、事件入口和插件运行环境。


六、Service:消费能力,而不是导入实现

假设我们要提供一个问候服务。

ts 复制代码
import {
  Service,
  type Context,
} from 'cordis'

interface GreeterConfig {
  prefix: string
}

declare module 'cordis' {
  interface Context {
    greeter: GreeterService
  }
}

class GreeterService extends Service {
  constructor(
    ctx: Context,
    private readonly config: GreeterConfig,
  ) {
    super(ctx, 'greeter')
  }

  greet(name: string) {
    return `${this.config.prefix},${name}!`
  }
}

加载它:

ts 复制代码
const fiber = ctx.plugin(
  GreeterService,
  {
    prefix: '你好',
  },
)

await fiber.await()

console.log(
  ctx.greeter.greet('Alice'),
)

输出:

text 复制代码
你好,Alice!

这里有两个容易混在一起的步骤。

super(ctx, 'greeter') 负责运行时注册

ts 复制代码
super(ctx, 'greeter')

它告诉 Cordis:

text 复制代码
服务名称:greeter
服务实现:当前 GreeterService 实例

插件卸载时,这个服务会自动从当前作用域中移除。

declare module 负责 TypeScript 类型

ts 复制代码
declare module 'cordis' {
  interface Context {
    greeter: GreeterService
  }
}

它只影响编译期,让 TypeScript 知道 ctx.greeter 存在,并提供方法补全。

忘记声明合并时,服务仍可能在运行时正常注册,但编辑器会提示:

text 复制代码
Property 'greeter' does not exist on type 'Context'

可以这样区分:

text 复制代码
super(ctx, 'greeter') 让服务真的存在。
declare module 让 TypeScript 知道它存在。

Service 子类被挂载后会注册为 ctx.<name>,注册本身属于当前 Fiber,Fiber 卸载时服务自动移除。


七、inject:不是启动检查,而是持续订阅

现在写一个消费 greeter 的插件:

ts 复制代码
import {
  type Context,
  type Plugin,
} from 'cordis'

const consumerPlugin: Plugin = {
  name: 'consumer',

  inject: ['greeter'],

  apply(ctx: Context) {
    console.log(
      ctx.greeter.greet('world'),
    )
  },
}

第一次看到 inject,很容易把它理解成:

插件启动时检查一下 greeter 在不在。

但它不只在启动时检查一次。

Cordis 的 inject 是响应式依赖:

text 复制代码
greeter 不存在
    → consumer 保持 PENDING

greeter 出现
    → consumer 执行 apply()

greeter 消失
    → consumer 自动卸载并清理副作用

greeter 再次出现
    → consumer 再次执行 apply()

因此,消费者可以先于服务挂载:

ts 复制代码
const consumerFiber =
  ctx.plugin(consumerPlugin)

const providerFiber =
  ctx.plugin(GreeterService, {
    prefix: '你好',
  })

await Promise.all([
  consumerFiber.await(),
  providerFiber.await(),
])

消费者虽然写在前面,却不会提前启动;它会一直等到 greeter 可用。

%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% stateDiagram-v2 [*] --> PENDING: 插件已声明 PENDING --> LOADING: inject 全部满足 LOADING --> ACTIVE: apply 成功 ACTIVE --> UNLOADING: 依赖消失 UNLOADING --> PENDING: 等待依赖恢复 PENDING --> LOADING: 服务重新出现 LOADING --> FAILED: 配置或启动失败 ACTIVE --> UNLOADING: 显式 dispose UNLOADING --> DISPOSED: 永久卸载 classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class PENDING,DISPOSED blue class LOADING,UNLOADING purple class ACTIVE green class FAILED orange

图 3:Fiber 的核心生命周期。

这与传统依赖注入框架有一个明显差别:

text 复制代码
传统 DI:
应用启动时构建一次对象图。

Cordis:
应用运行期间持续维护依赖图。

因此,配置文件的书写顺序并不决定启动顺序。插件何时进入 ACTIVE,取决于它声明的服务依赖。服务被移除,依赖它的插件随之卸载;服务恢复,插件会再次运行。

可选依赖怎么办

不是所有服务都必须写进 inject。

假设没有 greeter 时,插件仍然可以提供降级功能:

ts 复制代码
const greeter = ctx.get('greeter')

if (greeter) {
  console.log(
    greeter.greet('Alice'),
  )
} else {
  console.log(
    'greeter 未安装,使用默认文案',
  )
}

两者的边界如下:

text 复制代码
inject: ['greeter']
    服务缺失时,整个插件不运行。

ctx.get('greeter')
    插件照常运行,结果可能是 undefined。

ctx.get() 会绕过依赖声明直接读取服务,适合真正的可选能力。


八、Fiber 记录的是这一次运行

当你调用:

ts 复制代码
const fiber =
  ctx.plugin(myPlugin)

返回的 Fiber 保存了这次运行的关键信息:

  • 当前插件;
  • 插件 Context;
  • 校验后的配置;
  • 当前依赖快照;
  • 生命周期状态;
  • 子插件;
  • 正在生效的 Effect;
  • 卸载清理逻辑。

常用操作有:

ts 复制代码
await fiber.await()
await fiber.dispose()
await fiber.restart()
await fiber.update(newConfig)

其中:

ts 复制代码
await fiber.await()

等待当前加载或卸载过程结束,并重新抛出配置校验、插件启动等错误。

ts 复制代码
await fiber.dispose()

卸载插件,并等待异步清理结束。

ts 复制代码
await fiber.restart()

使用当前配置卸载并重新加载插件。

通过 getEffects(),还可以查看带标签的 Effect 树,用来排查插件当前持有哪些资源。


九、Effect:让资源知道自己属于谁

Effect 是理解 Cordis 生命周期的重点。

下面这些操作都会留下持续存在的资源:

ts 复制代码
setInterval(...)
setTimeout(...)
server.listen(...)
socket.connect(...)
fs.watch(...)
process.on(...)
eventEmitter.on(...)

麻烦不在创建资源,而在于没人负责回收。

如果代码只是这样:

ts 复制代码
function apply() {
  setInterval(() => {
    console.log('tick')
  }, 1000)
}

插件卸载了,定时器却不会自己停下来。

用 ctx.effect() 包住资源生命周期

ts 复制代码
import type { Context } from 'cordis'

function heartbeat(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('tick')
    }, 1000)

    return () => {
      clearInterval(timer)
      console.log('timer cleaned')
    }
  }, 'heartbeat timer')
}

ctx.effect() 会做两件事:

  1. 立即执行创建逻辑;
  2. 把返回的 disposer 绑定到当前 Fiber。

Fiber 卸载时,Cordis 会自动调用 disposer。

它很像一个跨越插件生命周期的 try/finally:

ts 复制代码
const resource = createResource()

try {
  // 插件在系统中运行
} finally {
  destroyResource(resource)
}

区别在于,这个 finally 何时触发由 Cordis 决定。

以下情况都会触发清理:

  • 显式调用 fiber.dispose();
  • 插件被配置禁用;
  • 热重载替换旧插件;
  • 必需服务消失;
  • 父插件被卸载;
  • 插件启动失败,需要回滚前面已经创建的资源。

Cordis 会收集 Effect 的清理函数,并在 Fiber 卸载时按注册顺序的逆序启动清理;异步 disposer 也会被等待。

哪些操作已经是 Effect

不少 Cordis API 会自动绑定当前 Fiber:

ts 复制代码
ctx.on('event', listener)
ctx.plugin(childPlugin)
ctx.provide('service', value)

所以通常不用再套一层:

ts 复制代码
ctx.effect(() => {
  return ctx.on(
    'event',
    listener,
  )
})

ctx.on() 本身会返回 disposer,Cordis 也知道这个监听器属于哪个 Fiber。

判断方法很简单:

text 复制代码
Cordis 创建的资源:
通常自动管理。

Node.js 或第三方库创建的资源:
使用 ctx.effect() 包裹。

有严格清理顺序时,放进同一个 disposer

假设服务器必须先停止接收请求,数据库才能关闭:

ts 复制代码
ctx.effect(() => {
  const database =
    createDatabase()

  const server =
    createServer(database)

  return async () => {
    await server.close()
    await database.close()
  }
})

有严格先后关系的清理动作应放在同一个 disposer 中,明确写出顺序。拆成多个异步 Effect 后,完成时机未必符合预期。


十、Service 用来调用,Event 用来广播

Service 适合明确调用某项能力:

ts 复制代码
ctx.database.getUser(id)
ctx.greeter.greet(name)
ctx.cache.set(key, value)

调用方知道要调用哪项能力,并期待明确的结果。

Event 适合宣布某件事情:

ts 复制代码
ctx.emit(
  'user/created',
  user,
)

发布方不用关心有哪些监听器。

场景 更适合
查询用户、调用模型、写入数据库 Service
通知用户已创建、任务已完成 Event
多个插件并发执行收尾工作 Event
按优先级让插件尝试处理 Event
允许插件包装默认行为 Waterfall Event

声明类型化事件

ts 复制代码
interface User {
  id: string
  name: string
}

declare module 'cordis' {
  interface Events {
    'user/created'(
      user: User,
    ): void
  }
}

监听:

ts 复制代码
ctx.on(
  'user/created',
  (user) => {
    console.log(
      `创建用户:${user.name}`,
    )
  },
)

发送:

ts 复制代码
ctx.emit(
  'user/created',
  {
    id: 'u-001',
    name: 'Alice',
  },
)

TypeScript 会检查事件名称、参数数量和参数类型。

事件声明如果放在单独文件中,消费方可以使用一个只影响类型系统的导入:

ts 复制代码
import type {} from './events.js'

它不会生成运行时代码,只是让 TypeScript 看见相应的声明合并。


十一、事件分发不只有 emit

Cordis 内置了五种事件分发方式。

方法 是否等待异步结果 行为
emit 否 同步调用全部监听器,忽略返回值
parallel 是 并发运行全部监听器,并等待它们结束
serial 是 依次运行,遇到第一个有效返回值停止
bail 否 serial 的同步版本
waterfall 取决于事件 洋葱模型,可包装或短路默认行为

emit:只负责通知

ts 复制代码
ctx.emit(
  'user/created',
  user,
)

需要注意:如果监听器是异步函数,emit 不会等待它完成。

错误用法:

ts 复制代码
ctx.on(
  'data/save',
  async () => {
    await saveToDatabase()
  },
)

ctx.emit('data/save')

console.log('保存结束')
// 此时数据库操作可能还没完成。

需要等待时使用:

ts 复制代码
await ctx.parallel(
  'data/save',
)

console.log('保存结束')

parallel:并发执行,全部结束后继续

ts 复制代码
declare module 'cordis' {
  interface Events {
    'system/startup'():
      Promise<void>
  }
}

ctx.on(
  'system/startup',
  async () => {
    await loadCache()
  },
)

ctx.on(
  'system/startup',
  async () => {
    await connectDatabase()
  },
)

await ctx.parallel(
  'system/startup',
)

适合:

  • 并发刷新缓存;
  • 多个插件分别上报指标;
  • 多个模块执行互不依赖的收尾工作。

serial:按顺序询问,拿到第一个结果就停止

ts 复制代码
declare module 'cordis' {
  interface Events {
    'auth/check'(
      token: string,
    ): Promise<
      boolean | undefined
    >
  }
}

ctx.on(
  'auth/check',
  async (token) => {
    if (token === 'admin-token') {
      return true
    }
  },
)

ctx.on(
  'auth/check',
  async (token) => {
    if (token === 'guest-token') {
      return true
    }
  },
)

const allowed =
  await ctx.serial(
    'auth/check',
    token,
  )

监听器会依次执行,直到有人返回:

text 复制代码
非 null
非 false
非 undefined

这种返回值称为 bail value。

适合:

  • 权限判定;
  • 路由匹配;
  • 多个插件依次尝试处理一种输入;
  • 按优先级寻找第一个可用实现。

十二、waterfall:可以包装,也可以截断

waterfall 类似 Koa 中间件。

每个监听器都可以:

  • 在后续处理前执行逻辑;
  • 调用 next();
  • 在后续处理完成后包装结果;
  • 不调用 next(),直接短路整个链条。

声明事件:

ts 复制代码
declare module 'cordis' {
  interface Events {
    'message/transform'(
      input: string,
      next: () => Promise<string>,
    ): Promise<string>
  }
}

注册两个中间件:

ts 复制代码
ctx.on(
  'message/transform',
  async (input, next) => {
    console.log('A before')

    const result =
      await next()

    console.log('A after')

    return result.toUpperCase()
  },
)

ctx.on(
  'message/transform',
  async (input, next) => {
    console.log('B before')

    if (
      input.includes('blocked')
    ) {
      return '[消息已拦截]'
    }

    const result =
      await next()

    console.log('B after')

    return `《${result}》`
  },
)

触发:

ts 复制代码
const result =
  await ctx.waterfall(
    'message/transform',
    'hello',
    async () => 'hello',
  )

console.log(result)

执行顺序:

text 复制代码
A before
B before
执行默认行为
B after
A after
《HELLO》
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% sequenceDiagram participant A as 中间件 A participant B as 中间件 B participant Core as 默认行为 A->>A: before A->>B: next() B->>B: before B->>Core: next() Core-->>B: &#34;hello&#34; B->>B: 包装为《hello》 B-->>A: 《hello》 A->>A: 转为大写 A-->>A: 《HELLO》

图 4:Waterfall 的洋葱调用顺序。

这里最危险的错误,是忘记调用 next():

ts 复制代码
ctx.on(
  'message/transform',
  async (input, next) => {
    console.log(input)

    // 忘记 return next()
  },
)

这会直接截断后续处理,并不只是漏掉一个步骤。

如果监听器只是记录日志,应写成:

ts 复制代码
ctx.on(
  'message/transform',
  async (input, next) => {
    console.log(input)
    return next()
  },
)

Cordis 将"不调用 next()"定义为主动否决或短路,而不是自动继续。


十三、完整实战:让服务消失一次,再回来

下面是一个可以直接运行的例子。

这个例子涵盖:

  • Service;
  • Context 类型声明;
  • inject;
  • 类型化事件;
  • waterfall;
  • ctx.effect();
  • Fiber;
  • 服务消失后消费者自动清理;
  • 服务恢复后消费者自动重新加载。

将 src/main.ts 替换为:

ts 复制代码
import {
  Context,
  Service,
  type Plugin,
} from 'cordis'

function sleep(
  milliseconds: number,
): Promise<void> {
  return new Promise(
    (resolve) => {
      setTimeout(
        resolve,
        milliseconds,
      )
    },
  )
}

// --------------------------------------
// 1. 配置、服务与事件类型
// --------------------------------------

interface GreeterConfig {
  prefix: string
}

declare module 'cordis' {
  interface Context {
    greeter: GreeterService
  }

  interface Events {
    'greeter/called'(
      name: string,
      result: string,
    ): void

    'greeter/format'(
      message: string,
      next: () => Promise<string>,
    ): Promise<string>
  }
}

// --------------------------------------
// 2. Greeter Service
// --------------------------------------

class GreeterService
  extends Service {
  constructor(
    ctx: Context,
    private readonly config:
      GreeterConfig,
  ) {
    super(ctx, 'greeter')
  }

  async greet(
    name: string,
  ): Promise<string> {
    const raw =
      `${this.config.prefix},` +
      `${name}!`

    // 允许其他插件包装或替换结果。
    const formatted =
      await this.ctx.waterfall(
        'greeter/format',
        raw,
        async () => raw,
      )

    // 广播一次调用通知。
    this.ctx.emit(
      'greeter/called',
      name,
      formatted,
    )

    return formatted
  }
}

// --------------------------------------
// 3. 格式化插件
// --------------------------------------

const formatterPlugin:
Plugin = {
  name: 'formatter',

  apply(ctx) {
    ctx.on(
      'greeter/format',
      async (
        _message,
        next,
      ) => {
        const result =
          await next()

        return (
          `【${result.toUpperCase()}】`
        )
      },
    )

    console.log(
      '[formatter] ACTIVE',
    )

    return () => {
      console.log(
        '[formatter] CLEANUP',
      )
    }
  },
}

// --------------------------------------
// 4. 消费服务的插件
// --------------------------------------

const consumerPlugin:
Plugin = {
  name: 'consumer',

  inject: ['greeter'],

  apply(ctx) {
    console.log(
      '[consumer] ACTIVE',
    )

    // ctx.on() 本身就是 Effect。
    ctx.on(
      'greeter/called',
      (name, result) => {
        console.log(
          `[event] ${name} -> ${result}`,
        )
      },
    )

    // setInterval 不由 Cordis 创建,
    // 因此需要手动声明其生命周期。
    ctx.effect(() => {
      let sequence = 0

      const timer =
        setInterval(() => {
          sequence += 1

          const name =
            `开发者-${sequence}`

          void ctx.greeter
            .greet(name)
            .then((result) => {
              console.log(
                `[consumer] ${result}`,
              )
            })
            .catch(
              (error: unknown) => {
                console.error(
                  '[consumer] 调用失败',
                  error,
                )
              },
            )
        }, 400)

      return () => {
        clearInterval(timer)

        console.log(
          '[consumer] ' +
          'interval CLEANUP',
        )
      }
    }, 'consumer interval')

    return () => {
      console.log(
        '[consumer] CLEANUP',
      )
    }
  },
}

// --------------------------------------
// 5. 组合应用
// --------------------------------------

const ctx = new Context()

// 没有依赖,立即启动。
const formatterFiber =
  ctx.plugin(formatterPlugin)

// 此时 greeter 还不存在,
// consumer 暂时保持 PENDING。
const consumerFiber =
  ctx.plugin(consumerPlugin)

// 现在提供 greeter。
let providerFiber =
  ctx.plugin(
    GreeterService,
    {
      prefix: '你好',
    },
  )

await Promise.all([
  formatterFiber.await(),
  providerFiber.await(),
  consumerFiber.await(),
])

await sleep(1300)

console.log(
  '\n--- 卸载 greeter ---\n',
)

// greeter 消失后:
// 1. consumer 被自动卸载;
// 2. interval 被清理;
// 3. 事件监听器被移除;
// 4. consumer 等待依赖恢复。
await providerFiber.dispose()

await sleep(900)

console.log(
  '\n--- 重新提供 greeter ---\n',
)

// 服务恢复后,原 consumer Fiber
// 会重新执行 apply()。
providerFiber =
  ctx.plugin(
    GreeterService,
    {
      prefix: '欢迎回来',
    },
  )

await providerFiber.await()
await sleep(1300)

console.log(
  '\n--- 关闭应用 ---\n',
)

await consumerFiber.dispose()
await providerFiber.dispose()
await formatterFiber.dispose()

运行:

bash 复制代码
npm run dev

输出大致如下:

text 复制代码
[formatter] ACTIVE
[consumer] ACTIVE
[event] 开发者-1 -> 【你好,开发者-1!】
[consumer] 【你好,开发者-1!】
[event] 开发者-2 -> 【你好,开发者-2!】
[consumer] 【你好,开发者-2!】

--- 卸载 greeter ---

[consumer] interval CLEANUP
[consumer] CLEANUP

--- 重新提供 greeter ---

[consumer] ACTIVE
[event] 开发者-1 -> 【欢迎回来,开发者-1!】
[consumer] 【欢迎回来,开发者-1!】

--- 关闭应用 ---

[consumer] interval CLEANUP
[consumer] CLEANUP
[formatter] CLEANUP

如果定时器恰好撞上服务切换,控制台偶尔会多出一条已经进入异步流程的日志。旧 interval 不会继续运行;服务消失时消费者仍会卸载,服务恢复后也会重新启动。

对应的生命周期如下:

%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart TB A[&#34;GreeterService ACTIVE&#34;] --> B[&#34;ctx.greeter 可用&#34;] B --> C[&#34;Consumer inject 满足&#34;] C --> D[&#34;Consumer ACTIVE&#34;] D --> E[&#34;注册 Listener&#34;] D --> F[&#34;创建 Interval Effect&#34;] G[&#34;GreeterService dispose&#34;] --> H[&#34;ctx.greeter 消失&#34;] H --> I[&#34;Consumer UNLOADING&#34;] I --> J[&#34;移除 Listener&#34;] I --> K[&#34;clearInterval&#34;] I --> L[&#34;Consumer PENDING&#34;] M[&#34;新 GreeterService ACTIVE&#34;] --> N[&#34;依赖重新满足&#34;] N --> O[&#34;重新执行 Consumer.apply()&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class A,G,M blue class B,H,N purple class C,D,I,L,O green class E,F,J,K orange

图 5:响应式依赖与可逆副作用共同构成了动态插件生命周期。

这段代码把两个机制接在了一起:

text 复制代码
inject 保证依赖有效
+
Effect 保证卸载干净
=
插件可以安全地反复启停

十四、Context 作用域:同名服务可以有不同实现

并非每个系统都适合全局共用一套数据库。

在多租户、多工作区、多 Bot 或测试场景中,需求可能是:

text 复制代码
团队 A 使用 team-a.db
团队 B 使用 team-b.db

而业务插件仍然只想调用:

ts 复制代码
ctx.database

Cordis 通过子 Context 解决这个问题。

extend():增加上下文元数据

ts 复制代码
const requestContext =
  ctx.extend({
    requestId: 'req-001',
    userId: 'user-123',
  })

子 Context 继承父 Context 的服务,也可以携带自己的元数据,而且不会修改父 Context。

适合:

  • HTTP 请求;
  • 会话;
  • 用户;
  • 租户;
  • 工作区。

isolate():给某个服务开一套独立作用域

先定义一个简单服务类型:

ts 复制代码
declare module 'cordis' {
  interface Context {
    database: {
      filename: string
    }
  }
}

提供服务:

ts 复制代码
const databasePlugin = {
  apply(
    ctx: Context,
    filename: string,
  ) {
    ctx.provide(
      'database',
      {
        filename,
      },
    )
  },
}

创建两个作用域:

ts 复制代码
const teamA =
  ctx.isolate('database')

const teamB =
  ctx.isolate('database')

teamA.plugin(
  databasePlugin,
  'team-a.db',
)

teamB.plugin(
  databasePlugin,
  'team-b.db',
)

在 teamA 下加载的插件看到:

text 复制代码
ctx.database.filename
→ team-a.db

在 teamB 下加载的插件看到:

text 复制代码
ctx.database.filename
→ team-b.db
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart TB ROOT[&#34;Root Context&#34;] ROOT --> A[&#34;Team A Context\nisolate('database')&#34;] ROOT --> B[&#34;Team B Context\nisolate('database')&#34;] A --> ADB[&#34;database = team-a.db&#34;] A --> AP[&#34;业务插件 A\n读取 team-a.db&#34;] B --> BDB[&#34;database = team-b.db&#34;] B --> BP[&#34;业务插件 B\n读取 team-b.db&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class ROOT blue class A,B purple class ADB,BDB green class AP,BP orange

图 6:Context 隔离允许同一个服务名称在不同子树解析到不同实现。

intercept():给子树中的服务附加配置

ts 复制代码
const tenantContext =
  ctx.intercept(
    'http',
    {
      timeout: 5000,
      headers: {
        'x-tenant-id':
          'tenant-a',
      },
    },
  )

在这个子 Context 下启动的插件,会看到合并后的 http 服务配置;父 Context 不受影响。

extend()、isolate() 和 intercept() 都会创建子 Context,而不是直接修改父 Context。


十五、用 YAML Loader 把应用变成插件树

代码里可以直接组合:

ts 复制代码
ctx.plugin(plugin)

应用规模变大后,通常还会需要配置驱动的组合方式。

典型的 cordis.yml 可以写成:

yaml 复制代码
- id: formatter
  name: './formatter.ts'

- id: greeter
  name: './greeter.ts'
  config:
    prefix: 你好

- id: consumer
  name: './consumer.ts'

插件模块可以导出:

ts 复制代码
import type {
  Context,
} from 'cordis'

export const name = 'consumer'

export const inject = [
  'greeter',
]

export function apply(
  ctx: Context,
) {
  console.log(
    ctx.greeter.greet('Alice'),
  )
}

Loader 会读取插件模块的入口与元数据,然后创建相应 Fiber。

YAML 行顺序不是启动顺序

下面两份配置在依赖关系上通常等价:

yaml 复制代码
- name: './greeter.ts'
- name: './consumer.ts'
yaml 复制代码
- name: './consumer.ts'
- name: './greeter.ts'

因为真正的规则是:

text 复制代码
consumer 声明 inject: ['greeter']
因此它会等待 greeter,而不是等待上一行。

按照官方教程,配置条目可以并发开始加载。插件何时运行由依赖关系决定,与列表位置无关。

为什么要写 id

yaml 复制代码
- id: greeter
  name: './greeter.ts'

id 是配置条目的稳定标识。

配置更新时,Loader 才能识别:

text 复制代码
这是原来那个 greeter 的配置变化

而不是:

text 复制代码
删除一个旧插件
再添加一个无关的新插件

临时禁用插件

yaml 复制代码
- id: greeter
  name: './greeter.ts'
  disabled: true

这会卸载插件,但保留配置项。

改回:

yaml 复制代码
disabled: false

服务重新出现后,依赖它的 PENDING 插件也会重新激活。Loader 依靠稳定的 id、disabled 状态和配置差异更新完成组合与 HMR。


十六、配置不能只靠 TypeScript

下面的接口只能约束编译期代码:

ts 复制代码
interface Config {
  greeting: string
  targets: string[]
}

但 YAML 完全可以写成:

yaml 复制代码
config:
  targets: 123

TypeScript 无法约束运行时读入的配置。

因此,Cordis 会在调用 apply() 前,使用插件导出的 Config 校验器检查配置。它接受符合 Standard Schema 的校验器,Schemastery 是常见选择。

示例:

ts 复制代码
import Schema from 'schemastery'
import type {
  Context,
} from 'cordis'

export interface Config {
  greeting: string
  targets: string[]
}

export const Config:
Schema<Config> =
  Schema.object({
    greeting:
      Schema
        .string()
        .default('Hello'),

    targets:
      Schema
        .array(String)
        .default(['world']),
  })

export function apply(
  ctx: Context,
  config: Config,
) {
  for (
    const target
    of config.targets
  ) {
    console.log(
      `${config.greeting}, ` +
      `${target}!`,
    )
  }
}

配置:

yaml 复制代码
- name: './config-demo.ts'
  config:
    targets:
      - Alice
      - Bob

输出:

text 复制代码
Hello, Alice!
Hello, Bob!

如果配置类型错误,插件会在 apply() 运行前失败,而不是带着半残配置进入 ACTIVE:

yaml 复制代码
config:
  targets: not-an-array

两者各管一层:

类型用于约束开发者,Schema 用于约束外部输入。


十七、Cordis 为什么适合热重载

热重载经常被理解成:

text 复制代码
重新 import 一次新文件

可靠的热重载还要完成下面这些步骤:

text 复制代码
卸载旧实例
    ↓
撤销旧实例的全部副作用
    ↓
加载新代码
    ↓
重新解析服务依赖
    ↓
启动新的插件实例
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart LR A[&#34;源码保存&#34;] --> B[&#34;定位旧 Fiber&#34;] B --> C[&#34;UNLOADING&#34;] C --> D[&#34;回滚 Effects&#34;] D --> E[&#34;加载新模块&#34;] E --> F[&#34;重新校验配置&#34;] F --> G[&#34;重新解析 inject&#34;] G --> H[&#34;新 Fiber ACTIVE&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class A,E blue class B,F purple class C,G green class D,H orange

图 7:可靠 HMR 的核心不是重新加载,而是先完整卸载。

Cordis 已经知道:

  • 旧插件注册过哪些监听器;
  • 创建过哪些定时器;
  • 提供过哪些服务;
  • 加载过哪些子插件;
  • 哪些插件依赖它;
  • 每项副作用应如何撤销。

因此,HMR 不必猜测旧插件留下了哪些资源。

旧 Fiber 卸载时会回滚 Effect;新代码加载后,依赖图会重新计算。官方 HMR 插件就建立在这套生命周期上。

Watcher 只负责发现文件变化。Cordis 能安全地热重载,靠的是插件本身可逆。


十八、插件没有输出,先看它是否在等待依赖

调试 Cordis 时,经常会遇到这种情况:

text 复制代码
我明明把插件写进配置了,
为什么什么都没发生?

先看它的依赖:

ts 复制代码
export const inject = [
  'database',
]

如果没有任何插件提供 database,当前 Fiber 会保持 PENDING。

这不一定是异常。数据库可能稍后才会挂载,PENDING 只是表示插件正在等依赖。

官方教程建议遍历 Registry 中的 Fiber,找出处于 PENDING 状态的插件。

示意代码:

ts 复制代码
import {
  FiberState,
  type Context,
} from 'cordis'

export function apply(
  ctx: Context,
) {
  ctx.effect(() => {
    const timer =
      setTimeout(() => {
        for (
          const runtime
          of ctx.registry.values()
        ) {
          for (
            const fiber
            of runtime.fibers
          ) {
            if (
              fiber.state ===
              FiberState.PENDING
            ) {
              console.log(
                `${fiber.name} ` +
                '正在等待依赖',
              )
            }
          }
        }
      }, 500)

    return () =>
      clearTimeout(timer)
  })
}

可以按下面的顺序排查:

text 复制代码
插件没输出
    ↓
检查模块路径
    ↓
检查 Fiber 状态
    ↓
如果是 PENDING,检查 inject
    ↓
确认服务名称和作用域
    ↓
再检查 apply 是否抛错

十九、八个常见坑

1. 使用了服务,却没声明 inject

错误:

ts 复制代码
function apply(
  ctx: Context,
) {
  ctx.database.query(...)
}

更稳妥:

ts 复制代码
const plugin = {
  inject: ['database'],

  apply(ctx: Context) {
    ctx.database.query(...)
  },
}

否则,插件可能在数据库尚未就绪时运行。


2. 把长期资源创建在 Effect 外

错误:

ts 复制代码
function apply() {
  setInterval(runTask, 1000)
}

正确:

ts 复制代码
function apply(
  ctx: Context,
) {
  ctx.effect(() => {
    const timer =
      setInterval(
        runTask,
        1000,
      )

    return () =>
      clearInterval(timer)
  })
}

3. 使用 emit 等待异步任务

错误:

ts 复制代码
ctx.emit('save')
console.log('保存完成')

正确:

ts 复制代码
await ctx.parallel('save')
console.log('保存完成')

4. Waterfall 中忘记 next()

ts 复制代码
ctx.on(
  'request',
  async (request, next) => {
    console.log(request)

    // 后面的处理链被截断。
  },
)

只是观察时应返回:

ts 复制代码
return next()

5. 依赖 YAML 行顺序

配置文件不是下面这种顺序脚本:

text 复制代码
第一行先启动
第二行后启动

它表达的是:

text 复制代码
所有条目参与组合
依赖图决定激活顺序

6. 服务名称过于通用

服务名称位于扁平命名空间中:

text 复制代码
database
logger
tools
cache
auth

公共插件最好约定命名方式,例如:

text 复制代码
acmeAuth
myPluginCache
companySearch

否则,不同插件可能使用同一个服务名,造成冲突。


7. 把真正的必需依赖写成 ctx.get()

ts 复制代码
const database =
  ctx.get('database')

database?.query(...)

这样一来,即使数据库缺失,插件仍会进入 ACTIVE,后续代码只能不断处理 undefined。

如果数据库是插件正常工作的前提,应该使用:

ts 复制代码
inject: ['database']

8. 把有严格顺序的清理拆成多个异步 Effect

如果关闭资源 B 时必须保证资源 A 仍然可用,就把它们写进同一个 disposer,明确控制顺序。

不要假设多个异步 Effect 恰好会按需要的顺序结束。


二十、Cordis 与熟悉技术的区别

Cordis 与 Spring

它们都涉及服务容器、依赖注入和生命周期。

主要区别是依赖图会不会在运行期间变化:

Spring 常见模式 Cordis
启动时构建对象图 运行期间维护动态依赖图
Provider 通常长期存在 Service 可以动态出现和消失
模块很少被频繁卸载 插件卸载是日常操作
依靠启动、销毁钩子 依靠 Fiber 与 Effect 所有权
更偏业务应用框架 更偏通用插件运行时

Cordis 与 RAII、using、try/finally

ts 复制代码
ctx.effect(() => {
  const resource = create()

  return () => {
    destroy(resource)
  }
})

可以类比:

text 复制代码
RAII
defer
using
try/finally
DisposableStack

区别在于,资源所有权绑定在 Fiber 上,而不是某个局部函数调用栈上。


二十一、把 Cordis 的主线串起来

Cordis 没有发明新的函数调用方式。它处理的是调用之外的生命周期问题:

text 复制代码
模块何时有资格运行?
它需要哪些服务?
服务突然消失怎么办?
它创建的资源归谁所有?
模块退出后如何恢复现场?
服务回来后能否自动继续?

整个流程可以归纳为八步:

text 复制代码
1. Plugin 声明 inject
2. Context 查找服务
3. 依赖满足,创建并激活 Fiber
4. apply() 注册服务、事件和 Effect
5. 必需服务消失
6. Fiber 卸载并回滚副作用
7. 插件回到等待状态
8. 服务恢复,apply() 重新执行

再用前面的主板比喻梳理一遍:

text 复制代码
Context
    是主板与总线。

Service
    是主板上的能力接口。

Plugin
    是扩展卡的设计。

Fiber
    是真正插入后的运行实例。

inject
    是扩展卡声明需要哪些接口。

Effect
    是扩展卡连接的电线和外部资源。

dispose
    是拔卡,并把相关资源一起拆除。

Event
    是总线广播。

isolate
    是给某个区域准备独立的一套接口。

Cordis 的价值不只在于加载插件。经过反复加载、卸载、替换和重载后,它仍要保证:

text 复制代码
依赖是正确的
资源是干净的
行为是可预测的

一个可靠的插件系统既要能加载插件,也要能完整撤销插件留下的改动。


参考资料

本文的概念与 API 行为参考了 Cordis 官方仓库、时空可组合性论文、Cordis Primer、Cordis Core API 和官方教程。框架仍在快速演进,实际开发请以项目锁定版本的类型声明为准。

相关推荐
高频因子挖掘机23 分钟前
QuantDash 明明安装成功,为什么 Python 还是无法 import?
后端·github·api
Java爱好狂.8 小时前
Java初学者如何一周速通SpringBoot!
java·spring boot·后端·spring·高并发·架构师·java面试
马良神笔10 小时前
2026 年Rag最近技术前沿总结
后端·python·django
程序猿DD10 小时前
同样10美元,OpenDesign和Command Code的DeepSeek V4.1 Flash额度到底有多少?
后端
高频因子挖掘机10 小时前
A股量化从日线升级到5分钟线:数据量增加后最先暴露哪些工程问题?
后端·github·api
高频因子挖掘机11 小时前
历史行情返回顺序变了,为什么按行号计算指标很危险?
后端·github·api
团子哒哒12 小时前
数组、切片和Map
后端
专注仿真12 小时前
ThinkPHP6 API接口开发实例:从零搭建RESTful风格接口
后端·php·restful
开心猴爷12 小时前
应对iOS审核4.3:可以使用 IpaGuard 来给属性名混淆的技术细节与平衡之道
后端·ios
1m5555555512 小时前
RAG 工程实战(五):正确知识已经进入 Prompt,为什么大模型还是会答错?
后端·面试