卷 V · 变CH 19深度 19/24

版本地狱:宿主升级,一万个插件怎么办

前十八章的问题,第一天就存在。这一卷的问题第一天全都不存在 —— 它们在你有了几百个插件、走过两年之后才浮出来,而那时候一切都已经很贵了。先从最狠的那个开始:你要改 API,而一万个人在依赖它。这一章会算出一个相当扎心的结论。

semver兼容矩阵MV2→MV3弃用节奏

先把账算出来

下面这张矩阵,每一格都是真的 semver 范围匹配算出来的。横着是宿主版本,竖着是各类插件在清单里写的 engine 范围 —— 第三列那个百分比是假设的生态构成(不是实测数据,但量级符合各家商店的观感):

盯着最后两列之间那一跳看。

「活下来几派」:4 派 → 3 派。看起来几乎没事。

「按占比 = 生态存活率」:75% → 20%。一夜之间报废 55%。

而且注意活下来的是谁

宿主 1.9.0宿主 2.0.0
活着的常规派(^1.0.0)、跟进派(^1.5.0)、乐观派(>=1.0.0)、躺平派(*乐观派、躺平派、押注未来(^2.0.0
死掉的钉死派、保守派、押注未来常规派、跟进派(合计 60%)
◆ 版本约定最黑色的一个笑话

换大版本时先死的,是那批老老实实写了版本范围的人。

诚实地写下 "engine": "^1.0.0" 的常规派 —— 也就是完全按 semver 规范办事、准确表达了「我在 1.x 上测试过」的那批人 —— 全灭

而什么都不写、engine* 的躺平派 —— 那些从没考虑过兼容性的人 —— 毫发无伤

这是一个真实存在的激励扭曲。你的版本规则如果只靠 semver,就是在惩罚认真的作者、奖励糊弄的作者。而后果会传导给用户:躺平派的插件「能装上」,然后在运行时以各种诡异方式炸掉 —— 比装不上更糟

所以:宿主不能只靠 semver。下面是真实系统的四种应对。

策略一:永不删除(VS Code)

最简单粗暴,也最有效:API 一旦发布,就永远不删。

VS Code 的公开 API 里有一些函数已经被标记废弃很多年,文档上写着「请改用 X」,但它们还在那里,还能用

代价换来的
API 表面越来越大,永远背着历史包袱插件几乎永不失效
新人看文档时会困惑「这两个有什么区别」用户升级编辑器不用担心插件挂掉
某些内部重构被永久堵死作者不需要持续维护也能让插件活着

这个策略成立的前提是:你的 API 是「加法」型的。VS Code 的 API 主要是「注册一个东西」「查询一个东西」,加新功能不需要改老函数的语义。

如果你的 API 里有大量「这个函数的行为取决于宿主状态」的东西,永不删除就守不住了 —— 因为行为会变,哪怕签名没变。这是比删除更隐蔽的破坏(第 22 章会提到「悄悄的语义变更」)。

VS Code 还有一个配套机制值得学:proposed API。新功能先以「提案 API」的形式放出来,只有开启特殊标志的插件能用,并且明确声明「随时可能改,不保证兼容」。这样它可以先让真实插件试用、收集反馈,等定型了再进正式 API。

关键在于:「不稳定」这件事是被显式标记的。没有这个机制,你要么冻结 API 演进,要么偷偷改然后弄死一批人。

策略二:硬白名单 + 快速迭代(Mihon)

完全相反的策略。第 11 章看到过 Mihon 的做法:扩展库版本必须在一个白名单里,对不上就整个不加载。

if (libVersion !in SUPPORTED_LIB_VERSIONS) {   // 撰写时是 listOf(1.4, 1.6)
    logcat(LogPriority.WARN) { "扩展库版本不支持,不加载:$pkgName" }
    return LoadResult.Error
}

为什么它敢这么硬?三个前提:

  • 扩展的分发不经过应用商店审核,作者能在几小时内发新版;
  • 扩展很小(通常几百行),改一个版本号重新编译的成本极低;
  • 它宁可「装不上」也不要「装上了但行为诡异」—— 因为一个坏掉的源,用户的第一反应是骂 App,不是骂那个源

硬拒绝的信息质量远高于软兼容:「这个扩展需要更新」是一句用户能理解、能行动的话;而「这个扩展有一半功能不工作」是没人能诊断的。

策略三:长弃用周期 + 工具(IntelliJ)

IntelliJ 走的是「明码标价的渐进淘汰」:

  • 插件在 plugin.xml 里声明 since-build / until-build,精确到 IDE 构建号;
  • 要废弃的 API 先标 @Deprecated,文档写明替代方案,保留数个大版本
  • 提供插件验证工具(verifyPlugin),插件作者能在 CI 里跑,提前知道自己在下一版 IDE 上会不会挂;
  • Marketplace 能按 IDE 版本筛选,用户只看到能装的。

最值得学的是那个验证工具。它把「你的插件会不会挂」从一个「等用户升级后才知道」的问题,变成了一个 CI 里的绿灯/红灯。

◆ 如果你只能从这一章抄一样东西

抄那个验证工具。

做一个能让插件作者在自己 CI 里跑的检查器:「拿我下一个版本的 API 定义,扫一遍你的插件,告诉你哪里会挂」。

它的价值在于把破坏性变更的发现时间,从「发布后」提前到了「发布前」。你甚至可以在自己发版之前,先拿全生态的插件跑一遍,看会死多少 —— 然后据此决定这个变更值不值得做。

这是把「我猜会有影响」变成「我知道会死 340 个插件,其中 12 个是头部插件」。有了这个数字,那个「要不要破坏兼容」的会议会开得完全不一样。

策略四:硬切 + 超长迁移期(Chrome)

当变更实在无法兼容时,剩下的只有硬切。Chrome 的 MV2 → MV3 是这类操作里规模最大的一次,值得完整看一遍时间线:

时间发生了什么
2018 年底MV3 提案公布,社区开始激烈争论
2020–2022MV3 逐步可用,工具链和文档跟上;多次推迟原定的淘汰节点
2024–2025MV2 扩展开始被逐步禁用,用户能看到警告
2025 年 7 月MV2 扩展在稳定版 Chrome 上彻底停止运行(重新启用的开关也被移除)
2026 年上半年企业策略豁免陆续取消;最后的开发者标志位被删除
2026 年 8 月 31 日Chrome 应用商店下架最后一批 MV2 扩展

来源:Chrome for Developers 的 MV2 弃用时间线,2026 年 7 月核对。

从提出到彻底完成:超过五年。而这是全球最大的浏览器厂商,有专职团队、有迁移文档、有自动化工具、有商店作为强制手段。

记住这个数字。它是「改一个已发布的插件契约」的真实成本下限。

⚠ 硬切时真正会发生的事

时间线上写着「迁移」,但实际发生的是:

① 头部插件会迁。有维护者、有用户压力、有商业动机的那些,都会完成迁移。

② 长尾插件会直接死掉。作者已经不维护了、作者换工作了、作者是三年前写完就走的学生 —— 这些插件不会有人来迁移,它们就是消失了。而长尾往往占插件总数的大多数。

③ 有些功能会永久消失。如果新 API 表达不了某类功能(MV3 削弱了某些网络拦截能力),那类插件不是「需要迁移」,而是「不可能存在了」。这是真实的能力损失,值得诚实承认。

④ 用户会怪你。用户不知道什么是 manifest 版本,他只知道「升级之后我的插件没了」。

所以硬切的成本,绝不只是「让作者改代码」。做这个决定前,请把上面四条都写进评估文档。

怎么在第一天就少挖点坑

这一节是给还没发布插件 API 的人的。下面每一条都是「现在花一小时,将来省一年」。

① 把 API 表面缩到最小

你没暴露的东西,永远不会成为兼容负担。

发布 API 时问:这个方法真的必须现在给吗?加一个 API 很容易,删一个要五年。宁可让插件作者先来提需求,再加 —— 那样你还能知道真实用法是什么。

② 让扩展点可选字段化,别用必填

// ✗ 加一个必填参数 = 破坏所有实现
interface Command { fun run(ctx: Context, mode: Mode) }

// ✓ 带默认值 / 用一个可扩展的参数对象
interface Command { fun run(params: RunParams) }
data class RunParams(val ctx: Context, val mode: Mode = Mode.Default)
//                   ↑ 以后加字段时给默认值,老实现照样编译通过

③ 版本范围要能表达「我在哪测过」和「我知道会挂」两件事

只有一个 engine 范围是不够的。考虑分开:

{
  "engine":     "^1.0.0",   // 我测试过的范围
  "engineMax":  "<3.0.0"    // 我确信会挂的边界(可选,缺省则乐观)
}

这样宿主可以做出更聪明的行为:超出测试范围但没超出已知边界 → 装上,但警告用户「这个插件未在当前版本测试过」。这比「一刀切拒绝」和「装上装死」都好。

IntelliJ 的 until-build 就是这个思路的一种实现,而且它默认是开放的(不写就表示不限制)—— 这个默认值的选择很关键,它避免了「所有插件在下个版本自动死掉」。

④ 建一个能跑全生态的兼容性测试

这是上面「验证工具」的宿主侧版本:在发版前,拿商店里的所有插件(或抽样)跑一遍你的检查器,得到一个「这个改动会死多少插件」的数字。

有了这个数字,破坏性变更就从一个价值观争论变成了一个成本收益计算。

装到自己身上

◆ 一份「我要改 API 了」的检查清单

每次准备改插件 API 时,按顺序过一遍:

□ 这个改动能不能做成「加一个新的」而不是「改现有的」?
□ 如果必须改,能不能让老写法继续工作(哪怕内部转发到新实现)?
□ 我能不能写一个检查器,扫出所有会受影响的插件?
□ 受影响的插件有多少个?其中活跃维护的有几个?
□ 我给了多长迁移期?(提示:比你以为需要的长一倍)
□ 迁移文档里有没有「改之前 → 改之后」的并排代码?
□ 有没有一个版本是「两种写法都能用」的?
□ 用户升级后看到插件失效时,会看到一句人话的解释吗?
□ 我有没有办法在插件商店里标记「此插件需要更新」?

倒数第二条最常被忘。用户不知道什么是 API 版本,他只知道「我的东西没了」。一句「『字数统计』需要更新以支持新版本,作者尚未发布更新」,能把一次差评变成一次理解。

◆ Kotlin:让废弃变成编译器的工作
// 给插件作者的 API 里,废弃要带可执行的迁移路径
@Deprecated(
    message = "改用 readNote(id, options),本方法将在 3.0 移除",
    replaceWith = ReplaceWith("readNote(id, ReadOptions.Default)"),
    level = DeprecationLevel.WARNING,      // 3.0 之前都是 WARNING
)
fun readNote(id: String): String? = readNote(id, ReadOptions.Default)

ReplaceWith 让 IDE 能一键自动修复 —— 插件作者按个 Alt+Enter 就迁完了。这个小小的注解参数,能把迁移率从 30% 提到 80%。

level 给了你一个渐进的节奏WARNING(提醒)→ ERROR(编译不过,但老的二进制还能跑)→ HIDDEN(只保留二进制兼容)→ 真正删除。四个档位,每档一个大版本,这就是一个体面的弃用周期。

这一章的一句话

换大版本时先死的是那批老老实实写版本范围的人 —— 所以宿主不能只靠 semver,必须配上迁移期、验证工具和诚实的弃用节奏;而「改一个已发布的插件契约」的成本下限,是 Chrome 用五年标出来的。

下一章看插件之间的关系:一旦允许它们互相依赖,你就多了一个包管理器的职责 —— 以及为什么很多系统刻意选择不支持这件事。