Electron 应用天生容易开多个窗口:主窗口、设置窗口、预览窗口、弹窗……每多一个窗口,背后都是一个完整的渲染进程,拥有独立的 JavaScript 堆、DOM 树和 GPU 资源。如果不加管理,十几个闲置窗口就能悄悄吃掉上 GB 的内存,让用户抱怨“这应用怎么比浏览器还卡”。
这一节不会重复“内存很宝贵”之类的老生常谈,而是给出几条在真实项目中经过验证的、可直接落地的管理策略。
20.4.1 认识每个窗口的真实成本
在 Chromium 中,一个空白的渲染进程(空白页,无脚本)大约占用 30–50 MB 内存。一旦加载了复杂的前端框架、图片、数据缓存,单个窗口轻松突破 150–300 MB。如果你的应用同时开着主窗口、三个文档标签页、两个弹窗和系统托盘,实际物理内存占用突破 1 GB 是很常见的事。
更要紧的是,即使你把一个窗口隐藏了(win.hide() 或 win.minimize()),它的渲染进程仍然在后台活着:DOM 没有释放,定时器还在跑,事件监听器依然挂着。对用户来说,他只是点了一下“关闭”,但你的进程表里还挂着这个窗口的完整内存镜像。这种“假关闭”如果大量累积,最终必然触发操作系统的内存压缩或强行回收,导致整个应用卡顿甚至闪退。
20.4.2 窗口复用而非重复创建
最容易见效的策略,是从源头上减少窗口数量。
很多应用喜欢“打开新窗口”去展示一些临时内容,比如“设置”、“关于”、“帮助”、“搜索”。你可以先在主进程里检查是否已经存在一个同类型的窗口,如果有,就直接把它聚焦并切换到最前,而不是再新建一个。
// 主进程:窗口管理器简化版
const windows = {};
function openOrFocusWindow(type, options) {
if (windows[type] && !windows[type].isDestroyed()) {
windows[type].focus();
return windows[type];
}
const win = new BrowserWindow(options);
windows[type] = win;
// 窗口关闭时从管理器中移除,但先隐藏而非销毁(见下一节)
win.on('close', (e) => {
delete windows[type];
// 若需要保留复用,可以在这里拦截 close 并隐藏
});
win.loadURL(/* 加载对应页 */);
return win;
}
这种做法对设置窗口、帮助窗口这类低频但可能反复打开的场景尤其有效。用户不会察觉到差异,但应用的内存使用会稳定许多。
20.4.3 关闭≠销毁:延迟重开的代价
在 Electron 中,BrowserWindow.close() 默认会立即销毁窗口并释放其渲染进程,下次再打开时需要重新创建、重新加载资源,启动会有一个短暂的空白加载时间。有些开发者为了避免这种等待,选择在用户点击关闭按钮时用 win.hide() 代替,下次再 show() 回来,速度确实快了,但窗口长期驻留在内存中。
理想的折中方案是设置一个短暂的“保活期”:
- 用户关闭窗口时,先不销毁,而是将其隐藏并启动一个定时器(例如 3–5 分钟)。
- 如果在定时器到期前用户重新打开这一窗口,则直接
show()恢复,省去加载时间。 - 如果定时器到期,说明用户短时间内不再需要,就真正调用
win.destroy()释放内存。
// 主进程:惰性销毁
function setupLazyDestroy(win, type, delay = 3 * 60 * 1000) {
let timer = null;
win.on('close', (e) => {
// 如果正在被程序化销毁(例如 quit),则不拦截
if (win.isDestroyed()) return;
e.preventDefault();
win.hide();
timer = setTimeout(() => {
win.destroy();
}, delay);
});
// 当窗口再次显示时取消定时
win.on('show', () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
});
}
用这种方法,像“搜索”、“历史记录”这类面板窗口,用户在频繁切换时不会感到延迟,但也不会长期霸占内存。
20.4.4 渲染进程的主动瘦身
即便窗口不得不开着,我们也可以让它在后台时尽量“节食”。渲染进程里的每一张图片、每一段数据缓存,都是内存的组成部分。
在页面进入后台时,可以做一些主动清理:
- 清除不再需要的图片缓存(如果前端用虚拟列表,可以让不可见区域的图片重新加载占位符)。
- 暂停
requestAnimationFrame循环、关闭 WebSocket 连接或停止轮询。 - 对大量列表数据,将滚动位置保存后,清空数据数组,等窗口重新显示时再重新拉取。
你可以利用 document.visibilityState 的变化来实现这种“休眠”模式。当窗口被隐藏或最小化时,visibilityState 会变为 hidden,此时是释放临时内存的最佳时机。
// 渲染进程
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 停止不必要的网络请求
pausePolling();
// 清空大型数据缓存
releaseLargeCaches();
} else {
resumePolling();
reloadCachesIfNeeded();
}
});
另一个更硬核但效果明显的手段是:对于已经隐藏超过一定时间的窗口,可以直接通过主进程销毁其渲染进程,并在需要重新显示时用一个十分轻量的骨架屏瞬间恢复。这种做法要求你的框架有很好的持久化和状态恢复机制(例如将窗口状态序列化到主进程),适合对内存极端敏感的工具类应用。
20.4.5 全局监控与兜底机制
再好的策略也可能被某些异常情况(如第三方库内存泄漏、忘记销毁的定时器)打破。为多窗口应用加上一道“内存水位”监控线,是防止失控的最后手段。
在主进程里定期通过 process.getProcessMemoryInfo() 获取内存使用情况(residentSet 是实际物理内存占用量)。当发现某个渲染进程的 residentSet 持续超过预设的阈值,比如 500 MB,就可以考虑强制回收这个窗口的资源:通知它清空缓存;若无改善,则关闭并重新加载该窗口。
// 主进程:内存监控示例
setInterval(async () => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
const memInfo = await win.webContents.getProcessMemoryInfo();
if (memInfo.residentSet > 500 * 1024 * 1024) { // 500MB
console.warn(`Window "${win.getTitle()}" is using ${memInfo.residentSet} bytes`);
// 可以发送一个 IPC 消息让它清理,或直接 reload
win.webContents.send('memory-pressure');
}
}
}, 30000);
同时,当一个窗口被销毁后,务必确认你用它注册的所有事件监听器、托盘事件、全局快捷键都已移除。一个常见的陷阱是:窗口关闭了,但主进程中为该窗口绑定的 ipcMain 监听依然存在,随新窗口不断累积,最终造成主进程内存泄漏和事件处理混乱。
20.4.6 一组可直接对照执行的原则
实践中,我们可以把上面的策略浓缩成几条简明原则,在代码审查时直接对照:
- 能复用不新建:相同类型的窗口,先查找是否存在,存在则聚焦,不重复创建。
- 关窗口先隐藏,延迟再销毁:3–5 分钟的保活窗口可以明显提升体验,代价可控。
- 隐藏即休眠:页面不可见时,停止密集操作,释放可恢复的数据。
- 设置内存阈值:超出预期的渲染进程及时介入,避免 OOM 崩溃。
- 销毁即清理:窗口关闭时同时清理为其注册的所有事件和定时器,不残留任何引用。
多窗口的内存管理,本质上是在“响应速度”和“资源占用”之间找平衡。上面这些方法并不要求你做到面面俱到,但你只要把前两点应用起来,常常就能让用户感知到的流畅度提升一个档次,同时内存占用降低 30%–50%。在真实的生产环境中,这些数字远比理论层面的讨论更有说服力。