Electron 项目目录怎么组织?
Electron 目录没有官方推荐,但 三层物理隔离 + main 内按职责拆分 是经得起长期维护的姿势。
- 三层分离:
src/main(Node 全权限)、src/preload(白名单桥)、src/renderer(沙箱 UI),别混 - 入口最小化:
main/index.ts只做生命周期组装,业务全部下沉到windows / ipc / services - IPC 按域拆:每个域(fs / dialog / store)一个 handler 文件,channel 名集中在
channels.ts常量 - Service 层下沉:数据库、文件、网络都封成 service,handler 只做参数转发
- 窗口工厂:每种窗口一个工厂函数,统一管创建、状态恢复、生命周期
- 共享类型:main / preload / renderer 之间的 interface 抽到
src/shared,杜绝重复定义
Electron 项目最容易烂在目录上——main/index.ts 写成一个 2000 行的"上帝文件",窗口创建、菜单、托盘、IPC handler、数据库初始化全堆一起。三个月后没人敢动。
这篇文章讲的是 main 进程文件怎么拆,从目录骨架到每个目录的职责边界。
一、三层物理隔离是底线
Electron 的进程模型决定了三类代码绝不能混:
| 目录 | 运行时 | 能用什么 | 不能用什么 |
|---|---|---|---|
src/main/ | Node.js | 全部 Node API、Electron API、文件系统 | 不能跑 DOM 代码 |
src/preload/ | Node + 受限 DOM | ipcRenderer、contextBridge | 不能直接 require('fs') 给 renderer 用 |
src/renderer/ | Chromium | DOM、React/Vue、Web API | 不能 require('electron') |
物理隔离的目的不是"代码美观",是让构建工具、TypeScript 配置、依赖白名单都能按目录区分:
// tsconfig.json 按目录区分
{
"compilerOptions": {
"paths": {
"@main/*": ["src/main/*"],
"@preload/*": ["src/preload/*"],
"@renderer/*": ["src/renderer/*"],
"@shared/*": ["src/shared/*"]
}
}
}
main / preload 编译目标是 Electron 的 Node ABI,renderer 编译目标是浏览器——分开配置才不会乱。
二、推荐的目录骨架
src/
├── main/ # 主进程
│ ├── index.ts # 入口(只做组装)
│ ├── app.ts # app 生命周期封装
│ ├── windows/ # 窗口管理
│ │ ├── index.ts # 窗口注册中心
│ │ ├── main-window.ts # 主窗口工厂
│ │ └── settings-window.ts
│ ├── ipc/ # IPC handler 注册
│ │ ├── index.ts # bootstrap 时统一 register
│ │ ├── handlers/ # 按域拆分
│ │ │ ├── fs.ts
│ │ │ ├── dialog.ts
│ │ │ └── store.ts
│ │ └── channels.ts # channel 名常量
│ ├── services/ # 业务逻辑
│ │ ├── store.ts # electron-store 封装
│ │ ├── database.ts # better-sqlite3 封装
│ │ └── logger.ts
│ ├── menu/ # 应用菜单
│ ├── tray/ # 系统托盘
│ ├── utils/ # 工具函数
│ └── config/ # 配置常量
├── preload/ # preload 脚本
│ ├── index.ts
│ └── api/
│ ├── fs.ts
│ └── store.ts
├── renderer/ # 渲染进程(前端代码)
└── shared/ # 跨进程共享
├── types.ts # TypeScript interface
└── ipc-channels.ts # channel 名字(main + preload 共用)
下面分块讲每个目录的职责和怎么用。
三、入口最小化:index.ts 只做组装
main 进程的 index.ts 只做三件事:初始化 services、注册 IPC handlers、创建窗口。不要在入口里写业务逻辑。
// src/main/index.ts
import { app } from 'electron';
import { initServices } from './services';
import { registerIpcHandlers } from './ipc';
import { createMainWindow } from './windows';
app.whenReady().then(async () => {
await initServices(); // 1. 初始化所有 service
registerIpcHandlers(); // 2. 注册 IPC handlers
createMainWindow(); // 3. 创建窗口
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
入口文件理想控制在 50 行以内。超过这个数,说明业务没下沉。
四、窗口工厂:一种窗口一个文件
窗口创建逻辑别堆在入口里。每种窗口一个工厂函数,参数化窗口配置:
// src/windows/main-window.ts
import { BrowserWindow } from 'electron';
import path from 'node:path';
export function createMainWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
win.loadFile('index.html');
win.on('closed', () => {/* 清理逻辑 */});
return win;
}
// src/windows/settings-window.ts
export function createSettingsWindow(parent: BrowserWindow) {
const win = new BrowserWindow({
width: 600,
height: 400,
parent, // 设置窗口总是悬浮在主窗口上
modal: false,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
},
});
win.loadFile('settings.html');
return win;
}
windows/index.ts 当注册中心,统一暴露工厂:
// src/windows/index.ts
export { createMainWindow } from './main-window';
export { createSettingsWindow } from './settings-window';
窗口数量一多(主窗口 + 设置窗口 + 关于窗口 + ...),工厂模式比"在
index.ts里if (type === 'main')"清爽得多。
五、IPC 按域拆,channel 名集中
IPC handler 是 main 进程最大的代码来源。按业务域拆文件,每个域一个文件:
src/main/ipc/handlers/
├── fs.ts # 文件读写
├── dialog.ts # 系统对话框
├── store.ts # electron-store 操作
└── app.ts # app 级别操作(重启、退出)
每个 handler 文件只暴露一个注册函数:
// src/main/ipc/handlers/fs.ts
import { ipcMain } from 'electron';
import { promises as fs } from 'node:fs';
import { IPC } from '@shared/ipc-channels';
import { FileService } from '../../services/file-service';
export function registerFsHandlers() {
ipcMain.handle(IPC.FS_READ, async (_e, path: string) => {
return FileService.read(path); // handler 只转发,不写业务
});
ipcMain.handle(IPC.FS_WRITE, async (_e, path: string, content: string) => {
return FileService.write(path, content);
});
}
src/main/ipc/index.ts 在启动时统一注册:
// src/main/ipc/index.ts
import { registerFsHandlers } from './handlers/fs';
import { registerDialogHandlers } from './handlers/dialog';
import { registerStoreHandlers } from './handlers/store';
export function registerIpcHandlers() {
registerFsHandlers();
registerDialogHandlers();
registerStoreHandlers();
}
channel 名集中到 shared
channel 字符串是最容易出错的地方——main 写了 'fs:read',preload 写成 'fs-read',结果两边对不上号,静默失败。把 channel 名集中到 src/shared/ipc-channels.ts:
// src/shared/ipc-channels.ts
export const IPC = {
FS_READ: 'fs:read',
FS_WRITE: 'fs:write',
DIALOG_OPEN_FILE: 'dialog:open-file',
STORE_GET: 'store:get',
STORE_SET: 'store:set',
} as const;
export type IpcChannel = typeof IPC[keyof typeof IPC];
main 和 preload 都从这个文件 import,绝不在两个地方分别定义 channel 名。
六、Service 层:业务逻辑下沉
handler 不写业务,handler 只做"收参数 → 调 service → 返回结果"。真正的逻辑放在 services/ 目录:
// src/main/services/database.ts
import Database from 'better-sqlite3';
import path from 'node:path';
import { app } from 'electron';
class DatabaseService {
private db: Database.Database;
constructor() {
this.db = new Database(path.join(app.getPath('userData'), 'app.db'));
this.migrate();
}
private migrate() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
listNotes() {
return this.db.prepare('SELECT * FROM notes ORDER BY updated_at DESC').all();
}
insertNote(title: string) {
return this.db.prepare('INSERT INTO notes (title, updated_at) VALUES (?, ?)')
.run(title, Date.now());
}
}
export const databaseService = new DatabaseService();
handler 调它:
// src/main/ipc/handlers/notes.ts
import { databaseService } from '../../services/database';
ipcMain.handle('notes:list', () => databaseService.listNotes());
ipcMain.handle('notes:create', (_e, title: string) => databaseService.insertNote(title));
这样的好处是 service 可独立测试——不需要起 Electron 进程,直接 import { databaseService } 跑单测。
数据库、store、logger 这类带资源的 service 用单例(模块顶层 export const xxx = new XxxService());窗口、临时任务这类带生命周期的一次性对象用工厂。
七、preload 对称拆分
preload 不是只写一个大文件,按暴露的 API 域拆:
src/preload/
├── index.ts # 入口:把所有 api 注册到 contextBridge
└── api/
├── fs.ts
├── store.ts
└── dialog.ts
// src/preload/api/fs.ts
import { ipcRenderer } from 'electron';
import { IPC } from '@shared/ipc-channels';
export const fsApi = {
read: (path: string) => ipcRenderer.invoke(IPC.FS_READ, path),
write: (path: string, content: string) => ipcRenderer.invoke(IPC.FS_WRITE, path, content),
};
// src/preload/index.ts
import { contextBridge } from 'electron';
import { fsApi } from './api/fs';
import { storeApi } from './api/store';
contextBridge.exposeInMainWorld('electron', {
fs: fsApi,
store: storeApi,
});
renderer 端通过 window.electron.fs.read('/tmp/a.txt') 调用,类型通过 src/shared/types.ts 共享:
// src/shared/types.ts
export interface ElectronAPI {
fs: {
read: (path: string) => Promise<string>;
write: (path: string, content: string) => Promise<void>;
};
store: {
get: <T>(key: string) => Promise<T>;
set: <T>(key: string, value: T) => Promise<void>;
};
}
// renderer 端全局声明
declare global {
interface Window {
electron: ElectronAPI;
}
}
八、依赖关系
依赖永远是单向的:renderer → preload → main,main 不知道 renderer 长什么样。三层之间只通过 shared/ 共享类型和 channel 名。
九、常见反模式
| 反模式 | 为什么坏 | 怎么改 |
|---|---|---|
index.ts 写 1000+ 行 | 入口承担太多职责,加新功能无从下手 | 入口只做组装,业务下沉 |
| handler 里直接写业务逻辑 | service 和 IPC 耦合,无法单测 | handler 只做转发,逻辑在 service |
| channel 字符串散落各处 | 拼写错一对不上号,静默失败 | 集中到 shared/ipc-channels.ts |
| 窗口创建逻辑堆在入口 | 加新窗口要改入口 | 工厂函数 + windows 目录 |
| preload 一个大文件 | 暴露的 API 多了之后无法维护 | 按域拆 api/fs.ts / api/store.ts |
| 跨进程类型各自定义 | 一边改了另一边不知道 | 抽到 shared/types.ts,三端共用 |
十、总结
Electron 目录组织的核心就三句话:
- 三层物理隔离——main / preload / renderer 物理分目录,TypeScript paths / 构建配置 / 依赖白名单都能按目录区分
- main 内按职责拆——入口最小化,windows / ipc / services / menu / tray 各司其职
- 跨进程共享类型——
shared/目录装 IPC channel 常量 + TypeScript interface,杜绝字符串散落和类型重复
目录结构一旦定型,加新功能就是"在对应目录加文件"——不需要回头改入口,团队协作也不会撞车。
References
- Electron 官方文档 - Application Architecture —— Electron 官方, 2026
- Electron 官方文档 - Process Model —— Electron 官方, 2026
- electron-vite 模板 —— electron-vite, GitHub, 2026