钉死一个传递依赖不是被害妄想
一个你从没安装过的包,在一个你从没选过的版本上,弄坏了一个你从没编辑过的页面。lockfile 本该防住这个 —— 它为什么没有。

钉死一个传递依赖不是被害妄想 📌
这个 bug 看上去不可能。首页上一个视觉特效开始在运行时抛版本检查错误。我们没碰过那段代码、那个包,也没动过清单里的版本号。错误来自一个插件包拒绝在一个它认为版本不匹配的核心包上运行。
两个都是传递依赖。我们只要了一个库,它拉进来一整族兄弟包,而这些兄弟包在运行时互相检查版本 —— 一次全新安装把其中两个解析到了互不认可的版本上。
lockfile 为什么没救我们
lockfile 很好用,而它恰好有两个洞,我们两个都掉进去了。
lockfile 只在被使用的地方生效。 如果任何一个环境不带它安装 —— 一个只拷了清单没拷 lock 的容器构建、一个用了不同安装命令的 CI 步骤、一个会重新生成而不是尊重它的工具 —— 那个环境就会重新解析。我们的本机是钉住的,我们的构建在关键意义上不是。
lockfile 记录的是一次解析结果,不是你的意图。 当任何事情触发重新解析 —— 加一个包、一次大版本升级、镜像源抽风 —— 解析器可以自由挑选任何满足已声明范围的版本。而那些范围是你的依赖的作者声明的,为的是他们自己的兼容需要,不是为了他们的插件系统碰巧在运行时强制的那条不变量。
所以这次失败不需要反派。包 A 给它的核心声明 ^3.0.0。插件 B 也声明 ^3.0.0。一个解析到 3.0.0,另一个解析到 3.1.x,插件的运行时检查一比对,昨天还能渲染的页面今天就抛异常了。
运行时版本检查把「轻微错位」变成了「崩溃」
这类 bug 之所以尖锐而不是隐晦,源于一个具体模式:在运行时校验版本一致性的插件生态。 动画库、图表库、编辑器框架、打包器的插件族 —— 任何核心与插件共享内部契约的地方。
这个检查是站得住的。作者们是在防止一种糟糕得多的失败:内部结构错位而静默地把状态搞坏。但它把一次通常无害的版本偏移,转换成了生产环境里的硬错误 —— 而且发生在你没写的代码里,关于你没点过名的包。
修法,按力度递增
overrides。 每个主流包管理器都有办法把一个传递依赖在整棵树上强制到确切版本 —— npm 和 bun 的 overrides、yarn 的 resolutions、pnpm 的 pnpm.overrides。当一族兄弟包必须一致时,把整族都钉住:
{
"overrides": {
"@example/core": "3.0.0",
"@example/plugin-a": "3.0.0",
"@example/plugin-b": "3.0.0"
}
}钉每一个成员,不只是抛错的那个。在一个互相检查的家族里只钉一个成员,只是换了哪一对在吵架。
到处都用 lockfile 安装。 在容器里,把 lockfile 和清单放在同一层拷进去,并使用 frozen 安装标志。一个被允许在 CI 里重新解析的安装,已经悄悄退出了你以为自己拥有的那份保证。
给这个钉子写注释。 没有解释的版本钉死,和一次意外无法区分,而下一个做依赖清理的人会把它删掉。一行字 —— 什么坏了、什么报错、大概什么时候 —— 就是"一个决策"和"一个谜"之间的区别。
什么时候拔掉钉子
钉子是债。它把你冻在安全补丁之外,也让下一次大版本升级更难。纪律在于:每个钉子在加上去的那一刻,就写下它的退出条件 —— 上游兼容问题解决后拔掉,或者下次升级父库时拔掉。
有条通用原则值得直说,因为它很容易被记反。你的依赖声明的版本范围表达的是它的兼容需要,不表达你的应用所要求的不变量。当两者分歧时 —— 而运行时互检的插件族正是分歧发生的地方 —— 那个钉子不是被害妄想,而是你把一条别人无从得知的需求写了下来。