给 Electron 做一场减重手术

给 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 的思路完全不同。它不打包任何浏览器引擎而是直接调用操作系统自带的 WebViewWindows 上用 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 上没有 WebView2Linux 上 WebKitGTK 的版本差异巨大不同平台的渲染行为也不完全一致。用 Tauri 编写一次应用在某些边缘场景下可能需要为不同平台编写兼容代码。这是 Tauri 用轻换来的碎也是它至今无法完全取代 Electron 的原因之一。重新设计 Electron可从 Tauri 借鉴的减重方案接下来进入正题。如果重新设计 Electron可以从 Tauri 借鉴哪些思路又如何在保留 Electron 生态优势的前提下把它的体积和内存降下来可以从七个方向同时入手。把 Electron 拆成 DLL让运行时成为系统级共享资源这是最激进也最有效的一刀。Electron 当前的根本问题是每个应用自带运行时对应的解决方案就是运行时与应用解耦。具体做法是把 Chromium、Node.js、Electron 的胶水层整体编译成一个名为 electron.dllWindows或 libelectron.soLinux或 Electron.frameworkmacOS的动态链接库。该 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% 以上。该方案最大的挑战在于版本兼容性。不同应用可能依赖不同版本的 ElectronDLL 必须支持多版本共存。可以借鉴 Windows 的 SxSSide-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%\ElectronRuntimemacOS 的 /Library/Application Support/ElectronRuntime按版本和模块组合缓存所有已下载的 Electron 组件。每个组件拥有唯一的哈希值相同版本相同模块组合的组件只下载一次绝不重复拉取。当一个 Electron 应用首次启动时启动器会读取自身的 manifest计算出所需的组件清单然后去仓库中查找。已存在的组件直接加载不存在的组件从官方 CDN 下载并缓存。整个过程对用户透明——如果所有组件均已就绪应用秒开如果有组件需要下载显示一个轻量的进度条。这种设计让用户几乎感知不到运行时的存在就像使用系统自带的字体库一样自然。这种设计的好处显而易见安装第一个 Electron 应用时可能需要下载 50MB 的基础运行时安装第二个应用时如果其依赖的组件与第一个应用有重叠可能只需再下载 10MB 的差异部分安装第十个应用时可能什么都不用下载直接复用已有缓存。对于重度桌面应用用户而言这种渐进式共享带来的存储和带宽节省相当可观。更进一步可以引入运行时预热机制。在操作系统层面可以在用户登录后、系统空闲时预加载常用的 Electron 组件到内存通过操作系统的 prefetch 或 mmap 机制这样当用户真正启动 Electron 应用时可以跳过磁盘 I/O直接进入工作状态。这种预加载不会增加用户感知的内存占用因为系统空闲时这些内存本就闲置却能显著提升 Electron 应用的冷启动速度让用户获得秒开的体验。双轨制 WebView在保留 Chromium 的同时拥抱系统 WebViewTauri 最核心的思路是用系统 WebView但 Electron 不能完全照搬——因为 Electron 的核心价值之一就是跨平台渲染一致性如果改用系统 WebViewWindows 上的 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 堆可能膨胀到几百 MBGC 时的停顿也会影响渲染进程的响应导致界面卡顿。这是 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.jsNode.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 已经走过的脚印里。