依赖与冲突:插件之间也要说话
插件生态长到一定规模,必然有人问:「我能不能用另一个插件提供的功能?」你说「能」的那一刻,你的宿主就兼职成了一个包管理器 —— 加载顺序、缺失依赖、循环依赖、版本冲突,npm 花了十年才把这些坑趟完。这一章讲清代价,以及为什么「不支持」经常是更好的答案。
需求是怎么冒出来的
一开始所有插件都是孤岛,各干各的。然后:
有人写了一个很好用的 Markdown 渲染插件。另一个人想在自己的图表插件里复用它 —— 他不想再写一遍 Markdown 解析。
你有三个选择:
| 选择 | 做法 | 代价 |
|---|---|---|
| A. 各自打包 | 图表插件自己也打包一份 Markdown 库 | 内存里两份、版本可能不一致、包体积翻倍 |
| B. 提升进宿主 | 宿主自己提供 Markdown 渲染能力 | 宿主越来越胖;而且你怎么知道哪些该提升? |
| C. 插件间依赖 | 图表插件声明依赖 Markdown 插件 | 本章的全部内容 |
先说结论:能选 A 或 B 就别选 C。下面解释为什么。
选了 C 之后,你要解决的四个问题
下面这个 demo 里的解析器是真的(真的拓扑排序、真的循环检测)。切换三个场景看看:
问题一:顺序
被依赖的必须先激活。这是拓扑排序,算法本身很简单:
function resolveOrder(list) {
const byId = Object.fromEntries(list.map(p => [p.id, p]));
const state = {}; // undefined=没访问 1=访问中 2=完成
const order = [];
let cycle = null;
function visit(id, path) {
if (state[id] === 2) return true;
if (state[id] === 1) { // ← 访问中又遇到自己 = 环
cycle = path.slice(path.indexOf(id)).concat(id);
return false;
}
if (!byId[id]) return true; // 缺失,另行处理
state[id] = 1;
let ok = true;
for (const d of byId[id].deps ?? []) {
if (!visit(d, [...path, id])) ok = false;
}
state[id] = 2;
if (ok) order.push(id); // ← 后序:依赖先入列
return ok;
}
list.forEach(p => { if (state[p.id] !== 2) visit(p.id, []); });
return { order, cycle };
}
停用时要按相反顺序(demo 里也算了)—— 先停依赖别人的,再停被依赖的。否则一个插件在清理时可能发现它依赖的东西已经没了。
问题二:缺失
用户装了图表插件,但没装 Markdown 插件。宿主的三个选项,选哪个是产品决策:
| 做法 | 体验 | 适合 |
|---|---|---|
| 拒绝安装 | 清楚,但用户得自己去找那个依赖 | 依赖是硬性的、且不好自动解析时 |
| 装上但不激活 + 提示 | 较好,用户知道缺什么 | 大多数情况的默认选择 |
| 自动装上依赖 | 最顺畅 | 你有商店、且能保证依赖可信 |
绝对不能选的是第四种:装上就跑。那会在插件某一行代码里炸成一个 undefined is not a function,而错误信息完全指不到真正的原因。
问题三:循环
A 依赖 B,B 依赖 C,C 依赖 A。没有任何顺序能满足它们,只能整组拒绝。
关键是报错要能指出环的路径(demo 里输出的是 a → b → c → a)。只说「检测到循环依赖」,作者要自己画图找半天。
问题四:版本冲突
这是最麻烦的一个,也是 npm 生态那些著名痛苦的来源:
图表插件 依赖 markdown@^2.0.0 表格插件 依赖 markdown@^1.5.0 已安装的 markdown@2.1.0 → 表格插件的依赖不满足。怎么办?
可选的路:
- 只允许装一个版本(最简单)—— 冲突时拒绝其中一个,让用户选。清楚但难受。
- 允许多版本共存(npm 的做法)—— 每个插件拿到自己那份。但在 JVM 上这会撞上第 10 章那个
ClassCastException,在 JS 里会出现「两个 markdown 实例,状态不共享」的诡异问题。 - 不支持插件间依赖 —— 见下一节。
就算技术上让两个版本共存了,还有一类问题解决不了:如果那个被依赖的插件持有全局状态(一个缓存、一个注册表、一个连接池),那么两个实例意味着两份状态。
症状是这样的:「我在图表插件里注册的自定义语法,为什么表格插件不认识?」—— 因为它们用的是两个 markdown 实例,各有各的注册表。
而这个问题在文档里几乎无法解释清楚,只能靠插件作者自己踩坑。这是「允许多版本」这个决定的隐藏成本。
为什么很多系统刻意不支持
回头看五个真实系统:
| 系统 | 支持插件间依赖吗 | 怎么处理复用需求 |
|---|---|---|
| Chrome | 基本不支持 | 各自打包;跨扩展通信要双方都明确配合(externally_connectable) |
| Mihon | 不支持,每个源是孤岛 | 共用逻辑提升到宿主的扩展库里 |
| Obsidian | 没有正式机制 | 插件之间靠约定互相探测(app.plugins.plugins['xxx']),全靠自觉 |
| VS Code | 有限支持:extensionDependencies + 插件可以导出 API | 大多数情况靠宿主 API,跨插件调用是少数 |
| IntelliJ | 完整支持:<depends> + 可选依赖 + 插件能开自己的扩展点 | — |
Chrome 和 Mihon 都完全有能力实现插件间依赖。它们选择不做,因为不做就永远不会有依赖地狱。
看这个交换:
放弃的:插件之间无法复用代码,可能有重复实现。
换来的:没有加载顺序问题、没有循环依赖、没有版本冲突、没有「装 A 要先装 B」的用户困惑、每个插件可以被独立地装卸而绝不影响别人、插件作者不需要理解任何依赖概念。
对一个有上千个插件、作者水平参差的生态来说,这个交换极其划算。
而 IntelliJ 支持完整依赖,是因为它的插件是深度改造 IDE 的重型插件(Kotlin 插件、Python 插件本身就是别人的平台),不支持依赖它的生态根本长不起来。不同的处境,不同的答案。
如果非要支持,把它做小
三条能大幅降低痛苦的设计:
① 依赖只声明 id,不声明版本范围
听起来很不严谨,但它消灭了整个「版本冲突」问题类别。配套要求是:被依赖的插件必须保证向后兼容(和宿主 API 一样的要求)。
如果一个插件想被别人依赖,它就要承担和宿主同等的兼容性义务 —— 把这条写进文档,会让大部分插件作者主动选择不提供公开 API,那正是你想要的结果。
② 支持「可选依赖」
这是 IntelliJ 做得很好的一点。「如果 X 插件在,我就多提供一个功能;不在也没关系」:
<!-- 必需依赖:没有就装不上 --> <depends>com.intellij.modules.java</depends> <!-- 可选依赖:有就加载额外的配置,没有就跳过 --> <depends optional="true" config-file="my-kotlin-support.xml">org.jetbrains.kotlin</depends>
可选依赖的价值在于它避免了依赖爆炸。否则一个想「支持所有语言」的插件会不得不依赖所有语言插件,用户装一个插件带来二十个。
③ 让被依赖的一方显式导出
VS Code 的做法:插件的 activate() 可以返回一个对象,那就是它的公开 API。
// 被依赖的插件
function activate(context) {
return { // ← 返回值就是公开 API
renderMarkdown(text) { return html; },
};
}
// 依赖它的插件
const ext = vscode.extensions.getExtension('publisher.markdown-plugin');
if (!ext) { /* 优雅降级 */ return; }
await ext.activate(); // ← 确保它已经激活
const api = ext.exports;
const html = api.renderMarkdown(text);
这个设计的三个好处:
- 公开 API 是显式的 —— 插件作者主动决定暴露什么,而不是被别人从内部结构里挖走(对比 Obsidian 那种
app.plugins.plugins['xxx']直接摸内部对象的做法,那等于每个插件的所有内部实现都成了公开 API); - 依赖方必须处理「不存在」的情况 —— API 的形状(返回
undefined)逼你写降级分支; - 激活时机明确 ——
await ext.activate()把顺序问题变成了显式的等待,不需要宿主做全局拓扑排序。
如果你要做插件间依赖,直接抄这个形状。它把大部分复杂度从宿主转移到了「明确的双方约定」上。
另一条路:让宿主成为唯一的中介
最后一个思路,很多时候是最好的:插件之间不直接说话,通过宿主提供的机制间接协作。
回到开头那个需求 —— 图表插件想用 Markdown 渲染。如果宿主提供了一个「渲染服务」扩展点:
// Markdown 插件:往扩展点里注册一个渲染器
ctx.registry.contribute('renderers', {
format: 'markdown',
render: (text) => html,
});
// 图表插件:问宿主要一个渲染器,不关心是谁提供的
const renderer = ctx.renderers.find('markdown');
if (renderer) { const html = renderer.render(text); }
| 直接依赖 | 通过扩展点间接协作 |
|---|---|
| 图表插件依赖某一个具体插件 | 图表插件依赖一种能力 |
| 那个插件没了,它就挂了 | 换个插件提供同样能力,照样工作 |
| 需要版本协商 | 只需要符合宿主定义的接口 |
| 宿主要做拓扑排序 | 宿主只需要一个注册表(第 4 章那个) |
这是「依赖倒置」在插件系统里的形态:插件依赖宿主定义的抽象,而不是依赖彼此的具体实现。
代价是宿主要预先定义好这些抽象(「渲染器」「格式转换器」「数据源」)—— 但这本来就是你该做的工作(第 4 章:扩展点开在变化真正发生的地方)。
装到自己身上
当有插件作者来问「我能不能用另一个插件的功能」时,按顺序问:
① 这个能力是不是很多插件都需要?
是 → 提升到宿主,或者开一个扩展点(上一节那个方案)。这是最好的结局。
② 是不是只有这两个插件的特殊需求?
是 → 让他们各自打包,或者干脆合并成一个插件。别为一个个案加一整套依赖机制。
③ 确实需要真正的插件间依赖?
→ 用 VS Code 那个形状:显式导出 + 显式 await activate() + 依赖方必须处理不存在的情况。不要做版本范围,不要做自动解析,不要允许多版本共存。
④ 任何时候都要做的:把「谁依赖谁」显示给用户。用户禁用一个插件时,如果有别的插件依赖它,必须告诉他会连带影响什么 —— 否则他会遇到「我禁用了 A,为什么 B 也不工作了」这种完全无法自行诊断的问题。
sealed interface ResolveResult {
data class Ok(val activationOrder: List<String>) : ResolveResult
data class Missing(val needed: Map<String, List<String>>) : ResolveResult // 谁缺了什么
data class Cycle(val path: List<String>) : ResolveResult // 环的完整路径
}
fun resolve(plugins: List<PluginRecord>): ResolveResult {
val byId = plugins.associateBy { it.manifest.id }
val missing = plugins.mapNotNull { p ->
val lack = p.manifest.dependencies.filterNot { it in byId }
if (lack.isEmpty()) null else p.manifest.id to lack
}.toMap()
if (missing.isNotEmpty()) return ResolveResult.Missing(missing)
val order = mutableListOf<String>()
val state = mutableMapOf<String, Int>()
fun visit(id: String, path: List<String>): List<String>? {
when (state[id]) {
2 -> return null
1 -> return path.dropWhile { it != id } + id // ← 返回环的路径
}
state[id] = 1
byId[id]?.manifest?.dependencies?.forEach { dep ->
visit(dep, path + id)?.let { return it }
}
state[id] = 2
order += id
return null
}
plugins.forEach { p ->
visit(p.manifest.id, emptyList())?.let { return ResolveResult.Cycle(it) }
}
return ResolveResult.Ok(order)
}
// 停用时反过来
fun deactivateAll(order: List<String>) = order.asReversed().forEach { deactivate(it) }
注意 Cycle 带的是完整路径而不是一个布尔值 —— 这是那种「多写三行,帮插件作者省一小时」的地方。
这一章的一句话
允许插件互相依赖,你就兼职做了包管理器;在决定支持之前,先试试能不能把那个能力提升成宿主的一个扩展点 —— 让插件依赖「一种能力」而不是「某一个插件」,能一次性消灭顺序、循环和版本冲突三类问题。
下一章算一笔非常具体的账:一千个插件的冷启动要多久 —— 以及为什么答案会让你重新看待第 7 章那份清单。