npm、yarn 和 pnpm 的 lockfile 机制
前端项目里有一种问题特别磨人:代码没变,环境一换,项目坏了。
有时变化藏在间接依赖里:它发布了新版本,重新安装时被选中。业务代码和 package.json 都没动,node_modules 却已经不一样了。
lockfile 解决的就是这个落差:package.json 只说允许哪些版本,lockfile 记录这一次实际解析出的依赖树,让团队成员、CI 和发布环境尽量装到同一批包。
固定直接依赖,也不等于固定整棵依赖树
假设项目里有这样一条依赖链。
项目依赖 A 的 0.0.1:
{
"name": "project",
"dependencies": {
"A": "0.0.1"
}
}
A 自己又依赖 B:
{
"name": "A",
"dependencies": {
"B": "^1.0.0"
}
}
项目可以把 A 固定到 0.0.1,但 B 的版本范围由 A 声明。这里的 ^1.0.0 允许安装 1.0.0 及以上、2.0.0 以下的普通发布版本。
今天安装时选中 B@1.0.0,过几天它发布了 1.0.1。如果没有锁定解析结果,重新安装就可能选中新版本。
注意,^ 不是一律允许更新 patch 版本。对于 ^0.0.1,0.0.2 已经不在允许范围内;node-semver 的 caret 规则以最左侧非零数字决定兼容上限。
package.json 说的是「需要什么依赖,以及允许什么版本范围」。lockfile 记的是「这一次实际解析出了哪些版本、从哪里下载、完整性校验是什么」。一个是声明,一个是结果。
yarn.lock 做了什么
Yarn 用 yarn.lock 保存解析结果。下面的锁文件格式和 yarn upgrade、--frozen-lockfile 命令都以 Yarn Classic 为例。
还是上面的例子,简化后的 yarn.lock 大概会像这样:
A@0.0.1:
version "0.0.1"
dependencies:
B "^1.0.0"
B@^1.0.0:
version "1.0.0"
第二段记录了这次选择:A 需要 ^1.0.0 范围内的 B,实际选中的是 B@1.0.0。
后面即使 B 发布了 1.0.1,只要声明与锁文件仍然匹配,普通安装就会继续使用锁定版本,而不是追着新版本走。Yarn Classic 的安装说明也区分了锁文件足以满足声明和需要更新两种情况。
lockfile 最有用的地方就在这里:依赖可以更新,但更新要变成一次看得见的文件变更。
那想升级依赖怎么办
lockfile 不会把依赖永远固定住。
如果你明确要把 A 从 0.0.1 升到 0.0.2,可以通过包管理器命令更新:
yarn add A@0.0.2
或者:
yarn upgrade A@0.0.2
也可以手动修改 package.json 后重新安装。
A@0.0.1 不能满足新的精确版本要求,包管理器就需要为 A@0.0.2 及受影响的依赖更新解析结果。不是每次修改 package.json 都要重新选择全部依赖;已有版本仍满足声明时,可以继续复用。
升级后要一起检查 package.json 和 lockfile。前者显示改了什么声明,后者显示实际带动了哪些依赖变化。
npm 的 package-lock.json
npm 用 package-lock.json 保存解析结果。按当前 npm install 文档,锁定版本满足声明时就复用;不满足时,重新解析相应版本并更新锁文件。
CI 里通常用 npm ci。它要求已有锁文件与声明一致,不一致就报错,不会代替开发者更新;已有的 node_modules 会先被移除。如果生成锁文件时使用了影响依赖树的配置,CI 也需要使用相同配置。
pnpm 的 pnpm-lock.yaml
pnpm 的锁文件叫 pnpm-lock.yaml。
它和 yarn/npm 一样,也会记录依赖解析结果,让安装过程可复现。pnpm 还有一个差异:它的依赖存储和 node_modules 结构更严格。
pnpm 会把包内容放到全局内容寻址存储里,再通过硬链接和符号链接组织项目的 node_modules。这让它更节省磁盘,也更容易暴露那些「明明没有声明依赖,却因为扁平化 node_modules 偶然能 import 到」的问题。
在 CI 中,对 pnpm 项目通常会用:
pnpm install --frozen-lockfile
这个命令会要求当前 pnpm-lock.yaml 和 package.json 保持一致。如果不一致,它会失败,而不是悄悄更新 lockfile。
平时怎么对待 lockfile
前端应用、后端服务、网站和桌面应用需要复现部署环境,lockfile 应该提交到 Git。团队也要选定包管理器:pnpm 项目保留 pnpm-lock.yaml,不同时维护 package-lock.json 和 yarn.lock,避免不同安装入口各用各的解析结果。
升级时,package.json 的 diff 说明声明改了什么,lockfile 的 diff 则说明实际带动了哪些直接、间接依赖。两份变化都要审查,不能因为锁文件改动多就跳过。
CI 只安装已经确认的结果,不在发布时临时升级。npm、pnpm 和 Yarn Classic 分别使用下面的命令,按项目选一条:
npm ci
pnpm install --frozen-lockfile
yarn install --frozen-lockfile
整个 monorepo 共用一个 workspace 时,由根目录统一管理锁文件;独立安装、独立发布的子项目则分别判断,不能仅凭位于同一个仓库就删掉它们的锁文件。
锁文件也不能包办整个环境。Node 和包管理器版本、安装配置、平台相关依赖及安装脚本仍会影响结果,需要和项目环境一起维护。
总结
固定直接依赖仍可能留下间接依赖的版本漂移。package.json 声明允许的版本,lockfile 保存实际解析结果;声明与锁定结果匹配时,安装可以复用这批版本,需要升级时再更新受影响的依赖,并审查两份文件的变化。
npm、Yarn Classic 和 pnpm 的锁文件名称、安装命令不同,但团队协作都需要共享同一份确认过的结果。提交锁文件、统一包管理器、在 CI 中拒绝声明与锁文件不一致,是复现依赖的基础;完整环境还要连同运行时、安装配置和平台差异一起维护。