Electron 白屏是最容易让人骂娘的一种故障:开发环境跑得好好的,打包给用户就是一块白。更折磨的是它没有报错弹窗、没有日志、什么都看不出来,你只能猜。这篇文章把我自己应用(一个塞了十几个功能模块的本地优先桌面应用)上线后遇到过的白屏逐一定位的过程写出来,照着走,大部分白屏半小时内能定出根因。
白屏的四种形态,先对号入座
排查前先分清你遇到的是哪一种,不同形态的根因分布完全不一样:
| 形态 | 典型场景 | 最可能的根因 |
|---|---|---|
| 秒白,窗口一开就是纯白 | 开发模式也白 | preload 抛错 / 资源路径错 |
| 闪一下才白 | 开发正常,打包后白 | asar 内资源加载失败 / 路由 base 路径 |
| 偶发白,重启又好 | 只在部分用户机器上 | GPU / 驱动 / 内存不足被杀 |
| 白屏但能点、能拖 | 界面框架没画出来 | GPU 合成问题 / CSS 挂 / JS 半路崩 |
记住一个判据:「白屏但能交互」基本不是页面没加载,是合成层出了问题,往 GPU 方向查;「纯白点什么都不灵」是渲染进程压根没起来,往 preload 和路径方向查。
主进程先挂两条日志:console-message 与 render-process-gone
白屏排查最大的困难是故障发生在渲染进程里,而你的日志只写在了主进程。渲染进程一崩,它自己那份日志跟着没了。正确做法是在主进程把渲染进程的 console 和死亡事件都接住:
// main.js
const log = require('./log'); // 你的文件日志模块
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
// 渲染进程的 console 全部转发到主进程日志
win.webContents.on('console-message', (e, level, message, line, sourceId) => {
log.write(`[renderer L${level}] ${message} (${sourceId}:${line})\n`);
});
// 渲染进程死亡:reason 直接告诉你为什么
win.webContents.on('render-process-gone', (e, details) => {
log.write(`[renderer-gone] reason=${details.reason} exitCode=${details.exitCode}\n`);
});
// 页面资源加载失败
win.webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL) => {
if (errorCode !== -3) { // -3 是被用户取消的,忽略
log.write(`[load-fail] ${errorCode} ${errorDescription} ${validatedURL}\n`);
}
});
win.loadFile(path.join(__dirname, 'dist/index.html'));
}
render-process-gone 的 details.reason 是排查白屏最有价值的一个字段,它的取值直接对应根因:
| reason | 含义 | 往哪查 |
|---|---|---|
crashed | 渲染进程真崩了 | 看 console-message 崩溃前最后一条输出 |
oom | 内存超限被杀 | 页面泄漏,查 DOM 节点数、大数组 |
launch-failed | 进程没起来 | preload 抛错,见下一节 |
integrity-failure | 代码签名校验失败 | 打包配置 / asar 完整性 |
killed | 被外部杀掉 | 安全软件 / 系统内存压力 |
这两条日志加上 did-fail-load,覆盖我遇到过的大部分白屏。它们必须在上线版本里就带上,等用户报白屏你本地复现不了的时候,只能靠这份日志回溯。日志写到 app.getPath('userData') 下面,别写到安装目录(只读)。
根因一:preload 抛错,渲染进程整个起不来
这是我遇到次数最多的白屏原因,而且只在打包后出现,开发模式永远正常。典型症状:窗口是白的,render-process-gone 报 launch-failed,或者干脆连窗口都不显示。
机制是:preload 在渲染进程启动最早阶段执行,它 require 的模块如果找不到、或者抛异常,整个渲染进程就起不来,页面根本没有机会渲染。开发模式不暴露是因为开发时 preload 里的相对路径恰好能解析,打包后 asar 内路径变了。三个高发写法:
- preload 里
require('./some-helper'),打包后文件没被包含进 asar,直接找不到模块。 - preload 里同步
require一个原生模块(比如你懒加载的 FFI 引擎),原生模块加载慢或失败,阻塞整个启动。 - preload 顶层代码就抛错——不是懒执行的函数,是加载即执行。这类最隐蔽,
console-message里能看到一条语法错误或 undefined is not a function。
定位方法:把 console-message 日志打开,白屏复现后看渲染进程死前最后一条日志,九成会指着 preload 里某一行。修复原则:preload 只做最小初始化,任何重活、任何可能失败的原生调用都不许在 preload 顶层执行,全部包成 contextBridge 暴露出去的函数,等渲染进程真正调用时再执行。这也是我在 Win32 FFI 那篇里把原生库做成懒加载的原因——不只是为了启动速度,也是为了让 preload 永远不可能成为白屏源。
根因二:contextIsolation 与 nodeIntegration 的版本差
Electron 12 起 contextIsolation 默认打开,20 起 nodeIntegration 默认关闭。如果你按老教程写代码、又在新版本运行,preload 里的 window.xxx = ... 赋值会被 contextBridge 的隔离层挡掉,渲染进程拿到 undefined,第一行业务代码就崩,白屏。
判断很简单:开发者工具的 console 里如果看到 Cannot read properties of undefined 且指向你最早上线的某个 window.api.xxx 调用,就是这个。修法不是把 contextIsolation 关回去(那是把门拆了防贼),而是 preload 改用 contextBridge.exposeInMainWorld:
// preload.js —— contextIsolation 下的正确写法
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('api', {
// 只暴露白名单方法,且都是懒执行
readConfig: (name) => ipcRenderer.invoke('config:read', name),
pickFolder: () => ipcRenderer.invoke('dialog:pickFolder'),
});
顺带一句:nodeIntegration: false 是安全底线也是排错边界——渲染进程摸不到 Node,所有「渲染进程里 require 一个包」的代码在白屏里都是先杀无赦。跨层拿不到能力的问题,基本都出在 preload 没配对。
根因三:资源路径,file:// 下相对路径全灭
打包后用 loadFile 加载,页面是 file:// 协议。这类页面里两类写法会白:
- 裸相对路径:HTML 里写
<script src="./assets/app.js">,在 file:// 下按当前目录解析,而你的文件实际在 asar 另一个深度,直接 404,页面只剩一个空 HTML 壳。 - history 模式路由:SPA 用 history API,用户深链到
file:///.../settings.html,file:// 没有服务端做 fallback,直接找不到文件。
对策:构建工具把资源路径全改成相对路径且不打 leading slash(Vite 设 base: './'),路由用 hash 模式(#/settings)。file:// 协议下这两条是保命组合。判断也很直接:did-fail-load 日志里一片 -6(FILE_NOT_FOUND),就是路径问题,跟 Electron 本身没关系。
根因四:GPU,白屏但界面还能交互
如果窗口是白的,但你能拖动、能点、标题栏正常,甚至盲按快捷键有反应——这是 GPU 合成挂了,页面 DOM 其实活着。某些老旧显卡、虚拟机和远程桌面环境高发。三个档位的处理:
// 1. 先试全局关硬件加速(最省事,代价是动画略卡)
app.disableHardwareAcceleration();
// 2. 或者只关 GPU 合成
app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('disable-gpu-compositing');
// 3. 针对性处理:白屏只发生在特定显卡时,按条件动态加开关
if (process.env.FORCE_SOFTWARE_GL === '1') {
app.commandLine.appendSwitch('use-gl', 'swiftshader');
}
这类问题不要在主进程里猜,让用户帮一个忙就能确认:白屏时按 Ctrl+Shift+I 打开开发者工具,如果 console 有输出、Elements 树完整,100% 是 GPU 合成问题。关掉硬件加速后白屏消失,就实锤了。
根因五:asar 内资源、中文路径与权限
剩下几个零碎但都真实遇到过的:
- asar 内动态 require:运行时按字符串拼出来的
require(path.join(__dirname, 'plugins', name)),打包后 plugins 目录没进去,或者进了 asar 但动态路径解析不到。原生模块和运行时加载的插件建议asarUnpack解出来放 app.asar.unpacked。 - 中文安装路径:用户装在
D:\软件\我的工具\,某段代码对路径编码处理不当,加载即失败。自己先拿中文路径装一遍测。 - 安装目录权限:程序装在
C:\Program Files却要写日志/缓存到自己的安装目录,被 Windows ACL 挡下,表现为半白或启动即崩。可写的东西一律走app.getPath('userData'),这条没有例外。
预防清单:上线前把白屏概率压到最低
白屏排查做得再多,也不如让它少发生。这是我现在的上线前自检清单:
- 主进程常驻
console-message/render-process-gone/did-fail-load三条日志,写 userData。 - preload 顶层零副作用,原生模块全部懒加载,启动路径上不许有可能失败的调用。
- 纯净环境实测:全新 Windows 虚拟机、
C:\Program Files安装、中文路径安装,各跑一遍。 - 构建产物
base: './'+ hash 路由,禁掉 history 模式。 - 保留一个环境变量开关,出问题能一键切软件渲染,不用重新发版。
最后说个心态问题:白屏的复现率往往在用户机器上最高、在你机器上最低,所以日志必须比用户更早知道出事。上面第一条做好了,用户截图发你一个白窗口,你也能从他机器上的日志里告诉他为什么白。