^1.2.3 和 ~1.2.3 到底差在哪:版本范围、lockfile 与 ERESOLVE 冲突排查

同一个 ^1.2.3,两台机器装出来的版本可能完全不同。把 semver 区间、lockfile 与 peerDependencies 三层机制拆开看,再谈 ERESOLVE 与 overrides,结论才不会正好相反。

约 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.00.x 阶段,效果接近「只升补丁」
1.2.x>=1.2.0 <1.3.0老项目里常见,区间与 ~ 相同但写法更宽松
>=1.2.3 <1.4.0自定义上下界需要跨一个次版本,但不想跟到大版本末尾
* 或 latest任意版本,含跨大版本几乎不要用,等于放弃可复现性

0.x 与预发布:两个最容易踩的边界

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 的区间完全一致。

五种依赖类型各管一段

  • dependencies:运行时真正需要的包,会被安装进使用方的 node_modules。
  • devDependencies:只在本仓库开发、构建、测试时需要,作为别人的依赖被安装时不会装。
  • peerDependencies:声明宿主必须提供的包,npm 7 起自动安装并校验,冲突即报 ERESOLVE。
  • optionalDependencies:安装失败不阻断整体安装,常用于平台相关的原生模块。
  • bundledDependencies:一个包名数组,把指定依赖打进自己的 tarball,同时必须在 dependencies 里声明。

lockfile 该不该提交,npm ci 到底做了什么

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 差异便宜得多。

把范围和覆盖写进 package.json

package.json:范围、peer 与 overrides
{
  "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" }
  }
}

两条命令:定位来源,验证结果

定位:谁把 xml2js 引进来的
npm ls xml2js
npm ls xml2js --all
npm explain xml2js
验证:不要靠删 node_modules 猜
npm ci                 # 严格按 lock 安装,lock 过期会失败
npm install --dry-run  # 只打印将要发生的改动,不落盘
npm install --omit=dev # 只装 dependencies,检查生产树能否独立成立

ERESOLVE 的真实成因,和两个 flag 的代价

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 才有意义,否则只是在给分叉打补丁。

一次可控升级的固定流程

  1. 先在干净环境跑 npm ci,确认问题能在 lock 上复现,而不是本机 node_modules 的历史残留。
  2. 用 npm ls <pkg> 和 npm explain 找出提出约束的那一层,分清是直接依赖还是传递依赖。
  3. 传递依赖冲突优先用 overrides 收敛版本;直接依赖冲突则回到范围写法,把 ^ 收窄成 ~ 或精确版本。
  4. 改完只重生成 lock(确认可重建时才删),跑构建与测试,再把 package.json 和 lock 放在同一个提交里。

常见问题

package-lock.json 到底要不要提交到 git?

应用提交,库不提交。应用靠它复现线上依赖树,库的 lock 对使用方无效且会制造合并冲突。

npm ci 会更新 package-lock.json 吗?

不会。它只读取 lock,lock 与 package.json 不一致时以 EUSAGE 错误退出,而不是顺手修好。

--force 和 --legacy-peer-deps 能随便用吗?

不能。前者跳过冲突检查,可能留下重复甚至损坏的依赖树;后者让 peer 约束失效,只适合临时排查。

overrides 会影响使用我这个库的人吗?

不会。overrides 只作用于当前项目的安装过程,不会被发布出去约束下游。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件