一、案例
Docker 构建挂在 vite build:
Error: Cannot find module @rollup/rollup-linux-x64-gnu.
npm has a bug related to optional dependencies
(https://github.com/npm/cli/issues/4828).
拧巴的地方:本地 Mac 没问题、之前服务器也一直没问题、全新 Docker 容器、npm install 阶段 added 1627 packages 全绿——直到 build 才炸。
二、背景:平台原生绑定 + optionalDependencies
现代前端工具链用 C++/Rust 加速,”主包 + 一堆平台原生包”是普遍套路:rollup、esbuild、swc、@next/swc、sharp、lightningcss、turbo、biome、prisma…
它们的共同做法都是:主包在 optionalDependencies 里列出 20+ 个平台包,每个平台包用 os / cpu 字段限定自己。npm 装的时候按当前平台只挑一个装上,其它合理跳过——这是设计得没毛病的。
问题不在设计本身,在 package-lock.json 怎么被生成。
三、真正的坑(npm/cli#4828)
当 node_modules 已存在时,如果 npm 要重新生成或更新 package-lock.json,它会以”当前 node_modules 实际装了什么”为准写 lock。其它平台的 optional 条目会在这一步被丢掉。
具体链条:
- Mac 上
npm install,node_modules/@rollup/里只装了darwin-arm64(正常) - 某种操作触发 lock 重新生成(下一节展开)
- npm 读现有
node_modules,把 lock 重写成”只含 darwin-arm64” - 这份看起来正常的 lock 被提交
- Linux CI
npm ci严格照 lock 装,@rollup/rollup-linux-x64-gnu根本不在 lock 里,跳过 vite build时 rollup 找不到原生绑定,报错
关键点:install 阶段全程 exit 0,added N packages 不会提示缺了原生包。
前提:这个 bug 只在 node_modules 已存在的前提下触发。真正的”没 lock + 没 node_modules + 全新容器”从零装,是能把所有平台的 optional 条目都写进新 lock 的。事故是通过被污染的 lock 传染到 CI 的,不是 CI 环境自己长出来的。
四、什么操作会触发 lock 重新生成
node_modules 存在 + 以下任一 = lock 可能被”清洗成单平台”:
npm install <新包>/npm update/npm uninstall—— 最常见。你只是想加个依赖,npm 顺手重算了整份 lock。lock 文件损坏 —— 最典型是提交了带 Git 合并冲突标记的 lock:
grep -c '<<<<<<< ' package-lock.json # 51npm 读不了坏 lock,静默降级到”重新生成”,此时如果
node_modules还在,就命中同一个坑。这也解释了本次案例的”突然某天挂”——某次 merge 后有人本地 install 了一下,把 lock 洗成 Mac-only 就提交了。npm install --no-package-lock—— 只保证当前命令不读写 lock,不会删磁盘上已有的坏 lock,也不删node_modules。下次任何一次正常 install 依然会踩。- npm 大版本升级 ——
lockfileVersion迁移会触发一次重算。
五、几个常见误区
误区 1:--no-package-lock / --force 能绕开
不行。它们只影响当前这条命令,不删磁盘上已经坏了的 lock、不删已经存在的 node_modules、更管不到 workspaces 子目录下的 nested lockfile(比如 dr-new/package-lock.json)。下一次 install 或 CI 依然可能从这些残留状态里读到错的东西。
误区 2:错误信息说”删 lock 和 node_modules 重装就好”
对,但两个都要删干净,monorepo 里 workspace 子目录的也要一起删。只删一半等于没修——留下的那一半会污染下次操作。
误区 3:CI 用 npm install 更”稳”
反了。npm install 会主动帮你”修正” lock,静默行为多、不受欢迎。CI 应该用 npm ci:lock 里有什么装什么,不改 lock,lock 非法立即报错。可预测才是 CI 想要的。
六、修复
一次性修好
# 所有 lock 和 node_modules 都删干净(monorepo 别漏了子目录)
rm -rf node_modules */node_modules
rm -f package-lock.json */package-lock.json
# 从零装
npm install
# 验证 lock 里包含目标平台
grep -c 'rollup-linux-x64-gnu' package-lock.json # ≥ 1
grep -c 'rollup-win32-x64-msvc' package-lock.json # ≥ 1
git add -A && git commit -m "fix: regenerate lock with all platform binaries"
CI 换 npm ci
RUN npm ci
兜底:显式补装
如果短期没法动 lock:
RUN npm ci || npm install
RUN npm install @rollup/rollup-linux-x64-gnu --no-save --force
指名安装不走 optional 逻辑,一定装上。缺点是每个用原生绑定的工具都得单独补。
防复发
.gitattributes 里给 lock 加合并策略,避免手改冲突把它弄坏:
package-lock.json merge=ours
(合并完再本地重新 npm install 校准,然后提交。)
pre-commit hook 拦一层:
if grep -qE '^(<<<<<<< |=======|>>>>>>> )' package-lock.json 2>/dev/null; then
echo "package-lock.json 里有未解决的冲突标记,拒绝提交"
exit 1
fi
七、教训
- 别在
node_modules存在时让 npm 重新生成 lock。要重装,两个一起删。 - CI 用
npm ci,不要用npm install。 - 看到
Cannot find module @xxx/xxx-linux-*这类报错,先怀疑 lock 里缺平台包。rollup / esbuild / swc / next-swc / sharp / lightningcss 都是同款失败模式。 - 别把带冲突标记的 lock 提交上去。它不是普通的坏文件,会诱导 npm 走”以
node_modules现状重生成”的路径,把所有平台包洗掉。 - monorepo 的 workspace 子目录 lockfile 也要一起管。删的时候别漏。
八、修复版本
Issue #4828 从 2022 年提出后经历过一次不彻底的修复(PR #5282,随 npm 8.x 发布),但在有 node_modules 时重新生成 lock 的场景下依然复现,因此长期没关闭。真正解决它的是 PR #8184 “arborist: omit failed optional dependencies from installed deps”,随 @npmcli/arborist@9.0.2 一起被 bundle 进 npm 11.3.0(2025-04-08) 发布。
也就是说:
- npm < 11.3.0:踩雷。老 CI/构建镜像(Node 20 / npm 10.x、Node 22 早期 / npm 10.9.x)都在射程内,需要靠上面的方案自保。
- npm ≥ 11.3.0:机制上已修好。但已经污染的 lock 不会被 npm 自动救回——旧 lock 里缺 Linux 平台包就是缺,
npm ci照样装不出来。所以要么升 npm 后重新生成一次 lock 提交,要么继续跟着上面的修复步骤走。
要用上修复,Node 24 及以上默认自带 npm 11;如果卡在 Node 22,可以 npm i -g npm@latest 显式升级。
