npm、yarn 和 pnpm 的 lockfile 机制
前端项目里有一种问题很烦:本地开发和测试都好好的,换一台机器、换一次 CI,或者隔几天重新安装依赖,项目突然就坏了。
排查到最后,发现不是业务代码变了,而是某个间接依赖发布了新版本。package.json 没动,但安装出来的 node_modules 已经不一样了。
lockfile 就是为这种问题准备的。它让同一份依赖声明,在团队成员、CI 和发布环境里尽量安装出同一棵依赖树。
package.json 只声明范围,不等于锁死版本
用一个最小例子看会清楚一点。
项目依赖 A 的 0.0.1:
{
"name": "project",
"dependencies": {
"A": "0.0.1"
}
}A 自己又依赖 B:
{
"name": "A",
"dependencies": {
"B": "^0.0.1"
}
}项目自己可以把 A 固定到 0.0.1,但 B 是 A 声明的间接依赖。^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.json 和 yarn.lock 仍然匹配,安装时就会继续使用 lockfile 里记录的版本。
lockfile 最有用的地方就在这里:依赖不是不能更新,而是更新要变成一次看得见的文件变更。
那想升级依赖怎么办
lockfile 不是把依赖冰封起来。
如果你明确要把 A 从 0.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.json和package-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.yaml 和 package.json 保持一致。如果不一致,它会失败,而不是悄悄更新 lockfile。发布环境里,依赖树最好就这样被卡住。
平时怎么对待 lockfile
现在基本按下面几条处理。
应用项目提交 lockfile。
如果这是一个前端应用、后端服务、网站、桌面应用,或者任何需要部署运行的项目,lockfile 都应该进 Git。否则每个人安装出来的依赖都可能不一样,CI 和线上也可能不一样。
不要把不同包管理器的 lockfile 混在一起。
如果项目用 pnpm,就保留 pnpm-lock.yaml,不要同时提交 package-lock.json 和 yarn.lock。多个 lockfile 会让后来的人不知道该信谁。
升级依赖时看 lockfile diff。
很多风险不是写在 package.json 里的。你可能只改了一个直接依赖,但 lockfile 里会显示它带动了哪些间接依赖变化。这个 diff 是依赖升级 review 的重要部分。
CI 用 frozen 或 clean install。
不同工具名字不完全一样,但目标一致:发布环境不应该临时解析一棵新依赖树。
npm ci
pnpm install --frozen-lockfile
yarn install --frozen-lockfilemonorepo 通常只保留根 lockfile。
如果整个仓库共用一个 workspace,依赖解析应该由根目录统一管理。每个包里再散落自己的 lockfile,反而会让依赖状态变得难判断。
现在的看法
lockfile 不是脏文件,也不是「装包时顺手生成的噪声」。
它记录的是一次完整依赖解析结果。
项目要稳定安装、稳定测试、稳定发布,就要提交它、审查它,并让 CI 在它不一致时及时失败。