npm、yarn 和 pnpm 的 lockfile 机制

前端项目里有一种问题很烦:本地开发和测试都好好的,换一台机器、换一次 CI,或者隔几天重新安装依赖,项目突然就坏了。

排查到最后,发现不是业务代码变了,而是某个间接依赖发布了新版本。package.json 没动,但安装出来的 node_modules 已经不一样了。

lockfile 就是为这种问题准备的。它让同一份依赖声明,在团队成员、CI 和发布环境里尽量安装出同一棵依赖树。

package.json 只声明范围,不等于锁死版本

用一个最小例子看会清楚一点。

项目依赖 A0.0.1

{
  "name": "project",
  "dependencies": {
    "A": "0.0.1"
  }
}

A 自己又依赖 B

{
  "name": "A",
  "dependencies": {
    "B": "^0.0.1"
  }
}

项目自己可以把 A 固定到 0.0.1,但 BA 声明的间接依赖。^0.0.1 这种 semver 范围表示「允许安装兼容的新版本」。

问题就出在这里:今天安装时 B 最新是 0.0.1,过几天 B 发布了 0.0.2,重新安装时就可能变成另一棵依赖树。

package.json 说的是「需要什么依赖,以及允许什么版本范围」。lockfile 记的是「这一次实际解析出了哪些版本、从哪里下载、完整性校验是什么」。一个是声明,一个是结果。

yarn.lock 做了什么

早期 npm 对确定性安装的支持不够好,yarn 当时很吸引人的一点,就是会用 yarn.lock 把解析结果记下来。

还是上面的例子,简化后的 yarn.lock 大概会像这样:

A@0.0.1:
  version "0.0.1"
  dependencies:
    B "^0.0.1"

B@^0.0.1:
  version "0.0.1"

这里看第二段就够了。A 说自己需要 ^0.0.1 范围内的 B,lockfile 则记录了当前实际选中的是 B@0.0.1

后面即使 B 发布了 0.0.2,只要 package.jsonyarn.lock 仍然匹配,安装时就会继续使用 lockfile 里记录的版本。

lockfile 最有用的地方就在这里:依赖不是不能更新,而是更新要变成一次看得见的文件变更。

那想升级依赖怎么办

lockfile 不是把依赖冰封起来。

如果你明确要把 A0.0.1 升到 0.0.2,可以通过包管理器命令更新:

yarn add A@0.0.2

或者:

yarn upgrade A@0.0.2

也可以手动修改 package.json 后重新安装。

这时包管理器会发现 package.json 里的版本范围和 lockfile 里的解析结果已经不匹配,于是重新解析依赖,并更新 lockfile。

一般把它理解成三句话:

  • package.json 没变时,lockfile 保证安装结果稳定。
  • package.json 变了时,包管理器会重新解析,并把新的结果写回 lockfile。
  • lockfile 的 diff 就是这次依赖变化的证据。

npm 的 package-lock.json

npm 也有 package-lock.json

npm v5 刚引入它时,行为经历过几次摇摆:有过过度相信 lockfile 的阶段,也有过过度跟随 package.json semver 范围的阶段。后来主流行为逐渐和 yarn 的思路靠近:同时看 package.json 和 lockfile,能匹配就按 lockfile 安装,不匹配就重新解析并更新 lockfile。

现在写项目,不太需要记那段历史里的每个小版本,记住几条使用习惯更有用:

  • 日常开发用 npm install 会在必要时更新 package-lock.json
  • CI 和发布更适合用 npm ci,它要求 package.jsonpackage-lock.json 对得上,并从干净环境安装。
  • 如果 lockfile 被意外修改,不要顺手带过,先看 diff,确认是不是一次真实依赖升级。

pnpm 的 pnpm-lock.yaml

pnpm 的锁文件叫 pnpm-lock.yaml

它和 yarn/npm 一样,也会记录依赖解析结果,让安装过程可复现。pnpm 还有一个差异:它的依赖存储和 node_modules 结构更严格。

pnpm 会把包内容放到全局内容寻址存储里,再通过硬链接和符号链接组织项目的 node_modules。这让它更节省磁盘,也更容易暴露那些「明明没有声明依赖,却因为扁平化 node_modules 偶然能 import 到」的问题。

在 pnpm 项目里,lockfile 不只是版本记录,它也配合 pnpm 那套更严格的依赖树模型一起工作。

在 CI 中,对 pnpm 项目通常会用:

pnpm install --frozen-lockfile

这个命令会要求当前 pnpm-lock.yamlpackage.json 保持一致。如果不一致,它会失败,而不是悄悄更新 lockfile。发布环境里,依赖树最好就这样被卡住。

平时怎么对待 lockfile

现在基本按下面几条处理。

应用项目提交 lockfile。

如果这是一个前端应用、后端服务、网站、桌面应用,或者任何需要部署运行的项目,lockfile 都应该进 Git。否则每个人安装出来的依赖都可能不一样,CI 和线上也可能不一样。

不要把不同包管理器的 lockfile 混在一起。

如果项目用 pnpm,就保留 pnpm-lock.yaml,不要同时提交 package-lock.jsonyarn.lock。多个 lockfile 会让后来的人不知道该信谁。

升级依赖时看 lockfile diff。

很多风险不是写在 package.json 里的。你可能只改了一个直接依赖,但 lockfile 里会显示它带动了哪些间接依赖变化。这个 diff 是依赖升级 review 的重要部分。

CI 用 frozen 或 clean install。

不同工具名字不完全一样,但目标一致:发布环境不应该临时解析一棵新依赖树。

npm ci
pnpm install --frozen-lockfile
yarn install --frozen-lockfile

monorepo 通常只保留根 lockfile。

如果整个仓库共用一个 workspace,依赖解析应该由根目录统一管理。每个包里再散落自己的 lockfile,反而会让依赖状态变得难判断。

现在的看法

lockfile 不是脏文件,也不是「装包时顺手生成的噪声」。

它记录的是一次完整依赖解析结果。

项目要稳定安装、稳定测试、稳定发布,就要提交它、审查它,并让 CI 在它不一致时及时失败。