为什么我们在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-uimoment.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,或者为了兼容老代码把它关掉。

我在处理那个电商项目时,具体是这么做的:

我们来看具体的代码配置。这是主进程中创建窗口的部分:

// 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 页面,里面嵌入了一个