我做过一个叫「哇哦.exe」的本地优先桌面应用:笔记、媒体库、旅行足迹、剪贴板历史、AI 助手都塞在一个壳里,还能按需扩展新功能。它跑在 Electron 上。这篇文章把我踩过的架构上的坑讲清楚,你可以直接照着搭一套能长期维护的骨架。
为什么是 Electron,而不是 Web 或 Tauri
先说结论:如果你的应用要重度读写本地文件、接系统 API、还要一个不丑并且好迭代的界面,Electron 仍然是投产最快、坑最少的方案。
- 纯 Web 做不到真正的本地文件系统访问、进程管理、系统托盘、开机自启。
- Tauri 包体小很多,但前端是系统 WebView,跨平台渲染差异和缺失 API 会让你在国产系统 / 老旧 Windows 上花大量时间填坑。
- Electron 自带 Chromium,渲染一致性最好,Node 生态直接可用;代价是包体积大(压缩后一个基础应用 80~120MB)。
取舍很清楚:你要的是「三个月内能上线、以后能加功能」,而不是「安装包最小」。包体积可以后面优化,架构烂了就得重写。
三进程分工:主 / preload / 渲染
Electron 的进程模型是这套架构的地基,先把它理清:
| 进程 | 职责 | 能干什么 |
|---|---|---|
| 主进程 main | 应用生命周期、窗口、原生能力 | 完整 Node / 系统 API |
| 预加载 preload | 在渲染进程里安全地暴露受限能力 | 可访问 Node,但只通过 contextBridge 白名单暴露 |
| 渲染 renderer | 界面 | 默认无 Node 权限,只能调 preload 暴露的接口 |
关键原则:渲染进程永远不要开 nodeIntegration: true。把 contextIsolation 打开,所有能力从 preload 用 contextBridge.exposeInMainWorld 白名单暴露。这样即使页面里跑了第三方内容,也拿不到你的文件系统。
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
readNotes: () => ipcRenderer.invoke('notes:read'),
saveNote: (n) => ipcRenderer.invoke('notes:save', n),
})
feature 插件架构:一个目录就是一个功能
功能一多,main.js 迟早变成两千行。我用的办法是把每个功能做成一个自包含目录,约定固定的入口文件:
features/
notes/
feature.json // 元信息:id / 名称 / 入口 / 是否需要主进程
index.js // 渲染层入口(注册 UI、路由)
main.js // 该功能的主进程逻辑(可选)
preload.js // 该功能的 IPC 暴露(可选)
style.css // 功能私有样式
media/
footprint/
主进程启动时扫描 features/*/feature.json,按需 require 每个功能的 main.js 注册 IPC handler;渲染层按 index.js 动态挂载。好处是:
- 增删功能 = 增删目录,不动核心代码;
- 功能之间通过共享的 IPC 契约通信,不互相
require; - 某个功能写崩了,主进程 try/catch 掉,其它功能照常跑。
踩坑提醒:
feature.json一定做 schema 校验,否则一个手滑的逗号会让整个应用启动白屏,且报错很难定位。
用 utilityProcess 把重活隔离出去
这是我最想强调的一点。Electron 的主进程是单线程的:你在主进程里跑一个耗时的解析 / 抓取 / 计算,整个应用的窗口就会卡住不响应。
解决办法是用 Electron 的 utilityProcess(Node 子进程的 Electron 封装)把重活扔出去:
// main.js
const { utilityProcess } = require('electron')
const engine = utilityProcess.fork(path.join(__dirname, 'services/engine.js'))
engine.on('message', (msg) => {
// 把结果广播回渲染进程
win.webContents.send('engine:result', msg)
})
// 从渲染层转来的任务,交给子进程
ipcMain.handle('engine:run', (e, task) => engine.postMessage(task))
我在这个壳里跑了三个 utilityProcess 服务:采集引擎、流程引擎、本机信息引擎。它们崩了不影响主界面,重启也很便宜。主进程只做调度,不做计算,这是 Electron 应用不卡的铁律。
IPC 契约集中管理,别让通道名满天飞
IPC 通道名(如 'notes:save')如果散落在各处字符串里,改一个名字要全局搜索,还容易拼错。我的做法是把契约集中在一个文件里,类型和常量都从那儿来:
// src/shared/ipc.ts
export const IPC = {
notes: { read: 'notes:read', save: 'notes:save' },
media: { scan: 'media:scan' },
} as const
主进程注册、preload 暴露、渲染层调用,三处都引用同一份常量。配合 TypeScript,通道名写错会在编译期就报出来。
包体积与启动速度的真实取舍
最后聊两个大家最关心、也最容易翻车的点。
包体积
- 基础 Electron 安装包 ~80MB 起,这是 Chromium 的税,省不掉;
- 用
electron-builder的asar+ 只打必要文件,能砍掉不少; - 别把整个
node_modules打进去,把运行期不需要的开发依赖排除掉,体积能差一倍。
启动速度
- 主进程里别做同步 I/O(
readFileSync读大文件)——它会让窗口白屏时间肉眼可见地变长; - 窗口先给一个轻量 loading 页,数据加载完后切真正界面;
- 插件按需加载,首屏只加载用户上次用过的功能。
「构建通过」不等于「程序能启动」。每次改完我都真跑一遍
npm run dev,看窗口有没有出来、功能能不能点,再谈交付。
这套骨架我用了很久,后来加多少个功能都没再动过核心。架构的价值不在第一天,而在第一百天。
如果你想看这套东西的实际成品,可以看我的项目页;视频制作过程在 B站搜 codevtool、抖音搜 codevtool334。