npm install 会改你的 package-lock.json:CI 必须用 npm ci 的 3 个理由
问一个问题:npm install 和 npm ci 有什么区别?
大多数人的答案是「ci 快一点,CI 里用」。这个答案不算错,但漏掉了最关键的一条:npm install 有权改写 package-lock.json,npm ci 永远不会。 一个是「按 lock 装,装不上就改 lock」,一个是「按 lock 装,装不上就报错」。
这一字之差,在你本机上几乎感觉不到。在 CI 里,它决定了你的构建到底是不是可复现的。
先说清楚 npm ci 做了什么
npm ci 里的 ci 不是 continuous integration,官方叫它 clean install。它的行为是固定的四条:
- 必须有
package-lock.json(或npm-shrinkwrap.json),没有直接报错。 package.json和 lock 不一致就报错退出,不会替你修。- 先删掉整个
node_modules,再从 lock 里逐条安装。 - 绝不写
package-lock.json。
对比 npm install:没有 lock 就生成一份;lock 和 package.json 对不上就重新解析、把结果写回 lock;node_modules 里已有的包能复用就复用。
所以 npm install 是「让项目能跑起来」,npm ci 是「验证 lock 文件描述的就是能跑起来的项目」。前者适合你写代码,后者适合机器验收。
下面三个场景,每一个我都见过团队因此浪费半天。
理由一:lock 文件会被镜像源来回翻转
打开你的 package-lock.json,每个包下面有一行 resolved:
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
这个 URL 是从当时安装用的 registry 来的。国内团队常见的情况是:A 同学 .npmrc 配了 npmmirror,B 同学用官方源,C 同学公司内网有私有仓库。三个人各自 npm install 一次,lock 文件里几百行 resolved 就换一次域名,diff 里全是无意义的 URL 变更,真正的版本变化淹没在里面。
npm ci 在这一步不会写 lock,所以 CI 不会制造这种 diff。但本地开发还是会。彻底的解法是把 registry 写进项目根目录的 .npmrc 并提交:
# .npmrc(提交到仓库)
registry=https://registry.npmmirror.com
所有人、包括 CI,用同一个源,resolved 就不再翻转。较新版本的 npm 还有一个 replace-registry-host 配置项,可以在安装时把 lock 里的 registry 域名替换成当前配置的源,适合已经翻乱了的老仓库。
理由二:macOS 生成的 lock,在 Linux 上装不上
这一条最反直觉,也最常见。
esbuild、swc、rollup、sharp 这类带原生二进制的包,把不同平台的二进制拆成了各自的可选依赖,比如 @rollup/rollup-darwin-arm64、@rollup/rollup-linux-x64-gnu。你在 Apple Silicon 的 Mac 上 npm install,npm 只会把 darwin-arm64 那个装进 node_modules。
问题出在旧版本 npm 的一个已知缺陷上:某些情况下它把其他平台的可选依赖从 lock 文件里漏掉了。lock 提交上去,CI 在 Linux 上跑 npm ci,按 lock 逐条安装,没有 linux-x64 那一条,构建时直接报:
Error: Cannot find module @rollup/rollup-linux-x64-gnu
而 npm install 在 CI 上反而不会报错,因为它发现缺了就重新解析、补上,顺便又改了一遍 lock。这就是很多人「本地和 CI 都用 install 一直没事,换成 ci 就炸」的原因。不是 npm ci 有问题,是它诚实地告诉你 lock 文件不完整。
修法两步:
# 1. 删掉不完整的 lock 和 node_modules,重新生成
rm -rf node_modules package-lock.json
npm install
# 2. 确认 lock 里包含了其他平台的条目
grep -c "rollup-linux-x64-gnu" package-lock.json
npm 9 以后的版本可以在安装时显式声明目标平台,让 lock 一开始就带上:
npm install --os=linux --cpu=x64
如果你的团队全是 Mac、CI 全是 Linux,把 lock 的重新生成放在 Linux 容器里做一次,之后就不会再遇到。
理由三:手改 package.json 之后,lock 静默失步
有人图省事,直接在 package.json 里把 "axios": "^1.6.0" 改成 "^1.7.0",然后提交。本地跑了 npm install,npm 发现不一致,重新解析,lock 更新了,一切正常。但如果这个人没跑 install 就提交了,或者跑了但没把 lock 一起提交,仓库里就出现了 package.json 和 lock 不一致的状态。
这种状态下:
- CI 用
npm install:静默按package.json重新解析,装上 1.7.x,构建通过,但装的版本和任何人本地都不一样。 - CI 用
npm ci:立刻报错。
npm ERR! `npm ci` can only install packages when your package.json and
package-lock.json are in sync. Please update your lock file with `npm install`
before continuing.
第二种才是你想要的。lock 文件的全部意义是「所有环境装同一套东西」,一旦允许 CI 自行解析,这个承诺就没了。
一张表:三个包管理器的对应命令
| 目的 | npm | yarn 1.x | yarn 2+ | pnpm |
|---|---|---|---|---|
| 开发时安装 | npm install |
yarn |
yarn |
pnpm install |
| CI 严格安装 | npm ci |
yarn --frozen-lockfile |
yarn install --immutable |
pnpm install --frozen-lockfile |
| lock 不一致时 | 报错 | 报错 | 报错 | 报错 |
| 生产环境不装 devDependencies | npm ci --omit=dev |
yarn --production |
需配置 | pnpm install --prod |
pnpm 有一个细节值得知道:它检测到 CI 环境变量时,pnpm install 默认就是 --frozen-lockfile 行为。也就是说在 GitHub Actions 里你不加参数也是严格模式。npm 没有这个默认,必须显式写 npm ci。
今天就能改的三处
GitHub Actions 里:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # 缓存 ~/.npm,npm ci 每次删 node_modules 也不慢
- run: npm ci
Dockerfile 里:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
先拷 lock 再 npm ci,这一层只在 lock 变化时重建,缓存命中率比 COPY . . 之后再 install 高得多。
本地 git hook 里(可选,防理由三):
# .husky/pre-commit
npm ls --depth=0 > /dev/null 2>&1 || {
echo "package.json 与 package-lock.json 不一致,先跑 npm install"
exit 1
}
一句话总结
npm install 是给人用的,它会替你把事情做对;npm ci 是给机器用的,它只负责告诉你事情对不对。CI 的职责是验证,不是修复。 把 CI 里的 install 换成 ci,你会多收到几次报错,每一次都是它替你拦下了一个「本地能跑、线上不能」的问题。
顺便问一句:你们的 CI 现在用的是哪个?评论区站个队。