为什么是 Pluxel?

从 Koishi 与 Cordis 的实践出发,理解 Pluxel 对依赖身份、Context、生命周期和构建语义的选择。

Pluxel 是对 Cordis v3 插件思想的一次再诠释。我们延续 Context、依赖注入和资源随插件回收的模型,希望把业务依赖的声明、类型和运行时身份进一步连接起来:开发者写下导入和构造函数参数,构建工具记录依赖关系,运行时据此管理启动、替换与清理。

需要判断是否采用 Pluxel 时读本页;开始开发直接进入快速开始。

从 Koishi Plugin 实践出发

我们从 2023 年开始编写 Koishi 插件。Koishi 的插件运行时建立在 Cordis 之上;本文引用的 @koishijs/core 提交明确依赖 Cordis 3.18.1。Pluxel 对依赖模型的思考,来自持续编写、组合、替换插件和处理其资源生命周期的经验。

Cordis 的应用也已超出 Koishi 生态。DeepSeek 官方的 DeepSeek Harness 将其 “everything is a plugin” 架构标记为 powered by Cordis,并在仓库中维护和发布 Cordis 4.0.1。这些实践说明了插件模型可以服务于不同类型的应用。

下面的代码与类型对比以链接中固定提交的 Cordis v3 为依据。DeepSeek Harness 的采用是模型背景,不作为推断 Cordis v4 实现细节的依据。

从 Cordis 学到的 Plugin 模型

Cordis 把功能组织成具有 Context 和生命周期的插件,这给应用组合提供了几个重要基础:

  • 插件是能力与副作用的边界。 一个功能可以连同它的监听器、定时器和其他资源一起装载、卸载。
  • Context 关联调用者与资源所有者。 共享服务处理调用时,需要知道注册和清理属于哪个插件。
  • 依赖可用性参与生命周期。 必需服务尚未出现时等待,服务变化时卸载并按条件重新加载插件。
  • scope 和 effect 管理清理。 资源跟随所属作用域释放,动态替换不必重启整个应用。

Pluxel 保留了这些判断。我们进一步关注的问题是:同一条业务依赖,如何在编辑器、构建产物和运行时中保持可追溯的关联?

Cordis 如何连接类型与运行时

理解差异,需要分清 Cordis 中两个配合工作的部分:TypeScript 为 Context 提供类型,运行时根据服务名称和依赖声明决定插件何时可用。

Declaration merging 提供 Context 类型

Cordis v3 的 service package 通过 module augmentation 扩展 Context。以 timer package 为例,下面保留类型扩展与服务注册,省略具体计时器实现:

import { Context, Service } from '@cordisjs/core'

declare module '@cordisjs/core' {
	interface Context {
		timer: TimerService
		setTimeout(callback: () => void, delay: number): () => void
	}
}

export class TimerService extends Service {
	constructor(ctx: Context) {
		super(ctx, 'timer')
		ctx.mixin('timer', ['setTimeout'])
	}

	// setTimeout 的具体实现见下方源码链接。
}

当包含这些声明的 package 进入 TypeScript program 后,timer 和 setTimeout 会合并进 Context interface,编辑器因而能检查 ctx.timer 和 ctx.setTimeout() 的使用。完整实现见 timer package 源码。

declare module 建立静态类型表面;服务是否已经注册、当前是否可用、何时释放,则由 Cordis runtime 处理。类型中存在某个属性,与当前插件可以使用该服务,是需要分别表达的两件事。

一个名称连接注册、依赖和访问

以 database 服务为例,下面三个片段分别位于 provider 和 consumer 中:

// provider:以 database 名称注册服务
super(ctx, 'database')

// consumer:声明自己依赖 database 服务
export const inject = ['database']

// consumer:通过 Context 使用服务
ctx.database.get(table, id)

database 同时连接服务注册、依赖 metadata 和 Context 属性。Cordis 的 Service 文档 明确约定:必需服务为真值时插件才加载;服务变化时插件卸载;新值仍可用时插件重新加载。依赖可用性和动态生命周期已经是这套模型的一部分。

类型扩展可以检查 ctx.database 的属性访问,却不会自动生成或同步 inject: ['database']。对应源码中的 Inject 类型为 string[] | Dict<Inject.Meta>。如果 consumer 改用另一个已声明的 Context 属性,还需要同时维护依赖 metadata。

这种按名称连接的模型保留了运行时绑定的灵活性:consumer 可以声明所需的服务名称,而不必通过值导入绑定提供该服务的具体插件。常量、lint 或封装可以帮助维护名称的一致性;在上面的基础写法中,这几个位置仍是分别声明的。

Pluxel 为什么选择另一种依赖表达

在长期维护多个插件包时,我们希望构建工具能够沿源码回答:这个参数依赖哪个包的哪个插件导出?声明的依赖是否就是代码正在使用的依赖?这个身份能否继续用于配置、日志、替换和发行检查?

这对应三个具体问题:

  1. 声明与使用需要一起维护。 独立的依赖名称列表和属性访问容易在修改时不同步,尤其是两边各自仍满足 TypeScript 类型检查时。
  2. 服务名称没有直接表达来源。 名称能够参与运行时查找,但它本身不包含包根入口、具名导出或构建时记录的来源关系。
  3. 一部分错误希望提前发现。 服务是否存在仍需运行时判断;可追溯的导入、参数与包边界错误,则可以交给构建阶段检查。

Pluxel 因此把必需插件依赖写在构造函数中:

import { BasePlugin, Plugin } from '@pluxel/core'
import { AccountsPlugin } from '@acme/accounts'

@Plugin({ displayName: 'Billing' })
export class BillingPlugin extends BasePlugin {
	constructor(private readonly accounts: AccountsPlugin) {
		super()
	}
}

构建工具在 TypeScript 擦除类型之前,记录 AccountsPlugin 的值导入来源及构造函数参数位置。包根入口与具名导出确定插件定义,参数声明确定依赖边;运行时根据这些 metadata 注入当前的 provider 实例。

业务插件不再另写一份 inject 列表,也不通过扩展 Context 属性注册自己。构造函数依赖的具体规则见插件依赖与生命周期。

两种选择的区别

比较点Cordis v3 的上述 service 写法Pluxel 的必需插件依赖
consumer 如何声明依赖inject 中的服务名称构造函数中的插件参数与值导入
consumer 如何访问能力对应的 Context 属性注入的插件实例
类型从哪里来Context 的类型扩展和服务类型导入的插件类型与构造函数签名
依赖关系如何进入运行时运行时读取名称与依赖 metadata构建工具从源码生成依赖 metadata
provider 变化时怎么办依赖服务变化驱动卸载和重新加载依赖图与插件实例生命周期驱动替换、重启和清理
主要取舍保留按名称进行运行时绑定的灵活性以源码和构建约束换取可追溯的依赖身份

两者都提供类型支持,也都处理动态生命周期。Pluxel 增加的约束服务于源码、构建与运行时身份的一致性;这些检查也不能替代实际启动、业务校验或运行时的依赖可用性判断。

能力与所有权

需求标准表达生命周期
必需业务能力constructor + 值导入provider 变化驱动依赖图更新
可选业务集成definePluginRef() + plugins.use()不安装 provider;其运行代变化时重启 consumer
宿主设施root 创建前安装 Context services宿主准备与关闭,访问绑定 caller/owner
内部组成函数、effects scope 或 PluginPart跟随所属 Plugin generation
管理界面可选 Workbench publication每次打开创建 API;关闭或 owner 退出后释放

一次启动产生一个 generation。旧实例和缓存 handle 在停止或 replacement 后如何失效,是公开契约的一部分;不能仅按 class name 判断可用性。

Context 与本地依赖视图使用固定 owner 的对象或预编译成员,避免在并发异步调用间改写全局“当前调用者”。远程 Workbench 使用 Cap’n Web 的 RPC 对象模型;网络引用与本地资源仍各有明确 owner。 这些实现选择服务于可追溯、可撤回的调用,不构成比其他框架更快的承诺;目前没有与 Cordis 同场、同语义的性能对照。

源码也是契约

构造器 import 提供依赖,包根 named export 提供身份,Valibot schema 提供配置类型、默认值和校验。 Vite/Rolldown 在类型擦除前提取这些事实,runtime 依此启动、替换与清理;inspect 复用同一语义来定位修改位置。 普通 TypeScript 擦除无法代替这条工具链。

同一份应用声明可冻结为发行物,也可通过显式动态来源扩展 catalog。业务 Plugin 的依赖、配置与生命周期模型保持一致;开发期的真实状态通过devconsole读取。

选择与代价

这些能力建立在明确的约束之上:

  • 插件源码必须经过 Pluxel 的 Vite/Rolldown 处理,保留构建所需的依赖与资源信息。
  • 可发布插件必须来自可追溯的包根具名导出;无法确定稳定来源的动态写法会被构建工具拒绝。
  • 插件作者需要遵循依赖图和 generation 生命周期:长期资源登记清理,旧实例退出后不继续复用其句柄。
  • 应用显式选择插件清单和宿主能力,并承担它们的组合与交付配置。

这些限制适合需要长期维护插件组合、配置、热更新、可审计发行和资源回收的应用。它们也减少了一部分开放字符串注册和任意动态装配的自由度。是否值得采用,取决于应用是否需要这套可追溯的依赖与生命周期模型。

要实际体验,可以用快速开始创建已配置好工具链的项目;要继续理解作者模型,阅读插件依赖与生命周期。框架实现和约束的索引在工程文档。

最后更新于

本页目录