在桌面客户端选型时,Electron 加 React 经常被拿来和原生方案比较。其实它最大的价值不是性能,而是把界面开发、状态管理、组件复用和系统能力调用放进同一套工程里。Windows、macOS 和 Linux 各自的原生 UI 体系差异巨大,如果每个平台都单独维护,迭代速度和视觉一致性会非常难控制。Electron 通过 Chromium 屏蔽了底层窗口绘制差异,React 负责渲染层,Node.js 与系统 API 负责文件、菜单、托盘、通知等本地能力。下面会按照初始化、通信、打包、优化的顺序,把一个可交付的桌面应用拆开来看。

一、初始化工程:主进程、preload 和渲染进程各司其职
Electron 应用通常拆成三层:主进程负责窗口创建和系统能力,渲染进程承载 React 界面,preload 脚本夹在两者之间做安全桥接。手动维护这套结构需要处理构建目标、路径和热更新,比较繁琐。electron-vite 把这三层统一到一个 Vite 构建流程里,开发时能分别监听主进程、preload 和渲染进程的改动,渲染进程改动会触发热更新,主进程改动则自动重启 Electron,体验接近纯前端项目。
初始化命令本身不复杂,可以直接用官方脚手架选择 React 模板。完成后目录大致会分成 src/main、src/preload 和 src/renderer 三块,主进程和 preload 最终编译到 dist-electron,渲染进程编译到 dist。这样设计的好处是构建产物天然分离,后面配置 electron-builder 打包时只需要把 dist 和 dist-electron 同时包含进去,不需要手动拼接路径。
npm create @quick-start/electron@latest my-app -- --template react cd my-app npm install npm run dev
主进程的核心任务是创建 BrowserWindow。这里要特别注意 webPreferences 的配置:contextIsolation 保持为 true,nodeIntegration 关闭,preload 指向编译后的脚本。这样渲染进程不能直接访问 Node.js,所有本地能力只能通过 preload 暴露的有限接口调用。窗口创建代码虽然看起来固定,但它决定了后续所有桌面能力的基础安全边界。
import { app, BrowserWindow } from 'electron'
import { join } from 'path'
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
if (process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
这段代码在开发环境会加载 Vite 提供的本地地址,生产环境则加载构建后的 index.html。macOS 的 activate 事件处理了点击 Dock 图标后重新创建窗口的场景,window-all-closed 里针对 darwin 平台做了特殊判断,避免关闭窗口后直接退出应用。这个平台差异是最基础的,但它说明桌面应用不能只按单平台思维编写主进程。
二、IPC 通信:把本地能力安全地交给 React
渲染进程拿不到 Node.js 模块,因此读写文件、操作系统对话框、注册全局快捷键等操作都必须放到主进程执行。preload 脚本通过 contextBridge 把主进程能力封装成 window 对象上的方法,渲染进程调用这些方法时,内部再通过 ipcRenderer.invoke 发送异步消息给主进程。相比直接开启 nodeIntegration,这种模式虽然多了一层定义,但能精确控制暴露面,避免远程加载的页面或第三方脚本拿到完整系统权限。
preload 脚本里通常只做转发,不写具体业务逻辑。返回值、错误处理、事件订阅都通过 invoke 和 on 完成。invoke 适合请求响应式操作,比如读取文件、保存配置;on 适合主进程主动推送,比如菜单点击后的动作通知。事件监听还应该返回一个清理函数,让 React 组件卸载时能移除监听,避免内存泄漏。
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('desktop', {
readFile: (path) => ipcRenderer.invoke('file:read', path),
saveFile: (path, content) => ipcRenderer.invoke('file:save', path, content),
onMenuAction: (callback) => {
const listener = (_event, action) => callback(action)
ipcRenderer.on('menu:action', listener)
return () => ipcRenderer.removeListener('menu:action', listener)
}
})
主进程用 ipcMain.handle 对应处理。这里要注意参数校验和异常捕获,不能默认渲染进程传进来的文件路径一定合法。比如用户可能通过输入框传入空路径,或者文件正在被其他进程占用。handle 内部用 try catch 把错误转成普通对象返回,React 组件拿到结果后判断 error 字段即可,主进程不会因为一次读写失败而崩溃。
import { ipcMain } from 'electron'
import { readFile, writeFile } from 'fs/promises'
ipcMain.handle('file:read', async (_event, filePath) => {
try {
const data = await readFile(filePath, 'utf-8')
return { ok: true, data }
} catch (error) {
return { ok: false, error: error.message }
}
})
ipcMain.handle('file:save', async (_event, filePath, content) => {
try {
await writeFile(filePath, content, 'utf-8')
return { ok: true }
} catch (error) {
return { ok: false, error: error.message }
}
})
React 这边的调用和普通异步函数没有区别。window.desktop 方法可以在浏览器控制台直接查看,但只在 Electron 环境存在,纯浏览器开发时要注意做环境判断。组件卸载时如果订阅了菜单事件,必须调用返回的清理函数,否则组件反复挂载会堆积多个监听器,导致一次菜单点击触发多次回调。
import { useEffect, useState } from 'react'
export default function FilePanel() {
const [content, setContent] = useState('')
useEffect(() => {
window.desktop.readFile('/tmp/demo.txt').then((result) => {
if (result.ok) {
setContent(result.data)
} else {
setContent('读取失败:' + result.error)
}
})
}, [])
return <textarea value={content} onChange={(e) => setContent(e.target.value)} />
}
三、跨平台打包与平台差异处理
electron-builder 是目前最常用的 Electron 打包工具,它可以把应用打成 Windows 的 NSIS 安装包、macOS 的 DMG 与 Linux 的 AppImage 或 deb。打包配置一般放在 package.json 的 build 字段,也可以单独写 electron-builder.yml。核心配置包括 appId、productName、输出目录、要包含的文件路径,以及三个平台各自的 target。
{
"build": {
"appId": "com.example.desktop",
"productName": "MyDesktop",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.productivity"
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
}
]
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Utility"
}
}
}
打包命令通常先执行前端和主进程构建,再调用 electron-builder。macOS 和 Windows 的包最好在各自系统上构建,虽然用 wine 可以交叉打包部分 Windows 产物,但遇到原生模块或签名时容易出问题。Linux 的 AppImage 兼容性较好,deb 则更贴合 Debian 系发行版。三个平台的安装体验并不完全相同,Windows 用户习惯向导式安装,macOS 用户习惯拖拽到 Applications,Linux 用户可能更接受 AppImage 这种免安装格式。
npm run build npx electron-builder --win npx electron-builder --mac npx electron-builder --linux
除了打包格式,菜单和快捷键也需要区分平台。macOS 的菜单固定显示在系统顶部栏,应用菜单第一项通常是应用名,Windows 和 Linux 则把菜单放在窗口顶部。accelerator 也不能写死 Cmd 或 Ctrl,应该根据 process.platform 动态生成。类似的小差异还包括托盘图标尺寸、通知样式、自启动注册路径、文件关联和单实例锁,这些都需要在主进程里用条件分支处理。
import { Menu, app } from 'electron'
const isMac = process.platform === 'darwin'
const template = [
{
label: 'File',
submenu: [
{
label: 'Open',
accelerator: isMac ? 'Cmd+O' : 'Ctrl+O',
click: () => {}
},
{ type: 'separator' },
{ label: 'Quit', role: 'quit' }
]
}
]
app.whenReady().then(() => {
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
})
macOS 上架或分发给用户前通常还需要签名和公证,否则系统会提示应用已损坏或无法打开。Windows 则需要代码签名证书来降低 SmartScreen 的拦截概率。Linux 一般没有强制签名,但某些发行版对依赖库版本有要求,所以 AppImage 打包时最好把必要的系统库一起带上。签名和公证配置可以写进 electron-builder 的 mac 和 win 字段,配合环境变量注入证书信息,不要硬编码在源码里。
四、性能优化与自动更新
Electron 应用最常被批评的就是内存占用和启动速度。一个空的 Electron 窗口也可能占用上百 MB 内存,因为它同时加载了 Chromium 和 Node.js。优化方向首先是减少窗口数量和隐藏页面的后台消耗,比如只保留主窗口,关闭不需要的 DevTools 扩展,设置 backgroundThrottling 为 true。对于 React 侧,可以使用 React.memo 避免不必要的重渲染,用动态 import 拆分首屏不需要的模块,减少渲染进程首次加载的脚本体积。
安全层面同样影响稳定性。渲染进程应该配置内容安全策略,限制脚本来源,防止 XSS 执行任意代码。主进程处理外部输入时要避免把用户内容直接拼进 shell 命令,涉及路径时优先使用 Node.js 的路径模块处理。对于需要持久化的数据,不要把敏感信息明文写进 localStorage,应该通过 IPC 交给主进程,由主进程写入用户数据目录下的文件或轻量数据库。
import { autoUpdater } from 'electron-updater'
autoUpdater.checkForUpdatesAndNotify()
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall()
})
自动更新是桌面应用交付闭环里的重要一环。electron-updater 支持从 GitHub Releases、私有服务器或对象存储拉取更新包,macOS 上可以配合公证实现平滑更新,Windows 的 NSIS 安装包也能做增量更新。更新逻辑最好放在主进程启动后延迟执行,避免阻塞首屏窗口创建。下载完成后的安装策略也分平台:macOS 可以提示用户重启,Windows 则需要在退出前等待安装器完成替换。把更新日志和版本号通过 IPC 推给 React 界面后,用户就能在设置页看到当前版本和可用更新。
最终,一套 Electron 加 React 工程如果能在初始化、通信、打包、优化四个环节都按平台差异补齐细节,就能以较低维护成本覆盖 Windows、macOS 和 Linux。跨平台并不意味着完全无差异,而是把差异收敛到主进程的少量平台判断里,把大量 UI 和业务逻辑留在同一套 React 代码中。这样的结构更适合小团队快速验证产品,也为后续接入原生模块和深度系统集成留出了空间。