锁文件存在不等于被遵守:七个包管理器的默认行为对照
package-lock.json 提交进仓库、CI 里跑过 install,构建机上装出来的依赖却和本地不是一套版本——这类问题通常不是锁文件坏了,而是它压根没被当成权威来源。锁文件由包管理器自动生成,不该手改,但「生成」和「遵守」是两件事。要判断一个项目是否真的锁住了依赖,得看两个独立维度:
- 是否尊重(respect):安装时只取锁文件里记录的版本,还是重新解析并静默改写锁文件。
- 是否强制(enforce):锁文件与声明文件或校验和不一致时,是报错停下,还是悄悄覆盖过去。
大多数事故出在第二个维度上:默认尊重、但不强制。
默认行为对照
| 包管理器 | 默认是否尊重锁文件 | 冲突时的默认行为 | 强制失败的命令 |
|---|---|---|---|
| npm | 是 | 静默覆盖锁文件 | npm ci(EUSAGE) |
| pnpm | 是 | 静默覆盖锁文件 | pnpm install --frozen-lockfile |
| yarn | 是 | 覆盖锁文件 | yarn install --frozen-lockfile |
| Cargo | 否,重新解析并静默更新 | 更新锁文件 | cargo build --locked / --frozen |
| Pipenv | 否,重新解析并静默更新 | 更新锁文件 | --deploy / pipenv sync |
| Poetry | 是 | 报错 | 默认即强制 |
| Gradle | 是 | 报错 | 默认即强制 |
| Go | 是 | 报错 | 默认即强制 |
几个容易踩反的点:
Cargo 和 Pipenv 是默认不尊重锁文件的两家。cargo build 会重新解析依赖并顺手把 Cargo.lock 改掉,本地看起来一切正常,CI 上却拿到了一套新版本;要它按锁文件来,必须显式 --locked 或 --frozen。Pipenv 同理,需要 --deploy 或 pipenv sync。
这两个开关同时完成「尊重」和「强制」两件事——版本被锁死,且不一致就失败。
Go 是默认强制的:它没有单独维护的依赖声明文件,下载时直接校验 go.sum 里的校验和,对不上就拒绝。Gradle 是唯一默认不生成锁文件的,得用 --write-locks 写出来,并在依赖声明里定义锁定状态,它才会参与解析;生成之后,Gradle 默认既尊重也强制。
npm 和 pnpm 的默认姿势最危险:解析时确实按锁文件来,但只要出现校验和或解析版本冲突,就静默覆盖锁文件,不报错。npm ci 和 --frozen-lockfile 才是把锁文件当唯一真相的开关。
npm install 与 npm ci 的机制差异
npm 内部把安装拆成两步:Arborist 先构建 ideal tree(读 package.json、package-lock.json,必要时问 registry 要元数据,产出一张内存里的依赖图),再把 ideal tree reify 到磁盘(对比现有 node_modules,缺的、错的位置从 registry 或缓存取 tarball,校验 integrity,解包放到正确路径)。
npm install 每次都跑 resolver。哪怕锁文件完美无缺,它也会读声明和锁、拉未缓存的元数据、重建 ideal tree,再和磁盘做 diff。diff 为空就什么都不装,但 resolver 已经跑完了——这一步是时间和网络开销的来源。
npm ci 完全跳过 resolver。它把锁文件当作唯一真相:删掉 node_modules,直接 reify 锁文件内容。不做约束求解,不会为了「有没有满足范围的新版本」去问 registry;只下载锁文件写明的包、校验哈希、链接文件。package.json 与锁文件对不上时,直接 EUSAGE 失败。
# 本地重装:会跑 resolver,可能改写锁文件
npm install
# CI / Docker:跳过 resolver,不一致即失败
npm ci
lockfileVersion 1 / 2 / 3
lockfileVersion 是个整数:
1:npm v5 / v62:npm v7 / v8,向后兼容 v13:npm v9+,向后兼容 v7
npm 总是尽力从任意版本的锁文件里取数据,所以混用版本通常不会直接炸,只是行为有差异。v7 起锁文件包含完整的包树信息,安装时不再需要反复读 package.json。
packages 对象是「包位置 → 快照」的扁平 map,空字符串表示项目自身,其余是 node_modules/... 路径。dependencies 段是 lockfileVersion 1 的遗留结构(按包名层级映射),v7 在有 packages 段时完全忽略它,但仍然保持更新,以便在 v6 / v7 之间来回切换。
node_modules/.package-lock.json 的隐藏锁文件
npm v7 起会在 node_modules 里放一份 .package-lock.json,避免每次安装都重新处理整棵目录树。它只在同时满足三条时才会被采用:
- 它引用的所有包目录都存在;
node_modules里没有它未列出的包目录;- 该文件的 mtime 不早于它引用的所有包目录。
换句话说,只有这份文件确实由最近一次包树更新产生,它才算数。其它 CLI 改动了树,会被检测到并忽略它。旧版 npm 直接忽略这个文件,所以它没有向后兼容层,恒为 lockfileVersion: 3。
integrity 与 EINTEGRITY
integrity 是 Subresource Integrity 字符串:算法名加上 tarball 内容的 base64 哈希。npm 取回 tarball 后重新计算并比对,不一致就报 EINTEGRITY 并拒绝安装。
npm error code EINTEGRITY
锁文件本质上是一份依赖集合的签名清单,integrity 是这份签名有意义的前提。resolved 字段保留来源 URL,配合 .npmrc 里的 registry 配置——只要签名匹配,包具体来自哪个镜像并不重要。这也意味着:改镜像不会破坏校验,改内容一定会。
锁文件冲突怎么重建
多人同时改 package.json 和锁文件,合并时几乎必然冲突。不要手改 JSON,正确做法是先把冲突解到某一侧,再重新生成:
# 锁文件先整体取某一侧
git checkout --theirs package-lock.json # 或 --ours
# 然后手工解决 package.json 的冲突
# 再让包管理器重新生成锁文件
npm install
也可以直接删掉锁文件和 node_modules 重来,但版本漂移要逐个 review——这条路径等于放弃锁文件里的历史约束。
CI 里应当使用强制模式:
npm ci
pnpm install --frozen-lockfile
yarn install --frozen-lockfile
composer install # 不是 composer update
cargo build --locked
poetry install --no-update # lock 已存在时
Docker 构建时注意层的顺序,先复制声明和锁文件再安装,缓存层才不会因为源码变动而失效:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
参考
- npm docs:package-lock.json — https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json
- The Design Space of Lockfiles Across Package Managers — https://arxiv.org/abs/2505.04834