Electron 架构反模式与重构建议:从单文件主进程到分层本地服务

很多 Electron 项目的起点都是一个「能跑起来的 Demo」,但如果在这个基础上一路堆功能,很容易发展成一个「几千行的主进程大杂烩」和「到处直接用 Node 的渲染进程」。
这一篇尝试把常见的 Electron 架构反模式梳理出来,并给出对应的重构方向:如何从单文件主进程走向分层本地服务,如何收紧渲染进程的权限,以及如何整理 IPC 和状态管理。

常见表现包括:

  • main.jsmain.ts 文件动辄几千行:
    • 创建窗口;
    • 菜单/托盘定义;
    • 所有 IPC 处理;
    • 文件读写、网络请求、业务逻辑混杂在一起。
  • 新需求一来,习惯性地「继续往主进程里加」:
    • 在某个 IPC handler 里直接写业务;
    • 主进程开始负责各种「不应该由它负责」的细节。

结果是:

  • 主进程难以测试,也难以复用;
  • 一旦发生问题,很难快速定位到底是哪一块出错。

可以用类似服务分层的思路重构主进程:

  • 把主进程拆成几个模块:
    • 窗口管理模块:只负责 BrowserWindow 的创建/销毁/聚焦;
    • 系统集成模块:菜单、托盘、快捷键、通知;
    • 本地服务模块:文件系统、配置管理、任务执行等;
  • IPC handler 不直接写业务逻辑,而是简单调用对应服务模块的方法。

从结构上看,主进程更像是:

  • 一个「本地服务容器」+ 「窗口管理器」,而不是「所有逻辑的垃圾桶」。

典型配置:

  • 所有 BrowserWindow 都设置 nodeIntegration: true
  • 渲染进程代码中广泛使用:
1const fs = require("fs");
2const { exec } = require("child_process");

甚至:

  • 把数据库访问、系统脚本、内部 API 调用都直接写在渲染进程里。

这会带来几个问题:

  • 安全风险显著增大(XSS → 本地代码执行);
  • 渲染代码难以移植到 Web 环境;
  • 业务逻辑和本地能力强耦合,测试困难。

收紧渲染进程权限可以分两步走:

  • 在配置上:
    • nodeIntegration 关掉;
    • 开启 contextIsolation,引入 preload 脚本。
  • 在代码结构上:
    • 把所有直接使用 Node 的代码迁移到主进程或专门的本地服务模块;
    • 在 preload 里为这些服务暴露受控 API(通过 contextBridge),渲染进程只调用这些 API。

最终结构更接近:

  • 渲染进程:前端应用,只调用 window.xxxApi
  • 主进程 + 本地服务模块:实现这些 API 背后的实际系统操作。

在项目早期,IPC 常常是临时起名、临时使用,后来就变成:

  • 到处可见类似字符串:
1ipcMain.handle("get-config", ...)
2ipcRenderer.invoke("get-config")
3
4ipcMain.on("task-started", ...)
5ipcRenderer.send("task-started", ...)
  • 没有集中定义,难以追踪调用关系;
  • 没有统一的错误处理和超时策略。

随着功能增多,IPC 层就会像一个「任意门」:
谁想加一个通道,就随手起个名字发消息。

可以把 IPC 当成一种「本地 RPC 协议」来整理:

  • 集中定义 IPC 通道和 payload 结构:
    • 抽出一个常量/类型定义文件,定义所有 channel 名和参数/返回类型;
    • 避免在代码中散写字符串。
  • 封装一层「客户端 SDK」:
    • 在 preload 中为每类操作提供一个函数,例如 configApi.read()taskApi.start()
    • 内部统一使用 ipcRenderer.invoke / ipcRenderer.send,并统一处理错误/超时。
  • 在主进程中:
    • 把 IPC handler 按领域组织,分别挂在对应服务模块上;
    • 统一处理异常日志与返回格式。

重构完成后,IPC 层更像是:

  • 一组明确的「本地服务接口」,而不是一堆自由发挥的消息字符串。

常见表现包括:

  • 主进程里用全局变量记录窗口、当前用户、配置等;
  • 渲染进程中既有本地 state,又频繁通过 IPC 读写共享状态;
  • 不同窗口各自维护一份类似状态,却没有清晰的同步机制。

结果是:

  • 状态流向不清晰,各种「明明已经更新了,另一个地方却还是旧值」的问题频发;
  • 调试时需要在主/渲染多个进程之间来回打印。

在 Electron 应用中,状态可以按「范围」做粗粒度划分:

  • 全局状态(与用户/会话关联):
    • 放在主进程或后端服务中管理;
    • 渲染进程通过 API 获取和修改。
  • 窗口级状态:
    • 放在各自渲染进程内部(前端状态管理库负责);
    • 通过 IPC 通知主进程重要变化(例如某些需要跨窗口同步的信息)。
  • 持久化状态(配置、缓存):
    • 由本地服务模块负责读写;
    • 提供「获取一次 + 订阅变更」之类的接口。

重构过程中,可以:

  • 先列出所有全局变量和跨进程共享状态;
  • 决定它们应该归属哪一层(主进程、本地服务、后端服务、窗口内部);
  • 为每一类状态设计清晰的读写 API 和事件流。

有些项目在一开始就把所有后端能力都塞进 Electron:

  • 所有业务逻辑、数据存储、第三方集成都写在主进程/渲染进程里;
  • 没有独立的服务端或云端 API,应用逻辑无法在桌面之外重用。

这在单机工具类应用里短期可行,但在以下场景中会遇到瓶颈:

  • 需要多用户协作;
  • 需要对接更多第三方服务;
  • 需要同时支持 Web、移动端等其他客户端。

在更大范围的系统里,更合理的分工通常是:

  • 独立的后端服务:
    • 负责多用户、持久化、鉴权、审计、复杂业务规则;
    • 提供 HTTP/WebSocket 等接口。
  • Electron:
    • 作为桌面壳,提供本地增强(文件系统、本地缓存、原生集成);
    • 同时调用「远端后端 + 本地服务」完成任务。

这种分层可以让:

  • 桌面能力和通用业务逻辑解耦;
  • 即便未来需要扩展 Web 客户端,也不必重写主要业务逻辑。

可以用几条总结这一篇的核心要点:

  • 主进程应从「大杂烩」重构为「窗口管理 + 本地服务容器」;
  • 渲染进程尽量像普通前端应用,通过受控 API 访问本地能力,而不是直接开启 Node;
  • IPC 应当被视作一种本地 RPC 协议,集中定义通道和数据结构,并封装成清晰的客户端接口;
  • 状态管理要按范围划分归属,避免全局变量横飞和跨进程随意读写;
  • Electron 更适合作为桌面壳和本地增强层,而不是承载整个后端世界。

沿着这些方向逐步重构,Electron 项目可以从「杂乱但能跑」走向「可维护、可扩展、易于协作」,也更容易和现有的前后端架构对接起来。