版本地狱:宿主升级,一万个插件怎么办
前十八章的问题,第一天就存在。这一卷的问题第一天全都不存在 —— 它们在你有了几百个插件、走过两年之后才浮出来,而那时候一切都已经很贵了。先从最狠的那个开始:你要改 API,而一万个人在依赖它。这一章会算出一个相当扎心的结论。
先把账算出来
下面这张矩阵,每一格都是真的 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–2022 | MV3 逐步可用,工具链和文档跟上;多次推迟原定的淘汰节点 |
| 2024–2025 | MV2 扩展开始被逐步禁用,用户能看到警告 |
| 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 里,废弃要带可执行的迁移路径
@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 用五年标出来的。
下一章看插件之间的关系:一旦允许它们互相依赖,你就多了一个包管理器的职责 —— 以及为什么很多系统刻意选择不支持这件事。