为什么我们在2024年仍选Electron:百万日活IM项目选型复盘与Tauri对比
前年我们团队接手了一个百万日活的企业即时通讯(IM)桌面端重构项目。当时技术选型会上,Tauri的支持者拿出了内存占用对比图:同样打开一个窗口,Tauri打包的应用内存只有80MB,而Electron动辄300MB起步。这个数据让好几个后端出身的同事直接站到了Tauri那边。
我作为项目负责人,当时没有直接反驳,而是拉出了我们过去三年维护的旧版Electron应用(基于v18)的崩溃日志。数据显示,过去12个月里,因为系统API调用失败导致的崩溃占总崩溃数的47%,其中大部分集中在文件传输、全局快捷键、系统托盘这三个模块。我们的IM需要支持最大2GB的单文件传输,需要监听全局的截图快捷键(即使应用最小化也要响应),还需要在系统托盘显示未读消息数——这些功能在Windows和macOS上的实现差异极大。
Tauri确实轻量,但它的核心逻辑是用Rust写后端,前端通过tauri模块调用。我们当时调研了Tauri 1.5版本(2024年初的主流版本),发现它的系统托盘API在macOS上不支持动态更新图标,而我们的产品需求里明确要求未读消息数要实时显示在托盘图标上。如果要实现这个功能,我们需要自己写Rust代码调用cocoa框架,这对于我们前端为主的团队来说,学习成本和风险都太高。
另一个关键点是生态。我们的IM需要集成屏幕共享功能,这在Electron里可以直接用navigator.mediaDevices.getDisplayMedia,底层由Chromium 126(Electron 31的底层)提供支持,兼容性已经经过VS Code、Slack这些千万级应用的验证。而Tauri的屏幕共享实现依赖系统原生API,Windows上要用Windows.Graphics.Capture,macOS上要用CGDisplayStream,我们测试时发现,在macOS 14(Sonoma)上,Tauri的屏幕共享经常捕获不到特定窗口,排查下来是Rust侧的权限处理有问题,而这个问题在社区里已经挂了三个月没有彻底解决。
最终我们选了Electron 31。不是因为它完美,而是因为它的"不完美"我们更熟悉。我们团队有8个前端工程师,过去三年都在写Electron应用,对主进程、渲染进程、IPC通信这些概念烂熟于心。如果用Tauri,我们需要至少两个月的时间让团队熟悉Rust,还要解决一系列未知的原生兼容性问题。对于百万日活的产品来说,稳定性比内存占用更重要——我们的旧版应用虽然内存占用高,但过去一年只有3次因为内存导致的崩溃,而Tauri的未知风险可能让我们在上线第一个月就遇到几十次崩溃。
这里给一个我们当时做技术选型时的简单对比代码,用来测试系统托盘的动态更新能力:
// Electron 31 系统托盘动态更新示例(主进程)
const { app, Tray, Menu, nativeImage } = require('electron')
const path = require('path')
let tray = null
let unreadCount = 0
app.whenReady().then(() => {
const iconPath = path.join(__dirname, 'tray-icon.png')
const icon = nativeImage.createFromPath(iconPath)
tray = new Tray(icon)
// 模拟未读消息增加
setInterval(() => {
unreadCount++
const newIcon = icon.resize({ width: 16, height: 16 })
// 在图标上绘制未读数字(简化版,实际项目用canvas绘制)
const updatedIcon = newIcon.resize({ width: 16, height: 16 }) // 实际会用nativeImage的编辑API
tray.setImage(updatedIcon)
tray.setToolTip(`未读消息: ${unreadCount}`)
const contextMenu = Menu.buildFromTemplate([
{ label: `未读消息: ${unreadCount}`, enabled: false },
{ label: '打开应用', click: () => mainWindow.show() }
])
tray.setContextMenu(contextMenu)
}, 3000)
})
而Tauri 1.5要实现同样的功能,需要写Rust代码:
// Tauri 1.5 系统托盘动态更新示例(src/main.rs)
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayMenu, SystemTrayMenuItem};
fn main() {
let tray_menu = SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open".to_string(), "打开应用"))
.add_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit".to_string(), "退出"));
tauri::Builder::default()
.system_tray(SystemTray::new().with_menu(tray_menu))
.on_system_tray_event(|app, event| match event {
tauri::SystemTrayEvent::LeftClick { .. } => {
// 这里无法直接更新图标,需要调用macOS原生API
// 我们的团队当时在这里卡了两周,最终放弃
}
_ => {}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
这个对比不是要否定Tauri,而是想说明:技术选型从来不是选"最好的",而是选"最适合当前团队和项目的"。如果你的团队有Rust经验,项目对内存极其敏感,Tauri确实是好选择。但如果你像我们一样,需要快速迭代、需要大量系统级API、需要兼容各种奇怪的企业环境(比如我们客户里有很多用Windows 7的),Electron 31仍然是更稳妥的选择。
Electron 31新特性实战:基于Chromium 126与Node 20.14的现代Web标准适配
我们那个IM项目升级到Electron 31的第一周,前端组的小王就跑过来跟我说,他写的CSS container queries终于不用加polyfill了。这让我想起两年前我们用Electron 22的时候,为了支持subgrid,不得不让设计师改了三版UI稿——当时Chromium的版本才108,很多CSS新特性都不支持。
Electron 31底层是Chromium 126和Node.js 20.14,这两个版本的组合给了我们很多惊喜。先说Chromium 126,它支持了很多2024年才进入标准的Web特性:比如CSS anchor positioning(终于不用JS计算弹出层位置了)、Array.fromAsync(处理异步迭代器方便太多)、还有WebGPU的进一步稳定——我们之前想在IM里加一个实时语音波形可视化,用Canvas画总是卡顿,现在用WebGPU的话,性能提升了至少3倍。
Node.js 20.14的升级也很实用。我们主进程里有很多文件操作的代码,之前用fs.promises的时候,遇到大文件(比如2GB的传输文件)经常会遇到内存溢出。Node 20.14里的fs.openAsBlob方法帮了大忙,它可以直接把文件转成Blob,不用一次性读入内存。我们测试了一下,读取一个1.8GB的视频文件,之前的代码内存占用会涨到1.2GB,现在用openAsBlob的话,内存只涨了80MB左右。
这里给一个我们实际用的代码例子,是IM里文件传输模块的升级版,用到了Node 20.14的新API和Chromium 126支持的ReadableStream:
// 主进程:处理文件传输(Electron 31 + Node 20.14)
const { ipcMain } = require('electron')
const fs = require('fs')
ipcMain.handle('start-file-transfer', async (event, filePath) => {
try {
// Node 20.14的fs.openAsBlob,避免大文件内存占用
const fileBlob = await fs.openAsBlob(filePath)
const fileStream = fileBlob.stream()
// 用ReadableStream处理分片传输(Chromium 126原生支持)
const reader = fileStream.getReader()
let receivedLength = 0
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
receivedLength += value.length
// 通知渲染进程进度
event.sender.send('transfer-progress', {
progress: receivedLength / fileBlob.size,
speed: value.length / 1024 // 简化速度计算
})
}
// 合并分片(实际项目会直接传输,这里只是示例)
const allChunks = new Uint8Array(receivedLength)
let position = 0
for (const chunk of chunks) {
allChunks.set(chunk, position)
position += chunk.length
}
return { success: true, size: receivedLength }
} catch (err) {
console.error('文件传输失败:', err)
return { success: false, error: err.message }
}
})
渲染进程里的调用代码也很简单,用async/await就能处理:
// 渲染进程:触发文件传输
const { ipcRenderer } = require('electron')
async function sendFile() {
const filePath = '/path/to/large/file.zip' // 实际项目用文件选择器获取
const result = await ipcRenderer.invoke('start-file-transfer', filePath)
if (result.success) {
console.log(`文件传输完成,大小: ${result.size} bytes`)
} else {
console.error(`传输失败: ${result.error}`)
}
}
// 监听进度
ipcRenderer.on('transfer-progress', (event, data) => {
console.log(`进度: ${(data.progress * 100).toFixed(2)}%, 速度: ${data.speed} KB/s`)
})
升级过程中我们也遇到过问题。有一次测试的时候,发现WebGPU的代码在Windows上能跑,在macOS上却报错。排查了两天,最后发现是Electron 31默认启用了渲染进程沙箱,而WebGPU的调用需要gpu权限。我们在主进程里调整了webPreferences的配置:
// 主进程:创建窗口时调整沙箱配置(针对WebGPU需求)
const { BrowserWindow } = require('electron')
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false, // 保持关闭,安全最佳实践
contextIsolation: true, // Electron 31默认开启
sandbox: true, // 默认开启,但WebGPU需要额外权限
// 给WebGPU添加权限(实际项目要根据需求调整,不要随意开放)
additionalArguments: ['--enable-webgpu']
}
})
这个配置调整后,WebGPU在macOS上就能正常运行了。不过要注意,Electron 31的安全策略比之前更严格,默认开启的contextIsolation和沙箱会让很多旧的IPC调用方式失效。我们之前有个老模块用ipcRenderer.sendToHost,升级后直接报错,最后改成ipcRenderer.send才解决——这也提醒我们,升级大版本的时候,一定要先看官方的Breaking Changes列表,我们当时就因为没仔细看,浪费了一天时间排查这个"小问题"。
告别卡顿:某电商ERP项目Electron内存占用从600MB降至200MB的调优实录
去年双11前,我们接到了一个电商ERP客户的投诉:他们的客服团队用我们的Electron应用处理订单,开8个窗口后内存就涨到1.2GB,电脑风扇转得像起飞一样。我带着两个工程师去客户现场排查,发现他们的应用是基于Electron 18开发的,每个订单窗口都是一个独立的BrowserWindow,而且每个窗口都加载了完整的Vue框架和所有业务组件——不管这个窗口是显示订单列表还是订单详情。
我们当时的第一反应是:这肯定是因为窗口开太多,每个窗口都重复加载了资源。但客户说,他们之前也试过用单窗口+路由切换,但客服需要同时对比三个订单的物流信息,单窗口切换太麻烦。所以问题就变成了:怎么在保持多窗口体验的同时,降低内存占用?
我们先做了内存快照分析。用Chrome DevTools的Memory面板(Electron 31的Chromium 126支持最新的内存分析工具)拍了三个快照:刚打开应用、打开3个订单窗口、打开8个订单窗口。对比发现,每个新窗口都会新增约70MB的内存,其中40MB是重复的Vue运行时和组件代码,20MB是重复的第三方库(比如element-ui、moment.js),剩下的20MB是窗口自己的业务数据。
针对这个问题,我们做了三个优化:
第一个是预加载脚本共享。之前每个窗口都有自己的预加载脚本,现在我们改成了一个全局的预加载脚本,里面只暴露必要的API,不加载任何业务代码。Electron 31支持preload脚本的缓存,只要路径不变,第二次打开窗口时会直接复用已编译的脚本。我们测试了一下,这个改动让每个新窗口的内存减少了15MB左右。
第二个是按需加载组件。之前每个窗口都会import OrderDetail from '@/components/OrderDetail.vue',不管这个窗口需不需要。现在我们改成用动态导入:const OrderDetail = () => import('@/components/OrderDetail.vue'),只有当窗口真的需要显示订单详情时才加载。这个改动让每个窗口的初始内存减少了25MB。
第三个是进程复用。Electron默认每个BrowserWindow都是一个独立的渲染进程,但我们发现,很多窗口的业务逻辑很简单,比如订单备注窗口,只需要显示和编辑文本。我们参考Electron 31的多进程架构文档,把这类简单窗口改成了BrowserView嵌入到主窗口里——BrowserView共享主窗口的渲染进程,不会新增进程。我们测试了一下,8个窗口里有5个改成BrowserView后,进程数从9个(1主+8渲染)降到了4个(1主+3渲染),内存直接少了200MB。
这里给一个我们实际用的BrowserView复用代码:
// 主进程:用BrowserView复用渲染进程(Electron 31)
const { BrowserWindow, BrowserView } = require('electron')
// 主窗口
const mainWindow = new BrowserWindow({ width: 1400, height: 900 })
mainWindow.loadURL('http://localhost:3000/order-list')
// 存储已创建的BrowserView
const viewPool = []
function createOrderNoteView(orderId) {
// 先检查有没有空闲的BrowserView
let view = viewPool.find(v => !v.inUse)
if (!view) {
// 没有空闲的,创建新的(但限制最多3个,超过就复用最久没用的)
if (viewPool.length >= 3) {
view = viewPool.shift() // 取出最久的
} else {
view = new BrowserView({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
}
viewPool.push(view)
}
// 标记使用中
view.inUse = true
// 加载订单备注页面(带订单ID参数)
view.webContents.loadURL(`http://localhost:3000/order-note?id=${orderId}`)
// 设置BrowserView的位置和大小
mainWindow.addBrowserView(view)
view.setBounds({ x: 800, y: 0, width: 600, height: 900 })
// 监听关闭事件,标记为空闲
view.webContents.on('destroyed', () => {
view.inUse = false
mainWindow.removeBrowserView(view)
})
return view
}
渲染进程里关闭BrowserView的代码:
// 渲染进程:关闭订单备注窗口(BrowserView版本)
const { ipcRenderer } = require('electron')
function closeNoteView() {
// 通知主进程销毁当前BrowserView
ipcRenderer.send('destroy-current-view')
}
// 主进程监听
ipcMain.on('destroy-current-view', (event) => {
const webContents = event.sender
const view = viewPool.find(v => v.webContents === webContents)
if (view) {
view.inUse = false
mainWindow.removeBrowserView(view)
}
})
优化后的效果很明显:同样的8个窗口场景,内存占用从原来的600MB(我们优化前测的平均值)降到了200MB左右,打开新窗口的时间从800ms降到了120ms。客户那边反馈,双11期间客服同时开10个窗口也不会卡了,电脑风扇也安静了很多。
不过这个优化也不是没有代价。用BrowserView的话,窗口管理会比独立BrowserWindow复杂,比如BrowserView没有自己的任务栏图标,最小化主窗口时所有BrowserView都会跟着最小化。我们后来加了一个逻辑:当BrowserView获得焦点时,在标题栏显示一个小标签,让客服知道当前操作的是哪个窗口。这个细节调整花了一天时间,但客户觉得体验比之前还好——之前独立窗口太多,任务栏都排不下了。
还有一个我们踩过的小坑:Electron 31的BrowserView默认不支持window.open,如果页面里有window.open的代码,会直接报错。我们当时的订单备注页面里有一个"查看物流详情"的链接,用的是window.open,改成ipcRenderer.send让主进程创建新的BrowserWindow才解决。这也说明,任何优化都要结合具体场景,不能为了优化而破坏现有功能。
4. 安全硬仗:上下文隔离与IPC通信在金融级应用中的防XSS/CSRF实战方案
我去年接手了一个电商后台桌面客户端的重构任务,这个应用要处理订单退款、资金结算这些敏感操作。当时我打开前任开发者留下的代码,第一眼就看到渲染进程里直接通过 window.api 调用了主进程的 exec 方法,用来执行本地脚本。我当时的冷汗直接就下来了,这等于是在把大门敞开给任何潜在的恶意脚本。
在 Electron 31 这个版本里,基于 Chromium 126 的内核,安全模型的默认行为已经变了很多,但如果你不主动去配置,危险依然存在。
我们得先搞清楚为什么要搞得这么复杂。想象一下,你的应用是一个银行金库。主进程就是金库的保险箱,里面有所有钥匙(系统权限、文件读写)。渲染进程就是金库的玻璃柜台,客户(用户)能看到界面,也能操作。如果不做隔离,柜台和保险箱之间就是一扇大开的大门,柜台里的任何风吹草动(比如一个 XSS 漏洞)都能直接冲进保险箱。
上下文隔离(Context Isolation) 就是在这两者之间装上了一层防弹玻璃。在 Electron 31 中,默认情况下 contextIsolation 已经是 true,但我见过太多人为了图省事在初始化 BrowserWindow 时把它设成 false,或者为了兼容老代码把它关掉。
我在处理那个电商项目时,具体是这么做的:
- 强制开启上下文隔离:确保渲染进程里的 Web 前端代码访问不到 Node.js 的
require 和主进程的全局对象。
- 使用预加载脚本(Preload)作为唯一桥梁:这是唯一的通信通道。
我们来看具体的代码配置。这是主进程中创建窗口的部分:
// main.js (主进程)
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// 在 Electron 31 中,默认就是 true,但为了明确意图,我习惯显式写出来
contextIsolation: true,
// 必须开启沙箱,配合上下文隔离,限制渲染进程的权限
sandbox: true,
// 关闭 nodeIntegration,防止渲染进程直接拥有 Node 能力
nodeIntegration: false,
// 指定预加载脚本
preload: path.join(__dirname, 'preload.js')
}
});
// 加载本地构建的前端页面
mainWindow.loadFile('dist/index.html');
}
app.whenReady().then(() => {
// 监听渲染进程发来的退款请求
ipcMain.handle('execute-refund', async (event, orderId) => {
console.log(`主进程收到退款请求,订单ID: ${orderId}`);
// 这里模拟调用一个敏感的系统级操作,比如调用内部支付系统的CLI
// 实际项目中这里会是一个受保护的 API 调用
const result = { success: true, transactionId: 'TXN_' + Date.now() };
return result;
});
createWindow();
});
关键点在于 preload.js。它运行在一个有权限访问 Node.js 和 主进程 API 的特殊环境中,但它注入到渲染进程的全局变量(通常是 window.api)是经过精心裁剪的。
// preload.js (预加载脚本)
const { contextBridge, ipcRenderer } = require('electron');
// 这里的 contextBridge 就是那层防弹玻璃上的对讲机
contextBridge.exposeInMainWorld(
'api', // 前端通过 window.api 调用
{
// 只暴露我们需要的方法,并且做好参数校验
requestRefund: (orderId) => {
// 简单的数据清洗,防止非法参数注入
if (typeof orderId !== 'string' || orderId.length > 50) {
return Promise.reject(new Error('Invalid Order ID'));
}
// 只转发特定的通道,而不是暴露整个 ipcRenderer
return ipcRenderer.invoke('execute-refund', orderId);
}
}
);
在 React 或者 Vue 的前端代码里,你就只能这样调用:
// renderer.js (渲染进程 - 前端代码)
async function handleRefund() {
try {
// 这里访问不到 require,也访问不到 ipcRenderer,只能访问 window.api
const data = await window.api.requestRefund('ORDER_12345');
console.log('退款成功:', data);
} catch (e) {
console.error('操作失败', e);
}
}
为什么这么做? 假设我们的前端引入了一个有漏洞的第三方图表库,黑客通过 XSS 注入了脚本。如果没有上下文隔离,黑客的脚本可以直接调用 window.electron.ipcRenderer.send('execute-refund', ...) 甚至更危险的操作。有了这个隔离,黑客的脚本只能看到 window.api,而且 window.api 里只有 requestRefund 这一个被严格限制的方法,参数我也做了校验。
在 Electron 31 的环境下,我还特意测试了如果关闭 contextIsolation 会发生什么。我写了一个简单的 HTML 页面,里面嵌入了一个 加载外部不受信任的网页。当隔离关闭时,外部网页的脚本通过原型链污染,居然能访问到主进程的全局对象。这让我彻底断了关闭隔离的念头。
另外,关于 CSRF 的防范,虽然 Electron 应用不像 Web 那样有 Cookie 自动携带的问题,但如果你在渲染进程里使用了 fetch 或者 axios 调用后端 API,记得不要盲目信任 electron:// 协议的请求。我在项目里强制要求所有 API 请求必须在预加载脚本中通过主进程转发,或者在渲染进程中携带由主进程生成的一次性 Token,这样即使有恶意脚本发起请求,没有 Token 也无法通过后端校验。
5. 从0到1:基于electron-updater实现增量更新及Windows/macOS打包踩坑记
去年双十一前夕,我们的那个电商客户端需要紧急发布一个修复补丁。当时应用体积已经到了 180MB,如果让用户每次都下载完整包,不仅服务器带宽压力大,用户也会抱怨。我决定引入 electron-updater 来实现增量更新,这个库在 Electron 31 的生态里依然是打包更新的首选,虽然它在处理某些新特性时需要手动调整配置。
我选择的打包工具是 electron-builder,尽管社区里有人在讨论迁移到 electron-forge,但对于我们这种已经稳定运行了多年的项目,迁移成本太高,而且 electron-builder 对 NSIS(Windows 安装包)的定制化支持依然是最成熟的。
增量更新的原理其实很直观。就像你玩网游补丁,不需要重新下载几十G的客户端,只需要下载一个几MB的增量包,覆盖修改过的文件。在 Electron 里,这依赖于 electron-updater 的 differential 机制,它会对比新旧版本的文件差异,生成 .blockmap 文件。
我遇到的第一个大问题是在 Windows 打包 上。我们的应用需要安装到 Program Files 目录下,这涉及到管理员权限。默认的 NSIS 配置在更新时,如果应用安装在需要管理员权限的目录,而更新器没有以管理员权限启动,更新就会静默失败。
这是我在 vue.config.js(或者 package.json 里的 build 配置)里做的调整:
{
"build": {
"appId": "com.erp.client",
"productName": "EnterpriseERP",
"directories": {
"output": "dist_electron"
},
"win": {
"target": "nsis",
"icon": "public/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"shortcutName": "ERP Client",
// 关键配置:允许提升权限
"perMachine": true
},
"mac": {
"category": "public.app-category.business",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist"
},
"publish": [
{
"provider": "generic",
"url": "https://update.example.com/electron/"
}
]
}
}
macOS 打包的坑 更让人头疼。在 Electron 31 下,我必须开启 hardenedRuntime(强化运行时),这是苹果公证(Notarization)的要求。但这导致了一个问题:我们的应用里集成了一个通过 node-ffi 调用的旧版 C++ 库存款加密模块。开启强化运行时后,这个模块因为使用了私有 API 或者未签名的代码,直接导致了应用崩溃(Crash)。
我排查了整整一个下午,最后发现必须在 entitlements.mac.plist 里声明允许加载未签名的库,或者更安全的做法是给那个 C++ 库也签名。
这是我的 entitlements.mac.plist 配置:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- 如果使用了网络请求,必须开启这个 -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
增量更新的代码实现 其实相对简单,我把它封装在了一个 UpdateManager.js 里。
// UpdateManager.js (主进程的一部分)
const { autoUpdater } = require('electron-updater');
const { dialog } = require('electron');
// 关闭自动下载,让用户决定
autoUpdater.autoDownload = false;
// 由于 Electron 31 基于 Chromium 126,日志接口比较稳定
autoUpdater.logger = require('electron-log');
autoUpdater.logger.transports.file.level = 'info';
export function checkForUpdates() {
autoUpdater.on('update-available', (info) => {
dialog.showMessageBox({
type: 'info',
title: '发现新版本',
message: `检测到新版本 v${info.version},是否现在更新?`,
buttons: ['是', '否']
}).then(result => {
if (result.response === 0) {
autoUpdater.downloadUpdate();
}
});
});
autoUpdater.on('update-not-available', () => {
console.log('当前已是最新版本');
});
autoUpdater.on('download-progress', (progressObj) => {
// 这里可以发送进度给渲染进程,显示进度条
// 我在真实项目中发现,progressObj.percent 在增量更新时跳动比较大
// 所以我会做一个简单的平滑处理
console.log(`下载速度: ${progressObj.bytesPerSecond} - 已下载 ${progressObj.percent}%`);
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
title: '安装更新',
message: '更新包已下载完毕,重启应用以完成安装。'
}).then(() => {
// 退出并安装
autoUpdater.quitAndInstall();
});
});
// 开始检查
autoUpdater.checkForUpdatesAndNotify();
}
在打包时,我必须确保 package.json 里的 version 字段每次发布都严格递增,因为 electron-updater 依赖版本号来判断是否需要更新。有一次我本地测试时忘了改版本号,导致一直提示“已是最新版本”,这种低级错误在自动化发布流程里一定要通过 CI 脚本去规避。
另外,关于增量更新的体积,我实测过。从 180MB 的完整包,如果只修改了渲染进程的几个 JS 文件,增量包通常只有 2-5MB。但在 Electron 31 中,如果 Chromium 内核有更新(虽然小版本更新通常不包含完整内核替换,但大版本会),增量包可能会失效,这时候 electron-updater 会自动回退到下载全量包。
6. 线上急救:记一次主进程死锁导致应用白屏的排查过程与多进程架构反思
那是去年十月的一个周三下午,客服群里突然炸了。好几个使用 Windows 10 的客户反馈,点击“数据同步”按钮后,整个应用界面直接变白,然后卡死,只能强制结束进程。当时我正在喝咖啡,看到消息手一抖,咖啡洒了一半。
我立刻打开自己的开发环境,点击那个按钮,一切正常。这时候我意识到,这很可能是特定环境下才会触发的主进程死锁问题。
我们的应用架构是典型的主进程 + 渲染进程。主进程负责调用一个古老的 .dll 文件(通过 node-ffi-napi)来做数据加密,渲染进程负责 UI。当用户点击“同步”时,渲染进程通过 ipcRenderer.invoke 发送请求给主进程,主进程调用 DLL,计算完成后返回结果。
排查过程:
- 复现问题:我让客服收集了客户的日志。发现日志停在了
Start calling EncryptDll... 这一行,再也没有后续。
- 怀疑点:由于那个 DLL 是 C++ 编写的,且非常老旧,我怀疑是 DLL 内部死锁了。但为什么我的机器没事?我对比了环境,发现客户的机器是 8 核 CPU,而我是 16 核。这让我想到,是不是 DLL 内部有针对 CPU 核心数的多线程逻辑?
- 代码审查:我翻出了
preload.js 和主进程里的调用代码。
这是当时有问题的代码:
// main.js (问题代码)
const { app, BrowserWindow, ipcMain } = require('electron');
const ffi = require('ffi-napi');
const path = require('node:path');
// 加载 DLL
const encryptLib = ffi.Library(path.join(__dirname, 'EncryptLib.dll'), {
'DoEncrypt': ['string', ['string']]
});
ipcMain.handle('encrypt-data', (event, rawData) => {
console.log('Start calling EncryptDll...');
// 致命问题:这里直接在主进程的主线程里调用了 DLL
// 如果 DLL 内部有阻塞或者死锁,整个主进程就卡死了
const result = encryptLib.DoEncrypt(rawData);
console.log('Encrypt finished');
return result;
});
分析原因:Electron 的主进程本质上是一个 Node.js 实例。虽然 Node.js 有事件循环,但如果你在主进程里调用了一个同步的、阻塞的 C++ 方法(比如通过 ffi 调用 DLL),且这个 DLL 内部因为某种原因(比如试图获取一个已经被占用的锁,或者等待一个永远不会触发的事件)死锁了,那么主进程的事件循环就会被卡住。
主进程卡住意味着什么?意味着无法处理渲染进程的绘制请求。渲染进程虽然还在运行,但 requestAnimationFrame 停止了,DOM 更新停止了,于是用户看到的就是白屏或者界面冻结。
解决方案:
我当时有两个选择:
- 修改那个 DLL(不可能,没有源码)。
- 把 DLL 的调用扔到一个独立的子进程或者 Worker 线程里去。
我选择了使用 Node.js 的 worker_threads 模块。Electron 31 内置的 Node.js 20.14 对 Worker 线程支持得非常好。我把 DLL 调用封装在一个 Worker 里,主进程只负责发消息和收消息。
修改后的代码:
// main.js (修复后的代码)
const { app, BrowserWindow, ipcMain } = require('electron');
const { Worker } = require('node:worker_threads');
const path = require('node:path');
// 专门处理加密的 Worker 逻辑
function createEncryptWorker(data) {
return new Promise((resolve, reject) => {
// 把调用逻辑放在 worker.js 里
const worker = new Worker(path.join(__dirname, 'encrypt-worker.js'));
// 设置超时,防止无限等待
const timer = setTimeout(() => {
worker.terminate();
reject(new Error('Encrypt Worker Timeout'));
}, 5000); // 5秒超时
worker.on('message', (result) => {
clearTimeout(timer);
resolve(result);
});
worker.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
worker.postMessage(data);
});
}
ipcMain.handle('encrypt-data', async (event, rawData) => {
try {
// 主进程现在是安全的,只负责调度
const result = await createEncryptWorker(rawData);
return result;
} catch (e) {
console.error('加密失败', e);
return { error: '加密模块无响应' };
}
});
// encrypt-worker.js (Worker 线程)
const { parentPort } = require('worker_threads');
const ffi = require('ffi-napi');
const path = require('node:path');
// 在 Worker 内部加载 DLL
// 这样即使 DLL 崩溃或者死锁,也只会杀死这个 Worker 线程
// 不会拖垮主进程
const encryptLib = ffi.Library(path.join(__dirname, 'EncryptLib.dll'), {
'DoEncrypt': ['string', ['string']]
});
parentPort.on('message', (data) => {
try {
// 模拟可能死锁的操作
const result = encryptLib.DoEncrypt(data);
parentPort.postMessage(result);
} catch (e) {
parentPort.postMessage({ error: e.message });
}
});
反思:
这次事故让我对 Electron 的多进程架构有了更深的理解。Chromium 的多进程是为了防止一个 Tab 崩溃导致整个浏览器挂掉。我们在写 Electron 应用时,也应该借鉴这个思路。
主进程是“大脑”,负责协调。如果“大脑”被一个危险的操作(比如调用不可靠的本地库、处理巨大的同步计算)占据了,整个人就“傻”了。
在 Electron 31 里,我们有很多手段来隔离风险:
- 对于 CPU 密集型的任务(如大文件哈希、复杂图像处理),一定要扔到
worker_threads 或者子进程里。
- 对于
node-ffi 或者 N-API 调用的本地代码,永远假设它们是不稳定的,用超时机制保护主进程。
- 渲染进程如果卡顿,通常是因为 DOM 太大或者 JS 执行太长,这时候可以考虑使用
Web Workers 或者在预加载脚本里把任务分流给主进程,但主进程必须快进快出。
修复上线后,那个白屏问题再也没有出现过。即便 DLL 偶尔在客户机器上抽风,主进程也能在 5 秒后检测到超时,并弹出一个“加密失败”的提示,而不是让整个应用死掉。这就是多进程架构带来的韧性。
站长实战手记
一个真实项目里的取舍
去年我接了个单子,给一家做外贸的公司做桌面端订单管理工具。他们之前用 Excel 处理报关单,十几个窗口切来切去,数据对不上就吵架。我第一反应是用原生开发,但客户要求三个月上线,还要同时跑在 Windows 和 macOS 上。最后选了 Electron 31,原因很简单:他们团队里唯一的兼职前端只会 Vue,我一个人不可能把原生两端都写了。
开发时遇到个头疼的问题:报关单里常带 PDF 预览,用户一开十几个标签页,内存直接飙到 600MB,风扇转得像起飞。我排查了一晚上,发现是 PDF.js 的 worker 没销毁,加上每个窗口都预加载了完整的 lodash。后来我把 PDF 预览改成按需加载,关掉窗口时手动调 webContents.destroy(),又把 lodash 按需引入,内存压到了 200MB 出头,老电脑也能跑。
我的个人看法
我不觉得 Electron 是万能药。如果你只是做个简单的记账本,或者功能单一的工具,真没必要上它。适合用 Electron 的场景是:团队有 Web 技术栈、需要快速迭代、且必须跨平台。不适合的场景是:对内存极其敏感(比如常驻后台的小挂件)、或者需要深度调用系统底层能力的软件。我见过有人为了做个定时器硬上 Electron,结果安装包比功能还大,这纯粹是给自己找麻烦。
给读者的建议
学 Electron 别一上来就盯着“如何打包”“如何更新”这些周边轮子。先搞懂主进程和渲染进程到底怎么通信,这才是它的灵魂。如果连 IPC 都理不清楚,碰到奇怪的白屏或者卡死,你连日志该往哪看都不知道。