package-lock.json里"看不见的坑":跨平台原生依赖为什么会消失
本文由 小茗同学 发表于 2026-07-27 浏览(4)
最后修改 2026-07-27 标签:npm lock package

一、案例

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 条目会在这一步被丢掉。

具体链条:

  1. Mac 上 npm installnode_modules/@rollup/ 里只装了 darwin-arm64(正常)
  2. 某种操作触发 lock 重新生成(下一节展开)
  3. npm 读现有 node_modules,把 lock 重写成”只含 darwin-arm64”
  4. 这份看起来正常的 lock 被提交
  5. Linux CI npm ci 严格照 lock 装,@rollup/rollup-linux-x64-gnu 根本不在 lock 里,跳过
  6. 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
    # 51
    

    npm 读不了坏 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

七、教训

  1. 别在 node_modules 存在时让 npm 重新生成 lock。要重装,两个一起删。
  2. CI 用 npm ci,不要用 npm install
  3. 看到 Cannot find module @xxx/xxx-linux-* 这类报错,先怀疑 lock 里缺平台包。rollup / esbuild / swc / next-swc / sharp / lightningcss 都是同款失败模式。
  4. 别把带冲突标记的 lock 提交上去。它不是普通的坏文件,会诱导 npm 走”以 node_modules 现状重生成”的路径,把所有平台包洗掉。
  5. 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 显式升级。

参考