卷 V · 变CH 20深度 20/24

依赖与冲突:插件之间也要说话

插件生态长到一定规模,必然有人问:「我能不能用另一个插件提供的功能?」你说「能」的那一刻,你的宿主就兼职成了一个包管理器 —— 加载顺序、缺失依赖、循环依赖、版本冲突,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 也不工作了」这种完全无法自行诊断的问题。

◆ Kotlin:拓扑排序 + 明确的错误类型
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 章那份清单。