这一篇要解决的问题是:当多个模块共享同一个动态依赖时,系统复杂度为什么会突然上升。为了把这个问题讲清楚,我们不会一下子把场景做大,而是只再增加一个同样依赖 Clock 的模块,看看依赖图只是多出一条边时,外部同步逻辑会怎样膨胀。
1. 这一篇要解决的问题
现在再加入一个完全独立的能力:
Farewell它也依赖 Clock,于是依赖图变成:
Clock
/ \
↓ ↓
Greeter Farewell需要注意的是,Greeter 和 Farewell 彼此并不互相 import,也不应该知道对方存在。它们只是恰好依赖同一个 Clock。
这一篇的主线可以先压缩成下面这条路径:
一个依赖方还勉强能手写同步
↓
两个依赖方开始复制 refresh
↓
依赖图知识泄漏到外部流程
↓
Runtime 统一维护依赖图2. Vanilla:胶水代码开始复制
先定义 Farewell:
let farewell: Farewell | undefined
function refreshFarewell() {
farewell = clock ? makeFarewell(clock) : undefined
}现在每次 Clock 改变时,外部流程都必须同时刷新两个依赖方。加载时要写:
loadClock()
refreshGreeter()
refreshFarewell()卸载时要写:
unloadClock()
refreshGreeter()
refreshFarewell()恢复时仍然要写:
loadClock('evening')
refreshGreeter()
refreshFarewell()到这里问题就已经很清楚了。依赖图本身并不复杂,复杂的是调用点开始知道所有依赖方:
Clock lifecycle changed
│
├── refreshGreeter()
└── refreshFarewell()如果以后再增加 Notification、Reminder、Scheduler 等模块,那么外部流程就会继续膨胀成:
refreshGreeter()
refreshFarewell()
refreshNotification()
refreshReminder()
refreshScheduler()这时真正失控的不是某一行代码,而是依赖图正在以手写胶水代码的形式散落在系统里。
3. Cordis:新增依赖方时仍然只声明依赖
在 Cordis 中,Farewell 只需要像 Greeter 一样声明自己的依赖:
abstract class Farewell extends Service {
static inject = ['clock']
constructor(ctx: Context) {
super(ctx, 'farewell')
}
abstract farewell(name: string): string
}核心仍然只有这一句:
static inject = ['clock']这意味着依赖关系由每个依赖方自己声明,而不是由 Clock 的加载流程硬编码出来。因此,当我们新增一个 Farewell 实现时,只需要:
await ctx.plugin(EnglishFarewell)不需要回头修改 Clock 的生命周期代码,也不需要新增一个 refreshFarewell()。Clock 并不需要知道到底有多少模块依赖自己,Runtime 会根据依赖图自动处理这些模块的激活和失活。
4. 边际成本为什么开始分叉
如果 Clock 被卸载,那么两个依赖方都会一起失效;如果 Clock 恢复,它们又会一起重新出现。这个差异说明,Vanilla 和 Cordis 真正的分界点不在于某一个 Hello World 谁写得更短,而在于系统规模开始增长时,新增一个依赖方的边际成本是否还稳定。
在 Vanilla 中,新增一个依赖方,就意味着你必须在多个生命周期节点上补更多同步代码;而在 Cordis 中,新增一个依赖方,主要只是让新模块自己声明:
static inject = ['clock']依赖知识仍然保留在模块内部,而不是泄漏到外部流程中。
5. 这一篇的结论
这一篇真正想说明的是:当系统规模增长以后,问题不再是“能不能工作”,而是“系统的边际复杂度是不是在失控”。Vanilla 当然可以继续写下去,但依赖图会逐渐以 refreshXXX()、状态同步和清理逻辑的形式散落在系统各处;Cordis 则把依赖关系收回到声明式模型里,让新增模块时的额外成本更可控。
下一篇不再继续扩展示例,而是回过头来,把前面一路出现的 Context、Plugin、Service、Event、Lifecycle 和 inject 统一收束成一张完整的 Runtime 心智图。