Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"
本文记录了我在开源项目 AionUi(一个统一 AI Agent 图形界面)中遭遇的一次 Electron 打包白屏事故。从发现问题到最终定位根因,历时三天三夜。如果你也在用 Electron + GitHub Actions 做 CI/CD,这篇文章或许能帮你避开一个隐蔽的大坑。
故事的开始:一个"完美"的 CI 构建
那是一个平静的夜晚。
我像往常一样推送了一个版本到 dev 分支,GitHub Actions 开始了它忠实的自动构建工作。十几分钟后,CI 亮起了绿灯——所有平台构建成功。
"不错,今天又是顺利的一天。"
我下载了 macOS 的 DMG,拖进 Applications,双击启动——
白屏。
纯白的、一尘不染的、令人窒息的白屏。像是这个 App 在用行为艺术表达它对这个世界的失望。
没有报错弹窗,没有崩溃提示,就是一片白。
第一天:本地没问题,那一定是 CI 的问题
作为一个经历过无数"在我机器上是好的"名场面的工程师,我的第一反应是——先在本地复现。
npm start # 本地开发 ✅ 正常
node scripts/build-with-builder.js arm64 --mac --arm64 # 本地打包 ✅ 正常
本地打包出来的 DMG 安装后运行完全正常。
这就尴尬了。经典的"本地好好的,CI 就炸"。
我打开 Electron 的开发者工具日志,终于看到了这个错误:
Not allowed to load local resource:
file:///Applications/AionUi.app/Contents/Resources/app/.webpack/renderer/main_window/index.html
ERR_FILE_NOT_FOUND——Electron 找不到渲染进程的入口文件。这个 index.html 是 webpack 打包生成的,没有它,整个界面就是一片白。
第一个嫌疑人:tar v7
翻看最近的 package.json 变更,我注意到一个依赖升级:
{
"overrides": {
"tar": "^7.5.7" // 从 ^6.2.1 升级
}
}
tar v7 是一个 breaking change 版本,API 完全重写。而 electron-builder 在打包时需要处理 tar/asar 归档。会不会是 tar v7 导致 asar 打包出了问题?
我花了大半天时间研究 tar v7 的兼容性:
- 翻了 npm 的 changelog
- 查了 electron-builder 的源码
- 对比了 tar v6 和 v7 的 API 差异
结论:tar v7 是无辜的。它修复的是 CVE-2026-23745 安全漏洞,electron-builder 并不直接依赖 tar 的 API。
第一天,白忙。
第二天:深入 asar 的内心世界
既然不是 tar 的问题,那就得看看 CI 打包出来的产物到底长什么样。
我从 CI artifacts 下载了 DMG,挂载后检查 asar:
# 本地打包的 asar
npx asar list /Applications/AionUi-Local.app/Contents/Resources/app.asar | grep index.html
# ✅ .webpack/renderer/main_window/index.html 存在
# CI 打包的 asar
npx asar list /Applications/AionUi-CI.app/Contents/Resources/app.asar | grep index.html
# ❌ 没有任何输出
找到了! CI 打包出来的 asar 里压根没有 index.html!
这意味着 webpack 的产物在 CI 环境下根本没有生成。但 CI 构建明明显示成功了啊?
转折点:一条被吞掉的错误
带着疑惑,我仔细翻看了 CI 的构建日志。在数百行日志的角落里,我发现了这个:
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed
- JavaScript heap out of memory
Node.js 内存溢出了!
webpack 在打包过程中吃光了内存,进程直接崩溃。但是——为什么 CI 没有报错?
答案就在 GitHub Actions 的 workflow 配置里:
- name: Build with electron-builder (non-Windows)
uses: nick-fields/retry@v3
with:
timeout_minutes: 80
max_attempts: 2
continue_on_error: true # ← 就是你!
command: ${{ matrix.command }}
continue_on_error: true——这个设置的本意是:macOS 构建有时会因为 Apple 公证服务超时而失败,为了不阻塞其他平台的构建,设置了继续执行。
但它也悄悄地吞掉了 OOM 错误。webpack 崩溃了,HTML 没生成,但 CI 一脸无事地继续往下走,把一个残缺的产物打包成了 DMG 并上传。
这就像一个建筑工人告诉你"房子盖好了",但实际上里面连墙都没有。
为什么只有 macOS 炸了?
这里有一个有趣的细节:Windows 和 Linux 的构建都正常,唯独 macOS 白屏。
原因在于 Node.js V8 引擎的内存管理机制。V8 有一个人为设定的堆内存上限(64 位系统默认约 4GB),这个限制独立于物理内存。
GitHub Actions 各平台 Runner 虽然标称内存相同(7GB),但实际可用内存差异很大:
GitHub Actions Runner (7GB RAM)
├── macOS 系统开销 (~1.5GB)
├── Xcode / 签名工具 (~0.5GB)
├── npm / 其他进程 (~0.5GB)
└── Node.js 可用 (~4.5GB)
└── V8 默认堆上限: ~4GB ← webpack 需要 ~5GB,超了!
macOS 系统本身就比 Linux 更"臃肿",加上 Xcode 工具链、代码签名服务等额外负担,留给 Node.js 的空间更少。当 webpack 打包一个包含 Monaco Editor、Arco Design、多个 AI SDK 的大型 Electron 项目时,内存消耗刚好卡在了 macOS 的临界点上。
Windows 和 Linux 刚好没超,macOS 刚好超了。差的可能就是那几百 MB。
这也解释了为什么这个问题之前没出现——随着项目不断壮大,依赖越来越多,webpack 的内存消耗在某个版本终于突破了 macOS 上的 4GB 天花板。
第三天:修复与反思
修复方案
修复本身只需要一行:
env:
NODE_OPTIONS: "--max-old-space-size=8192"
这不是"给 Node.js 更多物理内存"——Runner 的物理内存还是 7GB,不会凭空变多。
这是解除 V8 引擎的人为限制。告诉 V8:"如果需要,你可以用到 8GB"。实际上 webpack 只会用到它需要的量(约 5GB)。因为 5GB < 8GB(新上限),所以不再触发 OOM。
| 情况 | V8 堆上限 | 实际需要 | 系统可用 | 结果 |
|---|---|---|---|---|
| 修复前 | ~4GB (默认) | ~5GB | ~4.5GB | OOM |
| 修复后 | 8GB (手动) | ~5GB | ~4.5GB | 成功 |
防御性措施
光修 OOM 不够。真正的问题是 continue_on_error: true 让构建失败变成了"沉默的杀手"。
我重写了 macOS 构建步骤,将公证失败和构建失败区分开来:
- name: Build with electron-builder (macOS)
run: |
set +e
${{ matrix.command }} 2>&1 | tee build.log
BUILD_EXIT_CODE=${PIPESTATUS[0]}
# 检查 DMG 是否生成
if ls out/*.dmg 1>/dev/null 2>&1; then
DMG_EXISTS=true
fi
if [ $BUILD_EXIT_CODE -eq 0 ]; then
exit 0 # 完全成功 ✅
fi
# DMG 存在但构建失败 → 可能只是公证问题
if [ "$DMG_EXISTS" = true ]; then
if grep -qiE "notariz|staple" build.log; then
echo "⚠️ DMG 构建成功,但公证失败"
exit 0 # 允许继续
fi
fi
# DMG 都没生成 → 真正的构建失败
exit 1 # ❌ 阻断 CI
逻辑很简单:
- 构建成功 + 公证成功 → 全部通过 ✅
- DMG 生成了但公证失败 → 警告但不阻塞 ⚠️(用户右键打开即可)
- DMG 都没生成 → 直接失败 ❌(这才是真问题)
不再一刀切地 continue_on_error,而是精准区分错误类型。
番外篇:AionUi 的"混血"打包架构
讲完了 bug 本身,我想聊聊这个 bug 之所以能藏这么深的根本原因——AionUi 的打包流程本身就不走寻常路。
传统开源项目怎么打包?
大多数 Electron 开源项目的打包流程是这样的:
方案 A:纯 Electron Forge
源代码 → electron-forge make → DMG/EXE/DEB
(开发、编译、打包一条龙)
方案 B:纯 electron-builder
源代码 → webpack/vite 编译 → electron-builder → DMG/EXE/DEB
(自己编译,builder 负责打包)
简单直接。一个工具从头管到尾,出了问题也好排查。
AionUi 为什么要"混血"?
AionUi 的打包流程长这样:
源代码
↓
Step 1: Electron Forge (webpack 编译)
├── WebpackPlugin 编译 main process
├── WebpackPlugin 编译 renderer process → index.html ← 白屏就是这里没生成
└── 输出到 .webpack/ 目录
↓
Step 2: 产物校验
└── 检查 .webpack/renderer/main_window/index.html 是否存在
↓
Step 3: electron-builder (分发打包)
├── 读取 electron-builder.yml 配置
├── 将 .webpack/ 打入 asar 归档
├── afterPack: 重建原生模块 (better-sqlite3, node-pty...)
└── afterSign: 代码签名 + Apple 公证
↓
DMG / ZIP / EXE / DEB
为什么不直接用一个工具?因为 AionUi 的需求太"拧巴"了:
需要 Electron Forge 的原因:
- 它的
WebpackPlugin对 Electron 多进程架构(main + renderer + preload)有开箱即用的支持 - 开发时的 HMR 热更新、DevServer、日志端口管理都做得很好
FusesPlugin可以在打包时控制 Electron 安全特性(禁用 RunAsNode、启用 Cookie 加密等)
需要 electron-builder 的原因:
- macOS 代码签名 + Apple 公证(Forge 的 maker 支持有限)
- 跨架构编译(在 arm64 机器上构建 x64 包,反之亦然)
- 精细的 asar 控制(哪些模块打包、哪些解压、哪些排除)
- 多格式输出(DMG + ZIP 同时生成)
- 更成熟的 CI/CD 集成
单独用 Forge 做不了完善的公证;单独用 electron-builder 又没有 Forge 的 webpack 集成好用。所以 AionUi 用了一个混血方案——Forge 负责编译,electron-builder 负责打包。
混血的代价:两个工具之间的"信任边界"
这个方案的核心脚本是 build-with-builder.js,它充当了两个工具之间的"桥梁":
// Step 1: 让 Forge 编译 webpack
execSync(`npm exec electron-forge -- package --arch=${targetArch}`);
// Step 2: 把 .webpack/ 目录结构整理成 electron-builder 期望的样子
ensureDir(sourceDir, webpackDir, 'main');
ensureDir(sourceDir, webpackDir, 'renderer');
// Step 3: 校验关键产物
if (!fs.existsSync(rendererIndex)) {
throw new Error('Missing renderer entry');
}
// Step 4: 让 electron-builder 打包
execSync(`npx electron-builder ${builderArgs} --${targetArch}`);
问题就出在这里:Forge 和 electron-builder 之间没有原生的握手机制。Forge 编译完就完了,至于 .webpack/ 目录里到底有没有该有的文件,它不管。electron-builder 拿到 .webpack/ 就打包,至于里面是不是空的,它也不管。
当 webpack 因为 OOM 中途崩溃时,.webpack/ 目录可能是"半成品"——main 进程的代码可能已经编译好了(因为它先编译),但 renderer 的 index.html 还没来得及生成。electron-builder 照样把这个半成品打进了 asar,产出了一个"看起来正常但其实没有界面"的 DMG。
这就是混血架构的代价:两个工具之间的信任边界,恰好是 bug 的藏身之处。
与传统方案的对比
| 维度 | 传统方案 (单工具) | AionUi (混血方案) |
|---|---|---|
| 编译 | 工具内置 | Electron Forge (WebpackPlugin) |
| 打包 | 同一工具 | electron-builder |
| 签名/公证 | 工具内置或手动 | electron-builder + afterSign.js |
| 原生模块 | 工具自动处理 | afterPack.js 手动重建 |
| 错误传播 | 直通,容易发现 | 跨工具,可能被吞掉 |
| 灵活性 | 受限于单工具 | 高(可单独定制每个环节) |
| 复杂度 | 低 | 高 |
原生模块:另一个深坑
AionUi 不是一个纯 JS 应用。它依赖了多个原生 C++ 模块:
- better-sqlite3 — 本地数据库,存储对话历史和设置
- node-pty — 终端模拟,用于运行 CLI AI 工具
- tree-sitter — 代码解析,用于语法高亮
这些模块必须针对目标平台和架构编译成 .node 二进制文件。在 afterPack.js 中,AionUi 实现了一套完整的跨架构重建逻辑:
// 交叉编译时,先清理错误架构的二进制
if (isCrossCompile) {
// 删除 build/ 目录(包含错误架构的编译产物)
fs.rmSync(buildDir, { recursive: true, force: true });
// 删除对立架构的可选依赖包
// 比如目标是 arm64,就删除所有 *-x64 的包
}
// 然后为目标架构重新编译
rebuildSingleModule({
moduleName, moduleRoot,
platform: electronPlatformName,
arch: targetArch,
electronVersion
});
这意味着一次 macOS arm64 构建实际上要经历:webpack 编译 → Forge 打包 → electron-builder 打包 → 原生模块重建 → 代码签名 → Apple 公证,六个步骤。任何一步失败都可能导致最终产物有问题。
为什么不简化?
说实话,我也想简化。但现实是:
- Forge 的 maker 不支持 Apple notarytool — 这是硬伤,没法绕过
- electron-builder 的 webpack 集成不如 Forge — 特别是多入口(main + renderer + preload + worker)场景
- 原生模块的跨架构编译 — 需要精细控制,两个工具各自的方案都不够灵活
- 安全特性 — Electron Fuses 只有 Forge 的 FusesPlugin 支持得好
所以这个"混血"方案虽然复杂,但在当前的 Electron 生态下,它是 AionUi 这种重量级桌面应用的实际最优解。
代价就是——当 bug 出现在两个工具的"交界处"时,排查难度会指数级上升。就像这次的白屏事故。
经验总结
1. continue_on_error 是一把双刃剑
它能防止非关键失败阻塞流水线,但也能让关键错误悄无声息地溜走。如果一定要用,请确保有额外的产物校验逻辑,而不是无条件信任构建命令的退出码。
2. CI 绿灯 ≠ 构建成功
特别是在 Electron 这种多步骤构建(webpack → electron-forge → electron-builder → 签名 → 公证)的场景下,任何一个环节的静默失败都可能产出一个"看起来没问题但其实不能用"的安装包。
建议:在 build 脚本最后加一个产物校验:
const rendererIndex = path.join(webpackDir, 'renderer', 'main_window', 'index.html');
if (!fs.existsSync(rendererIndex)) {
throw new Error('Missing renderer entry: .webpack/renderer/main_window/index.html');
}
3. OOM 是一个平台相关的"薛定谔 Bug"
同样的代码,同样的 webpack 配置,在 Windows 不 OOM、在 macOS 就 OOM。它可能今天不出现,明天加了一个依赖就出现了。对于大型 Electron 项目,主动设置 --max-old-space-size 是一个好习惯,不要等到 OOM 了才想起来。
4. 永远验证最终产物
不要相信过程,要验证结果。在 CI 流水线里加一步检查最终产物是否存在且完整,能省去无数个排查白屏的深夜。
写在最后
三天三夜,从怀疑 tar v7、到拆解 asar、到翻遍数百行 CI 日志,最终发现是一个 continue_on_error: true 配合 Node.js OOM 造成的"完美犯罪"。
修复只用了一行配置。但找到这一行的过程,让我深刻理解了一个道理:
最难调试的 bug,不是会报错的 bug,而是假装没有 bug 的 bug。
如果你也在做 Electron 开源项目,或者正在被 CI 打包问题折磨,欢迎关注 AionUi —— 一个将命令行 AI Agent 变成现代聊天界面的桌面应用,支持 Gemini CLI、Claude Code、Codex、通义灵码等多种 AI 工具。
- 项目地址:github.com/iOfficeAI/A…
- 技术栈:Electron 37 + React 19 + TypeScript + UnoCSS + Monaco Editor