我做过一个叫「哇哦.exe」的本地优先桌面应用:笔记、媒体库、旅行足迹、剪贴板历史、AI 助手都塞在一个壳里,还能按需扩展新功能。它跑在 Electron 上。这篇文章把我踩过的架构上的坑讲清楚,你可以直接照着搭一套能长期维护的骨架。

为什么是 Electron,而不是 Web 或 Tauri

先说结论:如果你的应用要重度读写本地文件、接系统 API、还要一个不丑并且好迭代的界面,Electron 仍然是投产最快、坑最少的方案。

取舍很清楚:你要的是「三个月内能上线、以后能加功能」,而不是「安装包最小」。包体积可以后面优化,架构烂了就得重写。

三进程分工:主 / 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 动态挂载。好处是:

踩坑提醒: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,通道名写错会在编译期就报出来。

包体积与启动速度的真实取舍

最后聊两个大家最关心、也最容易翻车的点。

包体积

启动速度

「构建通过」不等于「程序能启动」。每次改完我都真跑一遍 npm run dev,看窗口有没有出来、功能能不能点,再谈交付。


这套骨架我用了很久,后来加多少个功能都没再动过核心。架构的价值不在第一天,而在第一百天。

如果你想看这套东西的实际成品,可以看我的项目页;视频制作过程在 B站搜 codevtool、抖音搜 codevtool334。