1. 为什么 Electron 项目结构决定了你后面会不会重构
先说个真实经历。我以前接手过一个 Electron 项目,代码量不大,但主进程、渲染进程、预加载脚本全混在一个文件夹里,IPC 监听散落在五个不同的文件里,窗口创建逻辑和业务逻辑纠缠在一起。结果就是:每加一个功能,就要翻遍全项目找对应的 handle 在哪里注册的,每改一个 IPC 通道名,就要全局搜索三遍确认没有漏网之鱼。
这个项目运行倒是没出过大问题,但开发效率低到让人崩溃。
一个合理的 Electron 项目结构,本质上是在帮你想清楚一件事:主进程、渲染进程、预加载脚本这三者之间的边界在哪里,各自的职责归谁管。这个边界清晰了,你加功能、查 bug、做安全加固,都会顺手很多。边界糊了,短期看着代码不多,一旦功能堆到十几个窗口、几十个 IPC 事件时,你迟早要花一整天做结构迁移。
这篇文章我从项目结构的角度,把 Electron 的目录组织、进程职责划分、IPC 通信架构、Vue 等前端框架接入时的结构调整、以及打包配置对目录结构的影响都过一遍。适合刚接触 Electron 想搭一个能长期维护的项目的开发者,也适合已经写了烂项目准备动手重构的人。
2. 核心目录结构:主进程、渲染进程、预加载三权分立
2.1 最小可运行的 Electron 项目长什么样
一个没有任何框架加持的 Electron 项目,最典型的目录结构其实非常简单:
my-electron-app/ ├── package.json ├── main.js # 主进程入口 ├── preload.js # 预加载脚本 └── renderer/ ├── index.html # 渲染进程页面 └── renderer.js # 渲染进程逻辑就这么五个文件,Electron 就能跑起来。package.json里的main字段指定主进程入口文件,默认会找index.js,所以你看到很多项目里入口文件叫index.js而不是main.js,是因为没显式设置main字段,Electron 走了默认值。
这五个文件虽然少,但已经体现了 Electron 最核心的架构思想:主进程和渲染进程是完全隔离的两个世界。
2.2 主进程:负责窗口生命周期,不碰业务逻辑
主进程是整个 Electron 应用的"操作系统层"。它跑在 Node.js 环境里,能访问文件系统、能创建窗口、能调用系统级 API。但正因为它权限最大,反而最应该管得最少。
一个健康的main.js应该只做这几件事:
const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); function createMainWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, } }); win.loadFile('renderer/index.html'); } app.whenReady().then(() => { createMainWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });看到没有,这个文件里只有应用生命周期和窗口创建,没有任何业务代码。文件读取、数据库操作、系统命令调用,这些都应该通过 IPC 事件映射到独立的功能模块里,而不是直接写在这个文件里。
2.3 预加载脚本:主进程和渲染进程之间唯一的桥
预加载脚本是 Electron 项目结构里最容易被忽视、但最容易埋安全隐患的部分。
早期 Electron 版本里,开发者习惯直接开nodeIntegration: true,让渲染进程里的 JavaScript 直接使用 Node.js API。这确实省事,但也意味着:只要你的页面里加载了任何外部脚本或被注入了 XSS,攻击者就直接获得了 Node.js 权限,可以读你磁盘上的任意文件。
现在的安全最佳实践是contextIsolation: true+nodeIntegration: false+ 预加载脚本。预加载脚本运行在一个隔离的上下文里,但它能通过contextBridge安全地把主进程的某些能力暴露给渲染进程:
// preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('api', { readConfig: () => ipcRenderer.invoke('config:read'), saveConfig: (data) => ipcRenderer.invoke('config:save', data), onUpdate: (callback) => { const listener = (_event, data) => callback(data); ipcRenderer.on('update:data', listener); return () => ipcRenderer.removeListener('update:data', listener); } });这个结构的意义是:渲染进程永远拿不到ipcRenderer本身,只能拿到你暴露出来的白名单 API。代码里干净,安全上也说得过去。
2.4 渲染进程:就是浏览器页面,别把它想复杂
渲染进程本质上就是一个 Chromium 页面。它负责 UI 渲染和用户交互,该用 DOM 操作就用 DOM 操作,该用前端框架就用前端框架。它唯一特殊的地方是可以通过window.api调宿主能力。
如果你没有用任何前端框架,渲染进程目录就放 HTML、CSS、JS 文件即可。如果你用了 Vue、React、Angular,情况会有点变化,我第三大节专门讲。
3. 主进程和渲染进程的 IPC 通信结构:别等通道乱了才后悔
3.1 IPC 在项目结构里该以什么形式存在
很多初学者会把ipcMain.handle和ipcRenderer.invoke直接写在main.js和渲染进程的组件里。小项目确实没毛病,但当你有十几个通道的时候,你会发现两个痛点:
第一个是通道名不集中,查找全靠全局搜索。第二个是消息格式没有统一规范,有的地方传{ data: xx },有的地方传xx本身,导致调试的时候根本不知道数据格式是啥。
我的做法是在项目结构里单独划出 IPC 相关目录:
src/ ├── main/ │ ├── index.ts # 主进程入口 │ ├── windows/ # 窗口管理 │ └── ipc/ │ ├── index.ts # 注册所有 IPC handler │ ├── config.ts # 配置文件读写相关 handler │ ├── file.ts # 文件操作相关 handler │ └── device.ts # 蓝牙、USB 设备相关 handler ├── preload/ │ └── index.ts └── renderer/ └── (前端代码)每个 IPC 模块导出一个注册函数,统一在主进程入口里调用:
// ipc/index.ts import { ipcMain } from 'electron'; import { registerConfigHandlers } from './config'; import { registerFileHandlers } from './file'; import { registerDeviceHandlers } from './device'; export function registerAllIpcHandlers() { registerConfigHandlers(); registerFileHandlers(); registerDeviceHandlers(); }这样一来,每类 IPC 通道的归属清晰,查错的时候直接进入对应的模块文件就行。
3.2 通道命名规范和数据结构建议
我见过很多项目 IPC 通道命名很随意,比如getData、save这种。一旦主进程和渲染进程新旧版本不匹配,这种泛化名字非常容易撞车。
推荐用命名空间 + 动作的格式:模块名:动词,比如config:read、file:save、device:list。这个命名方式的好处是:看通道名你就能知道这个消息是谁发出的、要干嘛、属于哪个模块。注意流程中每次修改通道名时,主进程 handler、preload.js 暴露的方法名、渲染进程调用点都要同步改,改任何一处的语法成本高。
在数据结构上,我建议所有 IPC 消息统一格式。可能你会觉得这不是项目结构的范畴,但它直接影响代码组织方式:
interface IpcResponse<T> { success: boolean; data?: T; error?: { code: string; message: string; }; }所有 handle 里,要么返回{ success: true, data: ... },要么返回{ success: false, error: ... }。这样渲染进程在处理异步调用时,永远只需要写同一套判断逻辑,不会有"这个接口成功返回 null、另一个失败也返回 null"的混乱。
3.3 IPC 通信和 Vue 有没有关系
我的答案是:IPC 通信本身和 Vue 没有任何直接关系,但你用 Vue 时,处理 IPC 的方式会影响你的代码质量。
IPC 是 Electron 的底层机制,Vue 是 UI 框架,两者运行在不同层级。Vue 组件里不应该直接出现window.api.xxx的调用代码散落在各个组件里。我建议在 Vue 项目里把 IPC 封装成独立的 composable 或 service 层:
// renderer/src/services/fileService.ts import { api } from '@/bridge'; export const fileService = { open(path: string) { return api.invoke('file:open', path); }, save(path: string, content: string) { return api.invoke('file:save', { path, content }); } };然后 Vue 组件只管调用fileService.open(...),完全感知不到 IPC 的存在。这样做的好处是:如果哪一天你把渲染进程换成了 Electron 里的 WebView,或者你要做 Electron 向鸿蒙平台的迁移,只需要替换services层的实现,UI 层代码一行都不用动。
4. 引入 Vue 等前端框架后的项目结构调整
4.1 前后端分离的目录组织方式
当你决定用 Vue 构建渲染进程时,项目结构就不能再是五个文件了。我推荐的方式是把 Electron 主进程代码和 Vue 渲染进程代码分层管理,用不同的构建工具分别处理。
electron-vue-app/ ├── package.json ├── electron/ │ ├── main/ │ │ ├── index.ts │ │ ├── ipc/ │ │ └── windows/ │ └── preload/ │ └── index.ts ├── src/ # Vue 渲染进程源码 │ ├── main.ts │ ├── App.vue │ ├── router/ │ ├── stores/ │ └── views/ ├── vite.config.ts # 渲染进程构建配置 ├── electron-builder.yml # 打包配置 └── tsconfig.json在这个结构里,electron/目录是主进程和预加载脚本的源码,用 TypeScript 编译后输出到dist-electron/;src/目录是 Vue 渲染进程源码,通过 Vite 构建输出到dist/。开发时两个构建进程并行跑,Vite 启动 Dev Server,主进程入口把BrowserWindow的加载地址指向http://localhost:5173;生产环境则指向dist/index.html。
4.2 开发模式和生产模式切换的路径处理
这个切点特别容易出问题。主进程加载渲染页面时,开发环境要加载本地 Dev Server,生产环境要加载文件,这个切换逻辑写不好就是线上白屏:
// electron/main/index.ts const isDev = !app.isPackaged; function loadPage(win: BrowserWindow) { if (isDev) { win.loadURL('http://localhost:5173'); win.webContents.openDevTools(); } else { win.loadFile(path.join(__dirname, '../dist/index.html')); } }注意这里有个坑:loadFile的路径不能写错。我在项目里踩过,打包后__dirname指向的是app.asar/electron/main/,相对路径走错一级就会找不到dist/index.html。解决方法是把路径写死为相对项目根目录的../dist/index.html,配合 electron-builder 的files配置确认dist目录被打进了包里。
4.3 预加载脚本地址在 Vite 构建下的特殊处理
Vite 默认把入口文件打包成 ES Module,但 preload 脚本在 Electron 里的加载方式对 CJS 更友好。如果你直接把 preload 文件交给 Vite 构建,会遇到contextBridge在模块执行顺序上的各种小问题。
我的推荐方案是把 preload 单独用 Vite 的另一个入口构建,并且明确输出格式为 IIFE 或 CJS:
// vite.config.ts export default defineConfig({ build: { rollupOptions: { input: { preload: 'electron/preload/index.ts', main: 'electron/main/index.ts', }, output: { format: 'cjs', entryFileNames: '[name].js', }, }, }, });或者更简单一些,把 preload 放到单独的tsconfig和构建指令下,避免和 Vite 渲染进程的模块体系混杂在一起。我自己的项目里是把 electron 相关代码全部用tsc编译,Vue 侧用 Vite,双轨构建,互不干扰。
"scripts": { "dev:electron": "tsc -p tsconfig.electron.json && electron .", "dev:renderer": "vite", "dev": "concurrently \"npm:dev:renderer\" \"npm:dev:electron\"" }5. 目录结构的边界划分:每个文件该放哪、不该放哪
5.1 按职责分层,比按技术栈分层更好维护
很多 Electron 项目的结构不是按职责划分的,而是按文件类型划分的:一个文件夹放所有.ts文件,一个文件夹放所有样式文件。这是前端习惯的惯性,但 Electron 项目应该按职责分。
我推荐的分法是:
- main 进程侧按功能域划分:
windows/(窗口管理)、ipc/(进程通信)、services/(业务服务)、utils/(工具函数) - preload 侧保持精简:只做 API 暴露,不放业务逻辑
- 渲染进程侧沿用前端框架的组件划分:
views/、components/、stores/、services/
services/这个目录值得多说一句。主进程里的业务服务,比如文件扫描器、数据库操作、串口通信、蓝牙设备访问,都应该以 service 的形式独立成目录,然后通过 IPC handler 对外暴露。而不是让 IPC handler 里直接写业务代码。
// ipc/device.ts import { BluetoothService } from '../services/bluetooth'; const bluetooth = new BluetoothService(); export function registerDeviceHandlers() { ipcMain.handle('device:list', async () => { return bluetooth.listDevices(); }); ipcMain.handle('device:connect', async (_e, deviceId: string) => { return bluetooth.connect(deviceId); }); }这样做的好处是:IPC handler 是薄薄的一层,业务逻辑都在 services 里,可以单测,可以复用。如果你想做 Electron 应用向其他平台迁移,services 层的逻辑基本可以原样搬走,只需要重写 IPC 暴露层。
5.2 配置文件的存放位置
Electron 应用的配置文件有个特殊之处:你放在package.json同级的config/目录里的文件,打包后会被塞进app.asar,而app.asar是只读的,运行时根本改不了。
所以涉及到运行时读写配置,正确的做法是把配置文件写到系统用户数据目录下面:
import { app } from 'electron'; import path from 'path'; import fs from 'fs'; const configDir = path.join(app.getPath('userData'), 'config'); const configFile = path.join(configDir, 'settings.json'); export function readSettings() { if (!fs.existsSync(configFile)) return {}; return JSON.parse(fs.readFileSync(configFile, 'utf-8')); } export function saveSettings(settings) { if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(configFile, JSON.stringify(settings, null, 2)); }app.getPath('userData')在 Windows 上是%APPDATA%/你的应用名,在 macOS 上是~/Library/Application Support/你的应用名,在 Linux 上是~/.config/你的应用名。这个目录不仅可写,而且会随用户系统用户目录走,跨机器同步、备份都靠谱。
5.3 静态资源的组织
图片、字体、语言包这类静态资源,如果被渲染进程直接引用,应该放在渲染进程的src/assets/下,让 Vite 或 Webpack 统一处理。如果有会被主进程读取的资源(比如应用的图标、安装包相关文件),放在项目根目录的build/或resources/下,electron-builder 可以直接引用。
resources/目录还有一个特殊用途:你可以在打包配置里把整个resources/目录原样复制到安装目录里,用于存放应用运行时要动态读取但不需要打进 asar 的素材。
6. 仓库级结构:多包管理、代码共享、单元测试的摆放
6.1 Electron 项目要不要用 monorepo
如果你的 Electron 项目只是一个小工具,渲染进程用 Vue 或者 React 但代码量不大,单包结构(一个package.json)完全够用,不用额外上 monorepo 工具。
但如果你的项目里有多个独立的 Electron 应用共享一部分公共代码,比如桌面端、管理员端、用户端三个应用共用同一套 IPC 协议定义和类型声明,或者你有一个比较大的后端服务需要跟 Electron 一起管理,monorepo 会帮你省掉大量复制粘贴的活。
一个简单的 monorepo 结构长这样:
workspace/ ├── packages/ │ ├── shared/ # 共享类型定义、IPC 协议、工具函数 │ ├── desktop/ # 桌面端 Electron 应用 │ └── admin/ # 管理端 Electron 应用 ├── package.json └── pnpm-workspace.yaml共享包里的 IPC 通道名、消息类型定义、IpcResponse接口,主进程和渲染进程都能引用。改一次协议,所有端同步生效,不会出现"桌面端发的字段叫userId,管理端读的字段叫user_id"这种低级但致命的错误。
6.2 测试文件放哪
Electron 项目的测试策略要分三层:主进程逻辑测试、预加载脚本测试、渲染进程组件测试。
主进程的 services 层是纯 Node.js 逻辑,直接放在electron/services/__tests__/或electron/services同目录下加.test.ts后缀都行。重点是 services 层的设计上要能脱离 Electron 运行,不然你测试时还得启动一个完整应用。所以 services 层里尽量不要直接调用BrowserWindow、dialog这些只有在 Electron 运行时才存在的 API,如果需要,把它们作为依赖注入进去。
预加载脚本的contextBridge没法直接在 Node.js 环境里测试,我一般不做单元测试,而是靠集成测试来覆盖。渲染进程的组件测试就按前端标准来,Vitest + Vue Test Utils 那一套。
6.3 环境变量和构建配置的目录管理
Electron 项目里至少有三套环境变量:开发环境、打包测试环境、生产环境。不同环境的区别主要是 API 地址、调试开关、更新源等。
不要把这些散落在代码里。我习惯在项目根目录放.env.development、.env.production,渲染进程侧由 Vite 自动加载,主进程侧用dotenv手动加载。注意主进程的process.env.NODE_ENV在打包后是production,但在开发模式下可能是development或test,判断逻辑时不要只依赖它,最好用app.isPackaged来区分开发和生产。
7. 打包配置对项目结构的影响:目录结构决定打包清单
7.1 electron-builder 的 files 配置该怎么写
打包配置不是项目结构本身,但它对你的目录结构有很强的约束力。写files配置时,很多人的第一反应是把整个项目目录都打进去,这样最省事。但后果是打包体积大、里面的源码全暴露、asar 里混进大量垃圾文件。
一份合理的 electron-builderfiles配置长这样:
files: - dist-electron/**/*.js - dist/**/* - package.json - resources/**/*这里只打包三个东西:编译后的主进程代码、编译后的渲染进程产物、静态资源。源码、测试文件、.env这些都不应该出现在最终包里。
你可能会问,主进程代码里引用了node_modules的一些依赖,那依赖呢?electron-builder 会自动分析package.json里的dependencies和主进程代码的实际引用,把用到的依赖打进包里。但有一个坑:如果你在主进程代码里动态 require 了某个模块,比如require(packagePath)这种写法,electron-builder 的分析器可能识别不到,导致运行时找不到模块。
解决方法是把动态引入的模块显示地放进files配置里,或者用asarUnpack把包含动态引用的整个目录解包出来。
7.2 窗口图标与安装包资源目录
electron-builder 默认会在build/目录里找icon.ico(Windows)、icon.icns(macOS)和icon.png(Linux)。这个build/目录跟 electron-builder 更契合,很多项目会把资源文件放这里而不是自己另建目录。但如果你有一些自定义资源,比如默认配置文件模板、外部工具程序,放在resources/更顺手。
注意:build/目录里的内容默认不会被复制到安装目录里,它只用于构建期使用;resources/目录默认会原样复制。这个区别在你规划项目结构时要心里有数。
7.3 应用更新机制对目录的要求
如果使用了 electron-updater 做自动更新,需要额外注意:更新后的应用版本在安装后仍然运行在userData目录和安装目录这两个地方。你的项目结构里如果有什么文件是要在运行时生成的,一定不能在安装目录里写文件,装到userData下。同样,如果有一个特殊配置文件必须在安装目录下,更新后它会被覆盖,用户数据也会丢。
这类问题往往是结构层面的错位,不是你代码逻辑 bug。一开始把"程序代码"和"用户数据"分开组织,能省掉非常多这类问题。
8. 实际项目中的目录结构模板:直接抄作业
8.1 一个可直接复用的中型 Electron + Vue 项目目录
我把这些原则落到一个模板上,可以直接改改名字就用:
electron-desktop-starter/ ├── .env.development ├── .env.production ├── electron/ │ ├── main/ │ │ ├── index.ts │ │ ├── windows/ │ │ │ ├── mainWindow.ts │ │ │ └── aboutWindow.ts │ │ ├── ipc/ │ │ │ ├── index.ts │ │ │ ├── config.ts │ │ │ ├── file.ts │ │ │ └── update.ts │ │ └── services/ │ │ ├── configService.ts │ │ ├── fileService.ts │ │ └── loggerService.ts │ └── preload/ │ ├── index.ts │ └── api.ts ├── src/ │ ├── main.ts │ ├── App.vue │ ├── views/ │ │ ├── Home.vue │ │ └── Settings.vue │ ├── components/ │ ├── services/ # 渲染进程侧的 API 封装层 │ │ ├── http.ts │ │ └── electron.ts │ ├── stores/ │ ├── assets/ │ └── styles/ ├── resources/ │ └── default-config.json ├── build/ │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── electron-builder.yml ├── tsconfig.electron.json ├── tsconfig.json ├── vite.config.ts └── package.json8.2 这个模板的优势在哪
我按照这个模板搭过好几个项目,最直接的感受是:每个文件都有明确归属,新加入的同事很快就能定位到改哪个文件。
看这个结构,新功能如果涉及文件读写,路径是"渲染进程组件 →src/services/file.ts→ preload 暴露的 API →electron/main/ipc/file.ts→electron/main/services/fileService.ts"。每一层都职责单一,出了问题顺着链路往下查,一定能找到根因。
如果你自己设计结构,最核心的判断标准是:给一个需求,你能不能不用问就说出代码应该改哪些文件。如果说不出来,大概率是职责边界还没分清楚。
9. 项目结构相关的常见坑:别人踩过的,你就不用再踩了
9.1 contextIsolation 和 nodeIntegration 配置不一致
很多项目上了 Vue 之后,因为nodeIntegration: true导致 Vite 开发服务器和 Electron 两者之间的全局变量冲突,或者某些require在渲染进程里报错。结果开发一急,有人直接改成nodeIntegration: true+contextIsolation: false,用起来确实顺了,但安全性已经亮红灯了。
只要渲染进程加载了任何远程内容,恶意脚本都能顺着这个口子拿到系统权限。正确做法永远是保持contextIsolation: true,通过 preload 暴露明确的方法。
9.2 主进程代码里的 ES Module 报错
现在 TypeScript 项目都喜欢写import,如果你的主进程代码用纯tsc编译且没有设置"type": "module"或"module": "commonjs",生产环境可能会遇到require is not defined。这个问题的根源不是代码写法,而是构建配置:Electron 主进程默认按 CommonJS 解析,但dist-electron/输出的如果是 ESM,Node.js 环境就会出问题。
解决方案有两个:要么在package.json里不设"type": "module"(保持 CJS),或者把主进程的tsconfig里的module设置为CommonJS;要么直接上 Electron 28+ 对 ESM 的支持,但这时预加载脚本的处理方式又会有变化,不太建议新手这么搞。
9.3 多窗口应用的预加载脚本复用逻辑
如果你的应用有多个窗口,每个窗口最好单独指定 preload 文件或者在同一个 preload 里通过process.argv中传入的参数来区分当前是哪个窗口。不要每个窗口塞一份独立的 preload 逻辑副本,那会让 API 暴露错乱。
我在一个项目里就遇到过:设置窗口的 preload 暴露了window.api.openFileDialog,但主窗口的 preload 用的是另一个版本,没有这个方法。渲染进程里判断window.api是否存在,搞得代码里全是防御式判断。后来统一成一个 preload 工厂函数,根据process.argv中传入的窗口类型参数返回不同的 API 集合,才干净了。
9.4 vue-router 的 history 模式在 Electron 下的路径问题
Vue 渲染进程如果用createWebHistory()做路由,开发时在 Dev Server 下没问题,但打包后如果用loadFile加载index.html,刷新某个子路径时就会 404。这是因为loadFile加载的是真实的文件路径,不是服务器路径。
解决方法是把 vue-router 改成createHashHistory()。桌面应用不需要浏览器里的 SEO 和 URL 美观,hash 模式是最稳妥的。Electron 项目里千万别因为想要好看的路径而给自己挖坑。
10. Electron 应用向其他平台迁移时的结构思考
虽然今天聊的是项目结构,但一个合理结构的额外红利是:它天然为跨平台迁移留了后路。
如果将来要把应用逻辑从 Electron 迁移到其他桌面环境(比如鸿蒙桌面应用生态、原生的 WebView 壳),你会发现:renderer 层的 UI 代码几乎不用动,preload 和 IPC 这层需要重写,main 进程的 services 层根据平台进行适配。一个好的项目结构,会让迁移工作量集中在一两个目录上,而不是在整个项目里到处挖补丁。
当然,不要为了"可能的未来技术选型变化"而过度设计。我的建议是:以当前需求为主把结构理清楚,在 IPC 暴露层和 services 层之间保留清晰的边界。这就是性价比最高的迁移准备。
拿我的实际感受总结一下:项目结构这件事,不是拆成多个文件夹那么简单。它决定了一个 Electron 应用能不能长期稳定发展下去成什么样,决定了团队协作时沟通成本的高低,也决定了你半年后看到自己代码时的心情。从今天开始,花两个小时把项目结构调整到"每个文件都有明确唯一职责"的状态,这个时间投入绝对值得。