
Electron 这个坑我是踩了不少才爬出来的。去年团队要做一款跨平台的桌面客户端技术选型绕来绕去最后还是落在 Electron React TypeScript 这套组合上。当时觉得简单不就是套个壳子吗真正把项目做深才发现主进程、渲染进程、预加载脚本之间的边界关系IPC 通信的时序问题TypeScript 类型怎么在三个进程之间共享坑远比想象中多。这篇先聊“上篇”核心原理和项目融合。我会从 Electron 的进程模型讲起把“为什么需要 preload”“为什么 IPC 要用 invoke/handle”这些问题掰开揉碎讲清楚再带你把工程骨架搭起来让 React 在 Electron 里跑通。如果你正准备做桌面应用或者已经上手 Electron 但总感觉稀里糊涂这篇应该能帮你把很多概念一次性理顺。1. 为什么我建议前端团队认真考虑 Electron React TS 这套组合先说结论Electron 是目前把 Web 技术栈搬到桌面端最成熟的一条路React 负责 UI 层的组件化TypeScript 负责把主进程和渲染进程之间“看不见的接口”变成可约束的类型。三者拼在一起产生了一种非常舒服的开发体验——你不是在“写一个桌面应用”而是用一套工程化体系同时管理 Node 后端逻辑和前端界面。1.1 Electron 解决什么问题前端开发者在日常工作中经常遇到一个尴尬Web 应用做得很顺手但一碰到文件系统、系统通知、托盘图标、开机自启这类能力浏览器就有心无力。Electron 把 Chromium 和 Node.js 打包进同一个运行时让你可以用 HTML CSS JavaScript 写界面同时通过 Node 层调用操作系统的原生能力。这意味着团队不需要额外养一套原生开发人员前端同学就能交付桌面客户端。很多知名工具都是这么干的文本编辑器、团队协作软件、API 调试工具桌面端几乎被 Electron 占了大半。它可能不是性能最优解但绝对是投入产出比最高的方案。如果项目需要快速迭代、跨平台发布Electron 的社区生态和成熟度目前没有对手。1.2 React 和 TypeScript 在这里扮演什么角色Electron 本身并不关心你用什么框架你用原生 JS 也能写但一旦界面复杂起来状态同步和组件复用就会失控。React 的价值在于把界面拆成组件树配合 Hooks 处理状态逻辑开发效率和可维护性都比较稳。TypeScript 的作用更大。Electron 的主进程和渲染进程是两个独立世界它们靠 IPCInter-Process Communication进程间通信传递消息。如果你用普通 JavaScript主进程暴露了什么 API、渲染进程能调用什么方法全靠记忆和文档改一个方法名另一头报错要到运行时才发现。用 TypeScript 后你可以把 preload 暴露的 API 定义成共享类型渲染进程里一调用就有类型提示参数传错了编译期就报错。这种“跨进程类型约束”是把 Electron 从“玩具”变成“工程化项目”的关键。1.3 为什么不选 Tauri 或原生开发现在 Tauri 很火体积小、性能好但它的安全模型和 IPC 设计跟 Electron 有本质差异而且 Rust 学习成本摆在那里团队没有 Rust 背景的话推进会很慢。原生开发比如用 C 写 Qt就更不用说了跨平台适配的工作量足够让人头疼。每个方案都有适用场景但如果你团队的技术底子是 JavaScriptElectron 依然是最短路径。这篇文章后续所有内容都围绕这套组合展开。2. 核心原理Electron 的双进程模型必须先搞懂很多 Electron 新手写出来的项目问题不断根本原因是没理解进程模型。Electron 启动后至少会创建两类进程主进程Main Process和渲染进程Renderer Process。它们各干各的又需要通过 IPC 互相通信。2.1 主进程应用的“大脑”主进程是 Electron 应用的入口运行在 Node.js 环境中。它负责创建应用窗口、管理应用生命周期、调用系统原生能力比如弹系统通知、访问文件系统、处理托盘菜单。每个 Electron 应用都只有一个主进程。你可以把主进程想象成餐厅后厨的负责人客人点什么菜、哪桌要加水、厨房什么时候下班都由后厨统一调度。UI 界面本身不直接管这些事儿它只负责把需求报给后厨后厨干完了再把结果传回来。主进程代码通常写在入口文件里通过app模块控制生命周期通过BrowserWindow创建窗口。这些操作在渲染进程里是做不到的因为渲染进程运行在浏览器环境没有这些 Node 能力。2.2 渲染进程界面的“门面”渲染进程就是 Electron 窗口里加载的网页内容。在浏览器环境下运行的是 HTML、CSS、JavaScript只能操作 DOM默认没有访问 Node.js API 的能力。一个 Electron 应用可以创建多个窗口每个窗口就是一个独立的渲染进程。渲染进程就像餐厅的餐桌。客人坐在餐桌前点菜餐桌本身不会去做菜也不该直接冲进后厨翻冰箱。你写的 React 组件、路由、状态管理通通跑在渲染进程里。渲染进程需要什么数据、要做什么操作必须通过“点菜”的方式告诉主进程。2.3 preload 脚本连接两个世界的桥梁这里有个关键问题渲染进程和主进程完全隔离怎么通信答案是 preload 脚本。preload 运行在一个特殊环境里——它在页面加载前执行既能访问一部分 Node.js API又能通过contextBridge把安全的 API 暴露给渲染进程的window对象。依然沿用餐厅的类比preload 就是菜单。菜单把后厨能做的菜列出来客人渲染进程照着菜单点就行不需要知道后厨的灶台怎么操作、油烟机怎么开。菜单上写什么菜客人就只能点什么菜这就叫“受控暴露”。新版 Electron 默认开启了contextIsolation上下文隔离渲染进程的页面上下文和 preload 的上下文是隔离的所以你不能在 preload 里直接把 Node 对象塞给 window必须用contextBridge.exposeInMainWorld这个方法。后面第 7 节会给出具体代码。2.4 IPC 通信进程间协作的通道主进程和渲染进程之间的通信全部通过 IPC 完成。Electron 提供了ipcMain和ipcRenderer两个模块。早年常用send和on这种“单向发消息”模式现在更推荐用ipcMain.handleipcRenderer.invoke这种“请求-响应”模式因为它是异步 Promise 风格代码可读性和错误处理都好很多。IPC 消息本质上走的是结构化克隆Structured Clone传递对象时会做序列化和反序列化。这意味着你传递的参数不能是函数、不能是类实例只能是可序列化的普通对象。我见过有人试图在 IPC 里传一个 Date 对象结果对端拿到的是字符串这类问题排查起来很容易让人抓狂。后面会专门讲。3. 技术选型背后的取舍React、TypeScript 与构建工具原理讲完来说选型。很多初学者直接上来就是npm init一把梭装一堆包最后项目结构一团糟。技术选型不是选最流行的而是选跟 Electron 的进程模型能自然融合的。3.1 React 版本与状态方案的选择React 我建议直接上 18 以上版本函数组件 Hooks不要再写类组件。Electron 项目通常不需要考虑浏览器兼容性因为 Chromium 版本是 Electron 自带的这反而让 React 新特性用得很爽比如并发渲染能力在某些复杂界面上真的有感知提升。状态管理方面如果你的应用不算特别复杂完全可以用 Zustand 或者 React 内置的useReducer Context。很多人一上来就上 Redux结果项目只有两三个页面Redux 样板代码比业务代码还多纯属自讨苦吃。Electron 项目里跨窗口状态同步通常走主进程状态库只管单窗口内部的 UI 状态就够了。3.2 为什么用 TypeScript 而不是纯 JavaScript讲个实际经历我之前维护过一段纯 JS 的 Electron 项目主进程暴露了一个writeFile方法渲染进程调用时少传了一个参数当时完全没有报错只在运行日志里偶然发现写入的文件是空的。后来给这个项目补 TypeScript 类型光是把 IPC 的调用链理一遍就揪出七八处类似的隐患。TypeScript 在这里不只是“加类型”它提供了一个跨进程的“契约层”。你可以把 IPC 方法的入参和返回值定义成 interface主进程、preload、渲染进程共用同一份类型文件。主进程改了接口签名渲染进程编译直接报错不用等运行时才发现。这种体验对工程化项目来说太重要了。3.3 构建工具为什么我选 electron-vite 而不是手动配置现在创建 Electron React TS 项目主流方式有三种手动搭 Webpack、用 Vite 配合 vite-plugin-electron、直接用 electron-vite 脚手架。强烈建议不要手动配 Webpack配置项多到怀疑人生而且主进程和渲染进程需要两套配置维护成本极高。electron-vite 是目前我认为的最优解。它基于 Vite天然支持 HMR热更新——渲染进程的改动秒级刷新主进程和 preload 的改动也会自动重启应用。它把主进程、preload、渲染进程三部分的构建配置内置好默认输出到out/目录开箱即用。Vite 启动速度快的原因也很好理解开发模式下它不需要打包整个应用只启动一个原生 ESM 的 dev server浏览器按需加载模块。对比 Webpack 的“先全量打包再启动”Vite 在大型项目上的体验完全是另一个档次。构建工具开发启动速度配置复杂度主进程/渲染进程统一构建推荐程度手动 Webpack慢配置繁琐高需要自己拼不推荐vite-plugin-electron快中需要额外配置 preload推荐electron-vite极快低开箱即用首选4. 工程化骨架搭建从零初始化一个 Electron React TS 项目理论说了这么多下面开始动手搭骨架。我用 electron-vite 来建每一步都会说明“为什么这么做”方便你以后迁移到其他项目时能举一反三。4.1 创建项目先确保本机装了 Node.js 18 以上版本。然后执行npm create quick-start/electronlatest my-electron-app这个命令会进入交互式界面选择框架时选React语言选TypeScript。它会生成一个标准的 electron-vite 项目结构my-electron-app/ ├── src/ │ ├── main/ # 主进程代码 │ │ └── index.ts │ ├── preload/ # 预加载脚本 │ │ └── index.ts │ └── renderer/ # 渲染进程React │ ├── index.html │ └── src/ │ ├── App.tsx │ └── main.tsx ├── electron.vite.config.ts ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.web.json └── package.json这个目录结构跟 Electron 的进程模型一一对应src/main对应主进程src/preload对应预加载层src/renderer对应渲染进程。这样的分层是刻意设计的它强制你在写代码时区分“哪些能力该放在哪一层”。4.2 配置文件解读electron.vite.config.ts是构建核心。默认配置类似这样import { defineConfig } from electron-vite import react from vitejs/plugin-react export default defineConfig({ main: {}, preload: {}, renderer: { plugins: [react()] } })这个配置的巧妙之处在于main、preload、renderer分别对应三条构建链路。渲染进程用 Vite 插件系统React 插件就在这里加载主进程和 preload 则走 Node 构建逻辑。开发模式下electron-vite 会自动处理“先启动 Vite dev server再用 Electron 加载它”的流程你不用手动跑两个命令。4.3 核心依赖说明{ main: ./out/main/index.js, scripts: { dev: electron-vite dev, build: electron-vite build, preview: electron-vite preview }, dependencies: { electron-toolkit/preload: ^3.0.0, react: ^18.3.0, react-dom: ^18.3.0 }, devDependencies: { electron: ^31.0.0, electron-vite: ^2.3.0, typescript: ^5.4.0, vite: ^5.4.0 } }注意package.json里的main字段指向./out/main/index.js。这个字段很关键Electron 启动时看的就是它它必须指向构建后的主进程入口文件而不是源码文件。提示模板默认带了electron-toolkit/preload这个包提供了一些预加载层的工具函数但并不是必需依赖。我建议初期先不用它自己手写 preload 的 API有利于理解原理。4.4 目录职责划分主进程目录里我会把窗口创建、IPC 注册、生命周期逻辑拆成不同模块。渲染进程则按功能模块分目录。原则很简单主进程不要写 UI 相关代码渲染进程不要直接调用 Node API除非通过 preload 暴露的窗口对象。这个边界从第一天就守住项目才不会随着规模变大而失控。5. 项目融合的关键一步让 React 在 Electron 里正常跑起来骨架搭好之后就要让三部分协同工作。初学者最常见的理解误区是把 React 和 Electron 当成两个分开的部分——一个跑浏览器一个跑桌面壳子。实际上它们是同一个应用的一体两面。5.1 开发模式下窗口加载的是什么开发环境下Vite dev server 启动后地址通常是http://localhost:5173。主进程创建窗口时应该加载这个地址而不是本地文件。electron-vite 会通过环境变量ELECTRON_RENDERER_URL把 dev server 地址传给主进程。主进程代码通常是这样的function createWindow(): void { const mainWindow new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false } }) if (process.env.ELECTRON_RENDERER_URL) { // 开发模式加载 Vite dev server mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) } else { // 生产模式加载构建后的 HTML 文件 mainWindow.loadFile(join(__dirname, ../renderer/index.html)) } }注意process.env.ELECTRON_RENDERER_URL这个判断。写成这种分支而不是写死在loadURL里是为了让同一份代码在开发和生产两种模式下都能工作。这个模式贯穿 Electron 应用开发始终理解了它你就理解了大部分 Electron 壳子项目的核心。5.2 生产模式下为什么加载 HTML 文件而不是 URL生产模式下渲染进程已经打包成了静态文件存放在out/renderer/index.html。这时候加载本地文件是最高效的——不需要网络请求也不会因为没启动 dev server 而白屏。这里有个细节loadFile内部会自动处理相对路径但如果你在渲染进程里用了BrowserRouter直接刷新子路由页面可能会找不到资源。建议用HashRouter或者对生产环境的路径做额外处理这是很多项目上线后踩坑的高发区。5.3 React 应用挂载和普通 Web 项目有什么不同本质上没有不同。React 的入口依然是import React from react import ReactDOM from react-dom/client import App from ./App ReactDOM.createRoot(document.getElementById(root)!).render( React.StrictMode App / /React.StrictMode )区别在于Electron 渲染进程里window对象会多出一些 preload 暴露的 API。后续调用这些 API 时感知会非常顺滑——你甚至感觉不到自己是在写桌面应用。5.4 主进程代码修改后能不能热更新Electron 里“热更新”分两种渲染进程的 React 页面有 HMR改了代码浏览器立刻刷新主进程和 preload 的代码没有 HMRelectron-vite 会检测到变化并自动重启整个 Electron 应用重启后窗口重新打开加载最新的主进程代码。这在实际开发中很爽但也有一个反直觉的点如果窗口里有未保存的表单数据主进程一重启所有状态就丢了。所以涉及主进程逻辑的改动我会尽量集中在一次修改里减少过程中反复重启带来的打断感。6. 主进程实战窗口管理、生命周期与安全配置主进程是与操作系统直接打交道的层掌握好这一层应用才真正有“桌面客户端”的样子。6.1 创建 BrowserWindow 的参数详解BrowserWindow构造函数接收一个配置对象参数很多但最常用的就这几个new BrowserWindow({ width: 1200, height: 800, minWidth: 900, minHeight: 600, show: false, // 先不显示避免白屏闪烁 autoHideMenuBar: true, // 隐藏默认菜单栏 webPreferences: { preload: join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, sandbox: true } })show: false的用途容易被忽略。如果窗口一创建就显示用户会看到一片空白等资源加载完才渲染内容。正确做法是先隐藏等到渲染进程完成页面渲染、触发ready-to-show事件后再显示mainWindow.on(ready-to-show, () { mainWindow.show() })这一小改动对用户体验的提升非常明显几乎没有成本但很多人就是不知道。另一个关键参数是sandbox: true。从 Electron 20 开始渲染进程默认开启沙箱sandbox 开启后preload 脚本里能访问的 API 会被进一步限制只能使用contextBridge和有限的ipcRenderer方法。这能有效防止渲染进程被 XSS 攻击后直接拿到系统权限。6.2 应用生命周期管理Electron 生命周期有几个关键事件ready、window-all-closed、activate。基本套路是import { app, BrowserWindow } from electron app.whenReady().then(() { createWindow() app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow() }) }) app.on(window-all-closed, () { if (process.platform ! darwin) app.quit() })解释一下某些系统上应用关闭所有窗口后通常还会驻留在 Dock 栏点图标又重新创建窗口所以activate事件里要判断如果当前没有窗口就重建。其他平台关闭所有窗口就等于退出应用直接app.quit()。这套写法是从官方模板带出来的几乎是桌面应用的标准范式。6.3 主进程全局异常处理主进程是 Node 环境代码里一旦抛出未捕获的异常整个应用可能直接崩溃。建议在一开始就挂上全局兜底process.on(uncaughtException, (error) { console.error(Uncaught Exception:, error) }) process.on(unhandledRejection, (reason) { console.error(Unhandled Rejection:, reason) })不要小看这几行代码。Electron 应用发布后你拿不到用户终端里的报错日志没有全局兜底的话用户只会告诉你“软件闪退了”而你什么线索都没有。7. 预处理脚本实战用 contextBridge 打通安全 IPCpreload 是 Electron 安全和工程化的核心枢纽。通过它渲染进程不用直接碰 Node就能调用主进程的能力。7.1 暴露 API 的标准写法在 preload 里用contextBridge.exposeInMainWorld把方法挂到window上import { contextBridge, ipcRenderer } from electron const api { // 窗口控制 minimizeWindow: () ipcRenderer.send(window:minimize), toggleMaximizeWindow: () ipcRenderer.send(window:toggle-maximize), closeWindow: () ipcRenderer.send(window:close), // 读取系统信息使用 invoke 获取异步返回值 getAppVersion: () ipcRenderer.invoke(app:get-version), // 通用事件监听 onUpdateAvailable: (callback: () void) { const handler () callback() ipcRenderer.on(update:available, handler) return () ipcRenderer.removeListener(update:available, handler) } } contextBridge.exposeInMainWorld(electronAPI, api)这里有一个非常实用的模式onUpdateAvailable返回一个取消订阅函数。这是因为渲染进程里如果重复监听同一个 IPC 事件不清理的话会不断累积监听器导致回调被触发多次。我在实际项目里就踩过这个坑页面切来切去事件触发次数翻倍增长最后排查了半天发现是 React 重渲染时重新订阅了事件旧监听器没清掉。7.2 渲染进程的类型声明没有类型声明的window.electronAPI是any用 TypeScript 就失去了意义。electron-vite 模板里会有一个.d.ts文件手动补一下export interface ElectronAPI { minimizeWindow: () void toggleMaximizeWindow: () void closeWindow: () void getAppVersion: () Promisestring onUpdateAvailable: (callback: () void) () void } declare global { interface Window { electronAPI: ElectronAPI } }这个是“项目融合”里最具含金量的一步。主进程注册什么 IPC、preload 暴露什么 API、渲染进程拿到什么类型三者全部统一。以后主进程改接口渲染进程编译期就报错不用再靠接口文档维护。如果你的项目更复杂可以把 IPC 请求和响应类型单独抽到一个src/shared/types.ts里让main、preload、renderer三个目录都引用它。7.3 基于 preload 的完整流程示例举一个“获取默认下载路径并选择文件”的场景。主进程里注册一个 handlerimport { ipcMain, dialog, app } from electron ipcMain.handle(dialog:select-directory, async () { const result await dialog.showOpenDialog({ properties: [openDirectory], defaultPath: app.getPath(downloads) }) return result.canceled ? null : result.filePaths[0] })preload 里暴露方法const api { selectDirectory: () ipcRenderer.invoke(dialog:select-directory) } contextBridge.exposeInMainWorld(electronAPI, api)渲染进程的 React 组件里调用async function handleSelectDirectory() { const path await window.electronAPI.selectDirectory() if (path) { // 更新状态 } }这是一整条干净链路渲染进程不关心 dialog 怎么弹、系统路径怎么取主进程也不关心 UI 长什么样。两边通过 preload 这个“契约层”协作代码边界清晰团队协作时互不干扰。7.4 安全模型为什么我坚持不开 nodeIntegrationnodeIntegration: false和contextIsolation: true这两个配置对安全至关重要不要随便改成 true。开启nodeIntegration等于直接把 Node.js 的全部能力暴露给渲染进程只要页面任何一个环节出现 XSS 漏洞攻击者就能直接读写用户文件、执行系统命令。Electron 官方在安全文档里都强调要关掉它但很多老项目因为历史原因改不回来。我见过一些项目为了图省事开启 nodeIntegration然后在渲染进程里直接require(fs)读写文件。确实方便但这是拿安全换便利非常得不偿失。preload contextBridge 的模式没有多写多少代码安全边界却正确得多。8. 常见开发问题与排查经验这部分是我在实际开发中反复踩坑后整理的速查表每条都有血泪教训。建议收藏。8.1 preload 脚本里用不了 ESMElectron 对 preload 的模块格式有一定要求。如果你在 preload 里直接写import语法一些版本下会报错。最佳实践是preload 用 CommonJS 风格的require或者让 electron-vite 负责把 preload 构建成 CJS 格式。总之在 preload 里不要直接依赖原生 ESM 加载否则发布后就会出现“找不到接口”的诡异问题。8.2 窗口白屏但页面没报错最常见的原因有三个生产模式下加载路径不对、preload 脚本路径不对、渲染进程抛了未处理异常。排查时先打开开发者工具看控制台报错再把loadFile的路径打出来确认一下。另一个隐蔽原因是ready-to-show事件没触发窗口一直保持隐藏状态看起来像白屏。8.3 IPC 参数序列化问题IPC 传参走结构化克隆函数、原型链对象、DOM 节点全都会丢失。临时的解决办法是传普通对象必要时用JSON.parse(JSON.stringify(data))做一次深拷贝。但更建议在类型定义层面就约束好参数只能是可序列化对象从根上规避。8.4 主进程窗口变量被垃圾回收一个经典错误写法app.whenReady().then(() { const win new BrowserWindow({ /* ... */ }) })这里win是局部变量应用加载完成后函数结束窗口对象可能被垃圾回收。正确做法是把窗口引用存在模块级变量里。我一开始也犯过这个错表现是应用启动后窗口一闪而过莫名奇妙。问题现象排查方向窗口白屏打开后空白检查 loadURL/loadFile 路径、控制台报错preload 未生效window.electronAPI是 undefined检查 preload 路径、CJS格式、contextIsolationIPC 回调重复触发事件监听的次数越来越多检查监听器是否清理、是否使用了监听器累加主进程窗口闪退窗口创建后立刻消失检查窗口变量是否被垃圾回收渲染进程控制台报模块找不到页面报错无法加载检查 Vite 配置、base 路径8.5 经验补充从第一行代码就要有“分层”意识最后说一个贯穿全局的心得开发 Electron 应用所有的复杂度几乎都来自“跨进程通信”和“环境差异”而不是 UI 本身。从项目一开始就守住三层边界后续的调试成本会低一个量级。我在实际项目中还发现一个提升效率的小技巧渲染进程里不要太早把所有状态放到一个全局 store 里先把界面逻辑跑通再根据拆分的 IPC 接口逐步接入主进程能力。这样调试时能清晰分辨问题出在 UI 层还是主进程层不会混在一起。这套 Electron React TS 的组合做内部工具、中小型跨平台客户端属于目前综合成本最低的方案。等到把主进程、preload、渲染进程的关系理顺你会发现桌面应用和 Web 应用的开发体验没有想象中那么大的鸿沟——多出来的复杂度几乎都集中在“安全边界”和“进程协作”这两件事上。后续我打算写“下篇”把多窗口管理、主进程任务调度、自动更新和打包分发这几个模块补上那部分是真正决定 Electron 项目能否落地的关键。如果你正卡在某个 Electron 问题上欢迎在评论区把现象和代码贴出来我会尽量回复。如果你在实践中有自己总结的坑也欢迎分享咱们一起把这套组合玩得更透。