约 8 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
版本范围、lockfile、peerDependencies 是三条互不重叠的规则,混在一起讨论就会得出错误结论。package.json 里的范围回答「允许装哪些版本」;lockfile 回答「上次实际装了哪个版本、从哪个地址下的、哈希是多少」;peerDependencies 回答「我这个包要求宿主项目提供哪个版本」。升级失控多数不是范围写错,而是 lockfile 没提交或被手动删掉;ERESOLVE 则几乎总是第三层的问题。排查顺序也应该照这个层次来:先看 lock 在不在,再看谁提出了 peer 要求,最后才考虑改范围。把这三层混为一谈,最典型的结果就是「删了 lock 重装」——它能让报错消失,也能让三个月后的构建产出完全不同的依赖树。
| 写法 | 实际区间 | 什么时候用 |
|---|---|---|
| 1.2.3 | 只有 1.2.3 | 需要绝对可复现,或上游明确只兼容这个版本 |
| ~1.2.3 | >=1.2.3 <1.3.0 | 只接受补丁修复,对次版本新特性不放心的生产依赖 |
| ^1.2.3 | >=1.2.3 <2.0.0 | 默认选择,适用于遵守 semver 的库 |
| ^0.2.3 | >=0.2.3 <0.3.0 | 0.x 阶段,效果接近「只升补丁」 |
| 1.2.x | >=1.2.0 <1.3.0 | 老项目里常见,区间与 ~ 相同但写法更宽松 |
| >=1.2.3 <1.4.0 | 自定义上下界 | 需要跨一个次版本,但不想跟到大版本末尾 |
| * 或 latest | 任意版本,含跨大版本 | 几乎不要用,等于放弃可复现性 |
semver 的三段是 major.minor.patch,分别对应不兼容变更、向后兼容的新功能、向后兼容的修复。麻烦在于 0.x 阶段没有稳定契约,npm 索性把次版本号当作破坏性信号,所以 ^0.2.3 的实际区间是 >=0.2.3 <0.3.0,而不是很多人以为的 <1.0.0。另一条边界是预发布版本:1.2.3-beta.1 不满足 ^1.2.3,因为范围匹配默认排除预发布,只有显式写出 -beta 或者用 || 把两段范围拼起来才会命中。自己发库时如果希望 beta 能被装上,得把范围写全,别指望 caret 帮你兜住。还有两个容易忽略的写法:^0.0.3 等价于 >=0.0.3 <0.0.4,几乎等于锁死;0.2.x 等价于 >=0.2.0 <0.3.0,与 ~0.2.3 的区间完全一致。
lockfile 只有一个判断标准:这个仓库的产物是否需要被别人以完全相同的依赖树复现。应用要提交 package-lock.json,因为它固定了线上构建用的每个 tarball 和 integrity 哈希;库不提交,因为使用方会生成自己的 lock,你的 lock 只贡献噪音和合并冲突。npm install 会在满足范围的前提下更新 lock,可能顺手改动 node_modules 的一部分;npm ci 要求 lock 与 package.json 完全一致,先删掉整个 node_modules 再按 lock 安装,不一致就直接以 EUSAGE 错误退出。CI 里用 ci 的价值不在快,而在于把「lock 过期」变成一个会失败的显式错误,而不是一台机器上悄悄通过的构建。还有一个版本细节值得知道:lockfileVersion 在 npm 7 和 8 里默认写 2,npm 9 起默认写 3,团队里 npm 版本不统一时 lock 会被反复改写,diff 里全是噪音。把 Node 与 npm 版本固定在 .nvmrc 或 engines 里,比事后 review 几百行 lock 差异便宜得多。
{
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"react": ">=17 <19",
"react-dom": ">=17 <19"
},
"peerDependenciesMeta": {
"react-dom": { "optional": true }
},
"overrides": {
"xml2js": "0.6.2",
"some-lib": { "xml2js": "0.6.2" }
}
}npm ls xml2js npm ls xml2js --all npm explain xml2js
npm ci # 严格按 lock 安装,lock 过期会失败 npm install --dry-run # 只打印将要发生的改动,不落盘 npm install --omit=dev # 只装 dependencies,检查生产树能否独立成立
ERESOLVE 的报错文本分两半读:while resolving 后面说明是谁提出了 peer 要求,found in 后面说明当前树里实际装的是什么。最常见的情况是 A 要 react ^17、B 要 react ^18,而一个项目只能有一个 react。--legacy-peer-deps 的做法是完全退回 npm 6 的行为,忽略 peerDependencies 的自动安装与校验;代价是 peer 约束不再被保证,冲突从安装期推迟到运行期。--force 更粗暴,它跳过冲突检查继续装,可能在同一棵树里留下两个版本,也可能装上一个本身已经损坏的包,并且不会提醒你。overrides(npm 8.3 起)是唯一能「把答案说出来」的手段:在根 package.json 里压住某个传递依赖的版本,让整棵树收敛到同一个 react,而不是靠 flag 把检查关掉。判断树里是否存在重复,直接看 npm ls 的输出就够:同一个包出现两个版本号,说明安装已经分叉,这时候 overrides 加 npm dedupe 才有意义,否则只是在给分叉打补丁。
应用提交,库不提交。应用靠它复现线上依赖树,库的 lock 对使用方无效且会制造合并冲突。
不会。它只读取 lock,lock 与 package.json 不一致时以 EUSAGE 错误退出,而不是顺手修好。
不能。前者跳过冲突检查,可能留下重复甚至损坏的依赖树;后者让 peer 约束失效,只适合临时排查。
不会。overrides 只作用于当前项目的安装过程,不会被发布出去约束下游。