升级 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.json 写 packageManager 字段,让 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。但操作系统的加载器读不了——LoadLibrary 和 dlopen 只认真实路径。所以 .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 指针。
判据不必复杂,只要能区分"有"和"没有"。构建的成功标准应该是产物可用,不是命令退出码为零。

参与讨论
(Participate in the discussion)
参与讨论