
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: truenodeIntegration: 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 IpcResponseT { 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.icoWindows、icon.icnsmacOS和icon.pngLinux。这个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: truecontextIsolation: false用起来确实顺了但安全性已经亮红灯了。只要渲染进程加载了任何远程内容恶意脚本都能顺着这个口子拿到系统权限。正确做法永远是保持contextIsolation: true通过 preload 暴露明确的方法。9.2 主进程代码里的 ES Module 报错现在 TypeScript 项目都喜欢写import如果你的主进程代码用纯tsc编译且没有设置type: module或module: commonjs生产环境可能会遇到require is not defined。这个问题的根源不是代码写法而是构建配置Electron 主进程默认按 CommonJS 解析但dist-electron/输出的如果是 ESMNode.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 应用能不能长期稳定发展下去成什么样决定了团队协作时沟通成本的高低也决定了你半年后看到自己代码时的心情。从今天开始花两个小时把项目结构调整到每个文件都有明确唯一职责的状态这个时间投入绝对值得。