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。

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

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

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

升级依赖时看 lockfile diff,不要讨厌它的 diff。

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

CI 用 frozen 或 clean install。

发布时不应顺便升级依赖。下面分别是 npm、pnpm 和 Yarn Classic 的命令,按项目使用的工具选一条:

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

monorepo 通常只保留根 lockfile。

如果整个仓库共用一个 workspace,依赖解析应该由根目录统一管理。独立安装、独立发布的子项目则另当别论,不能仅凭它们在同一个仓库里就删掉锁文件。

锁文件也不能包办整个环境。Node 和包管理器版本、安装配置、平台相关依赖及安装脚本仍会影响结果,需要和项目环境一起维护。

现在的看法

我会把 lockfile 和依赖声明放在一起审查:日常升级时确认解析变化,CI 安装时拒绝临时更新。这样依赖变动发生在一次明确的提交里,而不是某台机器重新装包的时候。