给 Electron 做一场减重手术

开篇:一个被讨论了十年的"原罪"

打开任务管理器,观察任意一个 Electron 应用,其内存占用常常以几百兆起步。VS Code、Slack、Discord、Notion 桌面端,每一款都在持续消耗系统内存。开发者社区常调侃 Electron 是"用浏览器套壳写桌面应用"的代名词,这一说法只对了一半——它确实基于浏览器,但更准确地说,它把整个 Chromium 和整个 Node.js 都打包进了每一个应用里。

这正是 Electron 的"原罪":每一个 Electron 应用都是一座独立王国,自带完整的浏览器引擎、完整的 JavaScript 运行时、完整的 V8 引擎。安装十个 Electron 应用,就有十份 Chromium 在硬盘上沉睡,十份 V8 在内存里运行。这种"重复建设"在十年前硬盘便宜、内存充裕的年代或许问题不大,但在用户对启动速度、续航、资源占用日益敏感的当下,它已经成为一种难以忽视的浪费。

Tauri 的出现,给出了另一种答案。它用 5MB 的体积实现了 Electron 100MB 才能做到的事,内存占用常常只有 Electron 的三分之一甚至更少。它究竟是如何做到的?如果重新设计 Electron,又能从 Tauri 借鉴哪些思路?本文将对此进行系统拆解。

Electron 的工作原理:被反复打包的运行时

要回答"如何减重",首先要厘清"重量"从何而来。

Electron 的架构可以拆成三层。最底层是 Node.js 运行时,负责文件系统、网络、子进程等系统能力;中间层是 Chromium 渲染引擎,负责把 HTML、CSS、JavaScript 渲染成用户看到的界面;最上层是 Electron 自身的胶水代码,通过 IPC(进程间通信)把 Node.js 的主进程和 Chromium 的渲染进程粘合在一起。三层叠加,构成了一个功能完备但体量庞大的运行时容器。

这套架构在功能上无可挑剔——浏览器全部的渲染能力与 Node.js 全部的系统能力兼得,等于把前端工程师最熟悉的两个工具链合二为一。问题在于,每一个 Electron 应用在打包时,都会把完整的 Chromium 二进制(约 100MB)和完整的 Node.js 二进制(约 30MB)一并塞进安装包。无论应用是 Notion 这种重渲染的笔记软件,还是只有几个表单的内部工具,都必须承担这 130MB 的"基础税"。这种一刀切的打包方式,正是 Electron 体积问题的根源。

内存层面的问题更隐蔽。Chromium 本身是一个多进程架构——主进程、GPU 进程、渲染进程、网络进程、音频进程等等,每个进程都拥有独立的 V8 实例、独立的 JavaScript 堆、独立的 Blink 渲染树。启动一个 Electron 应用,看到的不是一个进程,而是一整棵进程树。再加上 Node.js 的运行时开销,一个最简单的 Hello World Electron 应用,启动后内存占用即可突破 150MB。而当打开多个窗口、加载多个页面时,这一数字还会持续膨胀,因为每个渲染进程彼此独立,无法共享 V8 实例和渲染树。

Tauri 的工作原理:借力打力的轻量路线

Tauri 的思路完全不同。它不打包任何浏览器引擎,而是直接调用操作系统自带的 WebView:Windows 上用 WebView2(基于 Edge 的 Chromium 内核),macOS 上用 WKWebView(基于 Safari 的 WebKit 内核),Linux 上用 WebKitGTK。这意味着 Tauri 应用本身不需要携带任何浏览器二进制——这些引擎已经存在于用户系统中,Tauri 只是复用它们。这种"借力打力"的思路,是 Tauri 体积优势的根本来源。

后端方面,Tauri 用 Rust 替代了 Node.js。Rust 编译后的二进制体积小、运行时几乎没有 GC 开销、内存占用极低。一个典型的 Tauri 应用,安装包大小在 5-10MB 之间,启动后内存占用通常在 30-80MB,相比 Electron 是数量级的差距。Rust 的零成本抽象和所有权机制,让它在提供高级语言开发体验的同时,保持了接近 C++ 的运行效率,这对于资源敏感的桌面应用来说是显著优势。

Tauri 的另一个关键设计是"最小权限"。Electron 默认给渲染进程几乎所有的 Node.js 能力,这既不安全也增加了运行时负担;Tauri 则要求开发者显式声明每一个需要的能力(文件访问、网络请求、系统通知等),通过 Rust 后端以命令的方式暴露给前端。这种"白名单 + 命令式 IPC"的设计,不仅更安全,也让运行时只需加载真正用到的能力,避免了"全量加载、按需使用"的浪费。

但 Tauri 并非没有代价。最大的痛点是 WebView 的碎片化——Windows 7 上没有 WebView2,Linux 上 WebKitGTK 的版本差异巨大,不同平台的渲染行为也不完全一致。用 Tauri 编写一次应用,在某些边缘场景下可能需要为不同平台编写兼容代码。这是 Tauri 用"轻"换来的"碎",也是它至今无法完全取代 Electron 的原因之一。

重新设计 Electron:可从 Tauri 借鉴的减重方案

接下来进入正题。如果重新设计 Electron,可以从 Tauri 借鉴哪些思路,又如何在保留 Electron 生态优势的前提下,把它的体积和内存降下来?可以从七个方向同时入手。

把 Electron 拆成 DLL:让运行时成为系统级共享资源

这是最激进也最有效的一刀。Electron 当前的根本问题是"每个应用自带运行时",对应的解决方案就是"运行时与应用解耦"。

具体做法是:把 Chromium、Node.js、Electron 的胶水层,整体编译成一个名为 electron.dll(Windows)或 libelectron.so(Linux)或 Electron.framework(macOS)的动态链接库。该 DLL 包含完整的 V8 引擎、完整的 Blink 渲染器、完整的 Node.js 运行时,以及 Electron 的 IPC 桥接逻辑。它本身不提供任何 UI,只是一个"运行时容器",等待应用来调用。

随后,每一个 Electron 应用在打包时,只需打包自己的业务代码(HTML、CSS、JavaScript、资源文件),加上一个轻量的"启动器"可执行文件。启动器的职责非常简单:检查系统中是否已存在指定版本的 electron.dll,若有则直接加载,若无则提示用户安装一次"Electron 运行时"(类似 .NET Framework 或 Visual C++ Redistributable 的分发模式)。用户只需安装一次运行时,之后所有依赖该版本的 Electron 应用即可共享。

如此一来,安装十个 Electron 应用,硬盘上只有一份 electron.dll,内存中也只有一份被多个进程共享的核心库(通过操作系统的共享内存机制,DLL 的代码段在物理内存中是共享的,只有数据段是每个进程独立的)。粗略估算,这种方案可以把 Electron 应用的平均安装包从 130MB 降至 5-15MB,把多应用场景下的总内存占用降低 60% 以上。

该方案最大的挑战在于版本兼容性。不同应用可能依赖不同版本的 Electron,DLL 必须支持多版本共存。可以借鉴 Windows 的 SxS(Side-by-Side Assembly)机制,让 electron-22.dll、electron-28.dll、electron-30.dll 同时存在,应用在 manifest 中声明自己依赖的版本。这会增加一些复杂度,但换来的是真正的"一次安装,全局共享",对重度 Electron 用户而言是显著的体验提升。

模块化版本组合:按需裁剪,告别一刀切

Electron 当前的打包方式是"全量打包"——无论应用是否使用 GPU 加速、是否使用打印、是否使用 WebRTC、是否使用 PDF 渲染,Chromium 的所有模块都会被打进去。这就像购买一辆汽车,无论是否开启空调,空调系统都必须安装在车上;无论是否使用天窗,天窗都必须焊在车顶。这种"全家桶"式的打包,对于功能简单的应用而言是纯粹的浪费。

借鉴 Tauri 的"最小权限"思路,可以把 Electron 拆成一组可选模块,让开发者按需组合。

基础模块(必选):V8 引擎、Blink 核心渲染、基础网络栈、HTML/CSS/JS 解析。这是任何 Electron 应用都需要的最小集合,体积控制在 40MB 左右。

媒体模块(可选):WebRTC、Web Audio、WebM Codecs。视频会议类应用(如 Slack、Discord)需要,但笔记类应用完全不需要。

图形模块(可选):GPU 加速、WebGL、Canvas 2D 加速。游戏类应用和设计类应用需要,简单的表单应用可以禁用。

系统能力模块(可选):打印、蓝牙、USB、串口。物联网类应用需要,普通应用可以剥离。

Node.js 模块(可选):完整的 Node.js 运行时,或者只保留最小子集(fs、path、os 等核心模块)。很多 Electron 应用其实只用到了少数几个 Node.js API,完全可以裁剪掉大部分内置模块。

开发者在打包时,通过一个 manifest 文件声明需要哪些模块,打包工具根据声明生成定制化的 Electron 运行时。例如一个简单的笔记应用,可能只需要"基础模块 + 最小 Node.js 子集",打包后体积可控制在 30MB 以内;而一个视频会议应用,则需要"基础模块 + 媒体模块 + 图形模块 + 完整 Node.js",体积可能仍在 80MB,但相比当前的 130MB 已有显著下降。

这种模块化方案与前述 DLL 方案可以叠加使用:electron-core.dll 作为基础运行时,electron-media.dll、electron-gpu.dll 等作为可选扩展,应用按需加载。两者结合,既能实现跨应用共享,又能实现应用内按需裁剪,效果叠加。

DLL 缓存与全局运行时仓库:让 Electron 应用像 npm 包一样共享依赖

前述 DLL 方案若只是简单地"装一次运行时",还不够彻底。更完整的做法是建立一个类似 npm 全局缓存的机制,可称之为"Electron 运行时仓库"。

该仓库位于用户系统的固定路径下(例如 Windows 的 %PROGRAMDATA%\ElectronRuntime,macOS 的 /Library/Application Support/ElectronRuntime),按版本和模块组合缓存所有已下载的 Electron 组件。每个组件拥有唯一的哈希值,相同版本相同模块组合的组件只下载一次,绝不重复拉取。

当一个 Electron 应用首次启动时,启动器会读取自身的 manifest,计算出所需的组件清单,然后去仓库中查找。已存在的组件直接加载,不存在的组件从官方 CDN 下载并缓存。整个过程对用户透明——如果所有组件均已就绪,应用秒开;如果有组件需要下载,显示一个轻量的进度条。这种设计让用户几乎感知不到"运行时"的存在,就像使用系统自带的字体库一样自然。

这种设计的好处显而易见:安装第一个 Electron 应用时,可能需要下载 50MB 的基础运行时;安装第二个应用时,如果其依赖的组件与第一个应用有重叠,可能只需再下载 10MB 的差异部分;安装第十个应用时,可能什么都不用下载,直接复用已有缓存。对于重度桌面应用用户而言,这种"渐进式共享"带来的存储和带宽节省相当可观。

更进一步,可以引入"运行时预热"机制。在操作系统层面,可以在用户登录后、系统空闲时,预加载常用的 Electron 组件到内存(通过操作系统的 prefetch 或 mmap 机制),这样当用户真正启动 Electron 应用时,可以跳过磁盘 I/O,直接进入工作状态。这种预加载不会增加用户感知的内存占用(因为系统空闲时这些内存本就闲置),却能显著提升 Electron 应用的冷启动速度,让用户获得"秒开"的体验。

双轨制 WebView:在保留 Chromium 的同时拥抱系统 WebView

Tauri 最核心的思路是"用系统 WebView",但 Electron 不能完全照搬——因为 Electron 的核心价值之一就是"跨平台渲染一致性",如果改用系统 WebView,Windows 上的 WebView2、macOS 上的 WKWebView、Linux 上的 WebKitGTK 之间的差异,会让很多应用出现兼容性问题。这种一致性是 Electron 十年积累的护城河,不宜轻易放弃。

可行的方案是"双轨制":Electron 同时支持两种渲染后端,由应用在 manifest 中声明,让开发者自行选择。

第一种是"原生 Chromium 模式",即当前 Electron 的工作方式,自带完整 Chromium,保证最大兼容性。适合对渲染一致性要求极高的应用,例如设计软件、复杂的可视化工具、依赖特定 CSS 特性的专业应用。

第二种是"系统 WebView 模式",借鉴 Tauri 的思路,调用操作系统的 WebView。这种模式下,Electron 的胶水层会抽象出一套统一的 WebView 接口,屏蔽不同平台 WebView 的差异。对于大多数普通应用(笔记、聊天、邮件客户端等),这种模式完全够用,且体积和内存占用可以降到 Tauri 的水平。

更进一步,可以支持"渐进式切换"——应用可以在某些窗口使用系统 WebView(例如设置面板、关于对话框等简单界面),在另一些窗口使用原生 Chromium(例如主编辑区)。这种混合模式让开发者可以在"性能"与"一致性"之间灵活权衡,根据每个窗口的实际需求选择最合适的渲染后端,而非全局一刀切。

核心模块 Rust 化:用系统级语言重写热点路径

Node.js 作为后端语言,最大的问题是 GC 停顿和内存占用。一个长期运行的 Electron 应用,Node.js 的 V8 堆可能膨胀到几百 MB,GC 时的停顿也会影响渲染进程的响应,导致界面卡顿。这是 Electron 应用"越用越卡"的深层原因。

借鉴 Tauri 的 Rust 后端思路,可以把 Electron 的核心模块逐步用 Rust 重写,但并非完全替换 Node.js(那会破坏生态),而是把"热点路径"下沉到 Rust 层。这是一种渐进式的改造,既保留了 JavaScript 生态的丰富性,又获得了 Rust 的性能和内存优势。

具体而言,可以把文件系统操作、网络请求、数据库访问、加密解密、图像处理这些 CPU 密集或 I/O 密集的任务,用 Rust 实现为一组原生模块(.dll 或 .so),通过 N-API 暴露给 JavaScript 层。JavaScript 层只负责调用,真正的计算在 Rust 中完成,结果通过零拷贝的方式返回。这样既不破坏现有的 JavaScript 开发体验,又能在底层获得 Rust 的性能红利。

这样做的好处是多方面的。首先,Rust 没有 GC,这些热点路径的内存占用可以精确控制,不会出现 V8 堆膨胀。其次,Rust 的性能接近 C++,对于 I/O 密集型任务,比 Node.js 的异步 I/O 还要快。最后,Rust 的内存安全性可以消除 Electron 应用中大量的缓冲区溢出、空指针解引用等安全漏洞,提升整体安全性。

这种"JavaScript 编排 + Rust 执行"的混合架构,既保留了 Node.js 生态的丰富性,又获得了 Rust 的性能和内存优势。开发者仍用熟悉的 JavaScript 编写业务逻辑,只是底层的热点模块悄然换成了 Rust 实现,对上层透明。

V8 Snapshot 与按需加载:让启动只加载真正需要的代码

Electron 启动慢的一个原因是,V8 需要解析和编译大量的 JavaScript 启动代码——Electron 自身的初始化代码、Node.js 的内置模块、Chromium 的内置 JavaScript 资源。这些代码每次启动都要重新解析,浪费大量时间和内存。冷启动慢,是用户对 Electron 应用最直观的抱怨之一。

V8 其实提供了 Snapshot 机制,可以把解析后的字节码甚至 JIT 编译后的机器码序列化到磁盘,下次启动时直接加载,跳过解析和编译。Electron 当前已经使用了这一机制,但用得不够彻底,只覆盖了 Electron 自身的一部分初始化代码。

可以从两个方向深化这一机制。其一是把 V8 Snapshot 的覆盖范围扩大到应用代码层面。打包时,对应用的 JavaScript 代码做静态分析,把启动路径上一定会执行的代码(初始化逻辑、路由配置、主框架代码)预编译进 Snapshot。启动时直接加载这些字节码,跳过解析,让冷启动速度提升一个台阶。

其二是引入"按需加载"的代码分割机制。借鉴 Webpack 的代码分割思路,把应用代码拆成多个 chunk,主 chunk 只包含启动必需的代码,其他 chunk(例如某个不常用的设置面板、某个二级功能)在用户真正访问时才加载。这可以显著减少启动时的代码解析量,让冷启动速度提升 30-50%,同时减少启动时的内存峰值。

进程模型重构:从多进程到共享进程

Chromium 的多进程架构是 Electron 内存占用的另一个大头。每个渲染进程都拥有独立的 V8 实例、独立的 Blink 渲染树、独立的 JavaScript 堆,进程间无法共享内存(除了操作系统的共享库代码段)。打开五个窗口,就有五个独立的 V8 实例在运行,五个独立的渲染树在内存中。这种"进程隔离"虽然提升了稳定性,但也带来了巨大的内存开销。

可以重构 Electron 的进程模型,引入"共享渲染进程"模式。在这种模式下,多个窗口如果加载的是同一个应用的不同页面,可以共享一个渲染进程——通过 iframe 或 Web Component 的方式隔离,而非通过进程隔离。这种方案牺牲了一部分进程级的安全性(一个页面的崩溃可能影响其他页面),但对于同一个应用内部的多个窗口来说,这种风险是可接受的,换来的是显著的内存节省。

更进一步,可以引入"V8 Isolate 共享"机制。V8 本身支持 Isolate 概念——多个 Isolate 可以运行在同一个进程内,共享 V8 的编译器和垃圾回收器基础设施,但 JavaScript 堆是隔离的。这意味着多个窗口可以共享 V8 的"骨架",只各自维护自己的"堆数据",内存占用比完全独立的进程要低得多。这是一种介于"完全进程隔离"与"完全共享"之间的折中方案,既保留了一定的隔离性,又大幅降低了内存开销。

更激进的方案:跳出框架看问题

除了上述"在现有架构内优化"的方案,如果步子再大一些,还可以考虑几个更激进的方向,它们可能代表桌面应用框架的未来形态。

WebAssembly 后端:用 Wasm 替代 Node.js

Node.js 的体积和内存占用,很大程度上源于 V8 和 Node.js 自身的运行时开销。如果后端逻辑可以用 Rust、Go、C++ 编译成 WebAssembly,然后在一个轻量的 Wasm 运行时(如 Wasmtime、Wasmer)中执行,就可以完全绕开 Node.js。

Wasm 运行时的体积通常只有几 MB,内存占用也远低于 V8。而且 Wasm 模块是沙箱化的,安全性更好。这种方案下,Electron 的后端从"Node.js + V8"变为"Wasm 运行时 + 业务 Wasm 模块",体积和内存都可以再降一个台阶,同时获得更好的安全隔离。

代价是生态断裂——现有的 npm 生态无法直接复用,需要重写。但这种断裂可能是值得的,尤其是对于新应用。而且随着 Wasm 生态的成熟(WASI、组件模型等),这种方案的可行性会越来越高,可能成为下一代桌面应用框架的标准配置。

跨应用 IPC 总线:让 Electron 应用之间协作

如果多个 Electron 应用共享同一个运行时(通过前述 DLL 方案),那么它们之间天然就可以通信。可以设计一个"Electron IPC 总线",让不同的 Electron 应用通过该总线互相调用对方的能力,实现应用间的能力复用。

例如,用户安装了一个笔记应用和一个日历应用,笔记应用可以通过 IPC 总线调用日历应用的"创建事件"能力,而无需自行实现日历功能。这种跨应用协作可以让每个应用做得更小,专注于自身的核心能力,复杂功能通过组合实现,避免重复造轮子。

这本质上是微服务架构在桌面端的延伸——每个 Electron 应用是一个"微服务",通过 IPC 总线组合成"应用生态"。用户不再需要为每个应用安装完整的功能集,而是按需组合,让桌面环境变得更加模块化和高效。

落地挑战:理想与现实的差距

上述方案在理论上较为理想,但落地时会遇到一系列现实问题,技术之外的因素往往比技术本身更难突破。

兼容性是最大的拦路虎。Electron 已经拥有庞大的应用生态,VS Code、Slack、Discord 这些重量级应用都基于 Electron。任何破坏性变更都会让这些应用面临迁移成本。因此这些方案必须"渐进式"推进——先以可选模式提供,让新应用可以尝鲜,老应用保持原有打包方式,等新方案成熟后再逐步过渡。这种渐进式策略虽然较慢,但能保证生态的稳定性。

版本碎片化是另一个难题。DLL 共享方案要求所有应用使用相同或兼容的 Electron 版本,但现实中不同应用的升级节奏差异巨大——有的应用紧跟 Electron 最新版,有的应用仍停留在两年前的版本。需要一套完善的版本协商机制,让应用可以声明自己兼容的版本范围,运行时负责选择最合适的版本加载,避免"强制升级"带来的兼容性问题。

安全审计的复杂度也会上升。共享运行时意味着一个漏洞可能影响所有 Electron 应用,需要更严格的签名机制和更新策略。可以借鉴操作系统的补丁分发机制,让 Electron 运行时的安全更新通过系统级通道推送,所有依赖该运行时的应用自动受益,而非每个应用各自维护安全更新。

最后是商业博弈。Electron 由 GitHub(微软)维护,微软同时也是 Windows 和 Edge 的开发商。如果 Electron 转向系统 WebView,会与 Edge 的 WebView2 产生直接竞争;如果 Electron 运行时成为系统级共享组件,会削弱应用厂商对自身分发渠道的控制权。这些非技术因素,往往比技术本身更难突破,需要商业层面的协调与妥协。

结语:减重之路,非一日之功

Electron 的"重量"并非一日积累,而是十年生态演进的代价——它用体积换来了开发效率,用内存换来了跨平台一致性,用打包复杂度换来了运行时简单性。这些 trade-off 在 Electron 诞生时是合理的,但在 Tauri 已经证明"轻量也能做到"的今天,是时候重新审视这些权衡了。

从 Tauri 身上可以借鉴的,不只是"用系统 WebView"这一个技巧,更是一种"借力打力"的架构哲学——不重复造轮子,不打包已经存在的东西,不加载用不到的能力。把这种哲学贯彻到 Electron 的每一个角落,从 DLL 化到模块化,从运行时共享到 Rust 化,每一步都在为它减轻一份重量。

也许有一天,会看到一个全新的 Electron——5MB 的安装包,30MB 的内存占用,秒级启动,跨平台一致。那时再回头看今天的 Electron,就像今天回头看 IE6 一样,会心一笑。而通往那一天的路径,就藏在 Tauri 已经走过的脚印里。

posted @ 2026-08-02 23:51  减瓦~  阅读(70)  评论(0)    收藏  举报