前端依赖、lockfile 与可信构建
从 npm 依赖版本、传递依赖、lockfile、npm ci 和 pnpm frozen install 出发,整理前端项目如何获得更稳定、可复现的构建结果。
前端项目很少真正“只写自己的代码”。从构建工具、框架、组件库,到日期处理、请求封装、样式处理,一个项目背后通常站着一整棵依赖树。
npm 生态的好处非常明显:发包门槛低,社区包丰富,很多能力不用重复造轮子。代价也同样明显:依赖链很深,包质量参差不齐,越是现代化的项目,越容易拥有一个庞大的 node_modules。
English version: Frontend Dependencies, Lockfiles, and Reproducible Builds
这张老图仍然很传神:

依赖多不是原罪。真正的问题是:构建时安装到的依赖,是否就是开发、测试和发布时验证过的那一份?
如果答案是否定的,一个很小的需求改动,也可能因为某个依赖的变化让线上产物出现非预期行为。前端依赖管理的核心,不是拒绝第三方包,而是让依赖变化变得可见、可控、可回滚。
package.json 不够
前端项目通常在 package.json 里声明依赖:
{
"dependencies": {
"some-package": "^2.0.0"
}
}
语义化版本约定把版本号拆成 X.Y.Z:
X是主版本号,通常用于不兼容变更。Y是次版本号,通常用于向下兼容的新能力。Z是修订号,通常用于向下兼容的问题修复。
完整规范可以看 Semantic Versioning。
^2.0.0 的含义不是“永远安装 2.0.0”,而是在兼容范围内安装满足条件的版本。实际安装时,可能拿到 2.1.0、2.3.4,只要仍在 2.x 的范围内即可。~2.0.0 更保守一些,通常只允许修订号变化。
这种设计本身合理:补丁版本修 bug、次版本加能力,项目可以自动获得维护收益。但它依赖一个前提:包作者正确遵守语义化版本,且后续发布版本没有安全或质量问题。
现实里这个前提并不总是成立。维护者可能误发、可能低估 breaking change,也可能主动发布破坏性代码。colors.js/faker.js 事件就是典型例子:维护者发布带破坏行为的版本后,大量依赖链受到影响。类似的供应链事故很难完全靠“相信版本号”解决。
更麻烦的是,固定直接依赖版本也不够。
传递依赖才是深水区
把 package.json 里的依赖都写成精确版本,看起来能减少波动:
{
"dependencies": {
"some-package": "2.0.0"
}
}
这只能锁住项目直接声明的依赖。一个前端包往往还会依赖其他包,其他包再继续依赖更多包。随便打开一个项目的 node_modules,经常能看到这样的结构:

直接依赖不带 ^ 和 ~,并不代表它的依赖也全部固定。真正参与构建的是完整依赖树,而不是 package.json 里能看到的那几行。
因此,前端项目需要锁住的不是“几个直接依赖的版本号”,而是“某一次安装解析出的整棵依赖树”。
lockfile 解决什么
npm 的 package-lock.json、pnpm 的 pnpm-lock.yaml、Yarn 的 yarn.lock,解决的是同一个核心问题:记录一次安装得到的完整依赖解析结果。
以 package-lock.json 为例,它会记录:
- 依赖树里每个包的具体版本。
- 包从哪里解析而来,例如 registry tarball 或 git commit。
- 包内容的完整性校验信息,例如
integrity。 - 依赖之间的关系。
npm 官方文档也明确说明,package-lock.json 用来描述一份依赖树,使团队成员、部署环境和 CI 可以安装到完全相同的依赖。这也是 lockfile 应该提交到源码仓库的原因。
有了 lockfile,项目就从“每次按版本范围重新解析依赖”,变成“优先复现上次已经解析过的依赖树”。这一步是可信构建的基础。
这里的“可信构建”不是说构建过程已经具备密码学意义上的完全可证明性,而是指至少满足几个工程要求:
- 同一个提交在不同机器上安装到相同依赖。
- CI、测试、部署使用同一份依赖解析结果。
- 依赖变化以 lockfile diff 的形式进入代码审查。
- 构建失败时可以回到某个 Git 提交复现现场。
npm install 与 npm ci
npm install 和 npm ci 都能安装依赖,但定位不同。
npm install 是日常维护依赖的命令。它会读取 package.json 和 package-lock.json,如果 lockfile 里的版本仍满足 package.json 的版本范围,npm 会继续使用 lockfile 里的具体版本;如果不满足,npm 会重新解析并更新 lockfile。
所以 npm install 适合这些场景:
- 初始化项目依赖。
- 新增依赖。
- 删除依赖。
- 升级依赖。
- 修改依赖版本范围。
npm ci 更适合自动化环境。根据 npm 文档,它面向测试平台、持续集成和部署等场景。它的关键行为是:
- 必须存在
package-lock.json或npm-shrinkwrap.json。 - 如果 lockfile 与
package.json不匹配,直接失败,而不是自动更新 lockfile。 - 安装前会清理已有的
node_modules。 - 不会写入
package.json或 lockfile,安装过程是 frozen 的。
这正是 CI/CD 需要的行为:如果依赖描述不一致,应当暴露问题,而不是在构建机器上悄悄生成一份新的依赖图。
对于 npm 项目,一个基础流程可以这样定:
# 开发者明确要新增或升级依赖时
npm install some-package
# 刚拉代码、切分支、重装依赖、排查问题、CI 和部署时
npm ci
如果生成 package-lock.json 时使用过会影响依赖树形状的 npm 配置,例如 legacy-peer-deps 或 install-links,这些配置也应该沉淀到项目级 .npmrc 并提交到仓库,否则 npm ci 在其他环境可能安装失败。
pnpm 项目怎么做
pnpm 的思路类似,但命令不同。
pnpm 项目提交的是 pnpm-lock.yaml。在 CI 环境里,如果存在 lockfile 但它需要更新,pnpm install 默认会失败;显式写法是:
pnpm install --frozen-lockfile
这个命令表达得更直接:不更新 lockfile;如果 lockfile 与 manifest 不一致,就让安装失败。
所以 pnpm 项目的基础流程可以这样定:
# 开发者明确要新增或升级依赖时
pnpm add some-package
# 刚拉代码、切分支、重装依赖、排查问题、CI 和部署时
pnpm install --frozen-lockfile
如果项目使用 monorepo,还要注意 workspace 范围。依赖变化可能影响多个包,lockfile diff 也会更大。越是这种场景,越应该把依赖升级从普通业务改动里拆出来,单独提交、单独验证。
包管理器版本也要固定
lockfile 锁住的是依赖树,但不同包管理器、不同主版本的解析算法和 lockfile 格式也可能不同。团队里有人用 npm,有人用 pnpm,或者同一个项目里 pnpm 版本跨度太大,都可能让 lockfile 产生不必要的变化。
项目最好同时固定这些信息:
{
"packageManager": "pnpm@10.10.0",
"engines": {
"node": ">=24 <25"
}
}
packageManager 能让工具知道这个项目期望使用哪个包管理器及版本。配合 Corepack 或团队约定,可以减少“我本地 pnpm 版本不一样所以 lockfile 变了”的问题。
Node 版本也应该固定。可以用 .nvmrc、Volta、asdf、mise 或 CI 配置来约束。核心目标不是追求某个工具,而是让开发机、CI、部署机使用同一组运行时前提。
依赖更新应该是一个显式动作
可信构建不是永远不升级依赖。长期不升级会带来另一个问题:漏洞修复拿不到,生态适配不上,最终一次性升级成本更高。
更合理的做法是把依赖更新变成显式动作:
- 普通业务开发尽量不要顺手升级依赖。
- 新增或升级依赖时单独提交,让
package.json和 lockfile diff 容易审查。 - CI 和部署始终使用 frozen install。
- 定期做依赖维护,例如每两周或每月集中处理一次。
- 依赖升级后跑完整测试和构建,必要时补一次人工回归。
这样做的好处是,依赖变化不会混在业务 diff 里。代码审查时可以清楚看到:升级的是哪个包、带来了哪些传递依赖变化、有没有新的 install script、有没有替换 registry 或 git 来源。
lockfile diff 不需要逐行读完,但几个信号值得关注:
- 是否出现陌生的高风险包。
- 是否新增大量传递依赖。
- 是否从 registry 包变成 git/tarball/url 来源。
- 是否出现新的
postinstall、install、preinstall脚本。 - 是否有跨主版本升级。
- 是否改动了包管理器版本或 lockfileVersion。
npm audit、pnpm audit、GitHub Dependabot 这类工具可以提供安全信号,但不适合无脑 audit fix --force。自动修复可能跨主版本升级,也可能引入新的行为变化。安全修复仍然要进入正常的测试和发布流程。
不要提交 node_modules
偶尔会有人建议把 node_modules 一起提交到仓库。这个思路的动机可以理解:既然担心安装阶段变化,那就把安装结果也纳入版本控制。
但对绝大多数前端业务项目来说,这不是一个好默认值:
- 仓库体积会急剧膨胀。
- diff 很难审查。
- 跨系统、跨 CPU 架构、原生依赖会更麻烦。
- 安装脚本、构建产物、软链接等细节不一定适合直接进 Git。
- 团队日常开发和代码托管体验都会变差。
Chrome 这类超大型项目有自己的工程背景和基础设施,不能直接套到普通 Web 项目上。更常规的做法仍然是:提交 lockfile,不提交 node_modules,在 CI/部署阶段用 frozen install 复现依赖。
推荐实践
整理成一份可执行的清单:
- 提交
package-lock.json、pnpm-lock.yaml或yarn.lock。 - 不提交
node_modules。 - CI、测试、部署使用
npm ci或pnpm install --frozen-lockfile。 - 开发者刚拉代码、切分支、重装依赖时,也优先用 frozen install。
- 只有新增、删除、升级依赖时,才使用
npm install、pnpm add、pnpm update等会修改 lockfile 的命令。 - 依赖变更尽量单独提交,便于审查和回滚。
- 固定 Node 和包管理器版本,避免工具版本差异导致 lockfile 抖动。
- 项目级 npm/pnpm 配置要提交到仓库,特别是会影响依赖解析的配置。
- 使用 audit、Dependabot 等工具获取安全信号,但把修复纳入正常测试发布流程。
- 添加依赖前先判断是否真的需要,越小的依赖面越容易维护。
前端依赖管理不可能消除所有供应链风险,但可以把风险从“构建时随机发生”变成“代码审查时显式出现”。这就是 lockfile 和 frozen install 最重要的价值。