升级 pnpm 到 11 之后,Electron 的构建流程没有任何报错,安装包也照常产出。装上去启动,主进程立刻挂在 Error: Cannot find module

打开 app.asar 看一眼,node_modules 几乎是空的。

这类问题最难受的地方不是它坏了,而是它坏得很安静。CI 全绿,构建日志没有一行警告,产物大小只比正常情况小一点,只有真正运行才暴露。

症状先定位到"收集"而不是"打包"

先确认边界。pnpm install 本身是好的,本地 pnpm dev 跑得起来,说明依赖装对了、node_modules 结构正常。问题出在打包阶段的依赖收集

electron-builder 打包生产依赖时,不会自己遍历 node_modules 目录,而是问包管理器要一份依赖树。对 pnpm 就是执行 pnpm list --json --depth Infinity,拿它的输出去决定哪些包要进 asar。

所以真正要看的是这条命令在两个版本下的输出差异。

pnpm 11 改了 list 的输出结构

差异在顶层数组的语义。

pnpm 10 返回一个根条目,workspace 内的各个包作为这个根的 dependencies 嵌在里面。pnpm 11 改成每个 workspace 包一个独立数组条目,扁平并列,不再有那个统一的根。

// pnpm 10:单根,workspace 包嵌在 dependencies 里
[
  {
    "name": "monorepo-root",
    "dependencies": {
      "@scope/app":  { "version": "link:apps/app",  "dependencies": { /* ... */ } },
      "@scope/core": { "version": "link:packages/core", "dependencies": { /* ... */ } }
    }
  }
]

// pnpm 11:多条目并列,每个 workspace 包一条
[
  { "name": "monorepo-root", "dependencies": { /* 只有根自己的依赖 */ } },
  { "name": "@scope/app",    "dependencies": { /* ... */ } },
  { "name": "@scope/core",   "dependencies": { /* ... */ } }
]

electron-builder 的收集器按旧结构写的:取数组第一项当作整棵树的根,然后递归它的 dependencies

在 pnpm 11 下,第一项是 monorepo 根包。根包自己通常只有 devDependencies 和几个工具,真正的应用依赖都挂在其它条目上。递归下去自然收集到零个生产依赖。

为什么它不报错

这一点值得单独说,因为它决定了这个 bug 为什么能一路走到用户手里。

收集器返回空列表,在代码里是合法状态。空数组遍历零次,后续每一步都正常执行:没有依赖要复制,就不复制;没有文件要写入,就不写入。整条链路一路 success

"依赖为空"和"没有依赖"在类型上无法区分,除非显式断言。格式变更没有校验,失败就退化成静默

上游的修法是探测 pnpm 主版本号,v11 走多条目、v10 走单树,用一个统一的 getter 屏蔽两种布局的差异(PR #9720,对应 issue #9711)。

落地侧的四条路

修复合并进上游之前,或者你暂时不能升级 electron-builder,有四条路可选。按侵入性从低到高排。

升级 electron-builder。最直接,确认版本包含对应修复即可。

锁定 pnpm 10。在根 package.jsonpackageManager 字段,让 CI 和本地都用同一个版本。

{ "packageManager": "pnpm@10.15.0" }

回避而非解决,但在需要立刻恢复出包时是最省事的选择。

改用 hoisted 布局。让 pnpm 摊平成 npm 式的 node_modules,绕开树解析。

# .npmrc
node-linker=hoisted

代价是失去 pnpm 的严格依赖隔离,幽灵依赖会重新出现。

用 pnpm deploy。先把目标应用及其生产依赖导出到一个干净目录,再对这个目录打包。

pnpm --filter <app> deploy --prod ./out/deploy

这条路结构上最干净——deploy 出来的就是一份自包含、无软链的 node_modules,打包器不需要理解 workspace 拓扑。代价是构建流程多一步,且要调整 electron-builder 的入口目录。

原生产物和 WASM 的打包策略

依赖收集出问题时,最先炸的一定是原生模块。纯 JS 依赖有可能已经被打包器内联进产物,缺了也看不出来;.node 不行,它必须以真实文件的形态存在于磁盘上。

所以这里顺带把原生产物在 Electron 里的放置策略讲清楚,它和依赖收集是同一类"文件到不了该去的位置"的问题。

asar 是一个归档,不是目录。 Node 的 require 被 Electron 打了补丁,可以直接读 asar 里的 JS。但操作系统的加载器读不了——LoadLibrarydlopen 只认真实路径。所以 .node.dll.so.dylib 放在 asar 里都加载不了。

三个投放位置对应三种消费方式:

  • asarUnpack — 文件仍在 asar 清单里,但实体解到同级的 app.asar.unpacked/require 会自动改读那里。应用自己 require 的原生模块用这个

  • extraResources — 放到 resources/ 下,不进 asar,需要自己拼路径访问。给外部进程或系统加载器消费的产物用这个,比如 Shell 扩展 DLL、要 spawn 的可执行文件。

  • extraFiles — 放到安装根目录,和主可执行文件同级。需要落在 exe 默认 DLL 搜索路径里的依赖用这个

原生模块的配置通常是这样:

{
  // .node 必须解包,asar 内无法被 dlopen/LoadLibrary 加载
  asarUnpack: [
    '**/node_modules/**/*.node',
    '**/node_modules/<native-pkg>/**',
  ],
  // 给系统或外部进程消费的产物,不进 asar
  extraResources: [
    { from: 'build/native/${platform}-${arch}', to: 'native' },
  ],
}

WASM 的情况不同,它没有加载器约束,但有读取方式约束。

WebAssembly.instantiateStreaming 走的是 fetch,在 file:// 协议下拿不到正确的 MIME 类型,asar 里更是无从谈起。可靠的做法是用 fs.readFileSync.wasm 读成 Buffer 再 WebAssembly.instantiate,或者让打包器把 wasm 内联成 base64。前者要记得把 .wasm 一起 asarUnpack,后者体积会涨三分之一但省掉路径问题。

原生与 WASM 双后端是一个值得做的结构。 主进程走原生模块,性能上限高;渲染进程或降级场景走 WASM,不依赖编译环境。两者实现同一套接口,运行时按可用性选择。

这个结构在打包上要注意一件事:预编译产物按平台架构分目录,打包时只带当前目标平台那一份

prebuilds/
├── win32-x64/
├── linux-x64/
└── darwin-arm64/

三个平台的产物全塞进安装包,体积会白涨两倍。用 ${platform}-${arch} 变量或在 beforePack 钩子里筛选即可。

顺带一个同类的静默失效

pnpm 11 还改了另一个配置项的名字:onlyBuiltDependencies 换成了 allowBuilds,位置也从 package.json 挪到了 pnpm-workspace.yaml

# pnpm-workspace.yaml
allowBuilds:
  <native-pkg>: true

老写法不会报错,只是静默失效。结果是原生模块的编译脚本不再执行,.node 文件根本没生成,等到打包时才发现产物缺失。

和主线是同一个模式:配置格式变了,旧写法被无声忽略,故障延后到很远的地方才显现。升级包管理器主版本时,这类"改名 + 静默"的变更比破坏性报错更值得警惕,因为报错至少会当场停下来。

让空包在 CI 里失败

最后回到方法论。

这个 bug 能走到用户手里,根本原因不是 pnpm 改了输出格式——上游改格式是正常演进,下游适配是迟早的事。根本原因是构建流程没有校验产物

打完包加一步断言即可:

// 生产依赖数量和体积都必须过下限,否则视为收集失败
const entries = fs.readdirSync(path.join(unpackedDir, 'node_modules'))
if (entries.length < EXPECTED_MIN) {
  throw new Error(`依赖收集异常:只有 ${entries.length} 个模块`)
}

原生产物同理,校验文件存在、非空、可执行格式魔数正确(PE 的 MZ、ELF 的 \x7fELF、Mach-O 的 \xcf\xfa\xed\xfe),能拦住空文件和没被 checkout 下来的 LFS 指针。

判据不必复杂,只要能区分"有"和"没有"。构建的成功标准应该是产物可用,不是命令退出码为零