编程 锁文件存在不等于被遵守:七个包管理器的默认行为与强制模式对照

2026-10-18 00:03:49

锁文件存在不等于被遵守:七个包管理器的默认行为对照

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 / v6
  • 2:npm v7 / v8,向后兼容 v1
  • 3: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,避免每次安装都重新处理整棵目录树。它只在同时满足三条时才会被采用:

  1. 它引用的所有包目录都存在;
  2. node_modules 里没有它未列出的包目录;
  3. 该文件的 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 . .

参考

复制全文 生成海报 锁文件 lockfile 依赖管理 npm CI 供应链

推荐文章

程序员茄子在线接单