Skip to main content

Electron 项目目录怎么组织?

· 9 min read

Electron 目录没有官方推荐,但 三层物理隔离 + main 内按职责拆分 是经得起长期维护的姿势。

  1. 三层分离src/main(Node 全权限)、src/preload(白名单桥)、src/renderer(沙箱 UI),别混
  2. 入口最小化main/index.ts 只做生命周期组装,业务全部下沉到 windows / ipc / services
  3. IPC 按域拆:每个域(fs / dialog / store)一个 handler 文件,channel 名集中在 channels.ts 常量
  4. Service 层下沉:数据库、文件、网络都封成 service,handler 只做参数转发
  5. 窗口工厂:每种窗口一个工厂函数,统一管创建、状态恢复、生命周期
  6. 共享类型: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 + 受限 DOMipcRenderercontextBridge不能直接 require('fs') 给 renderer 用
src/renderer/ChromiumDOM、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.tsif (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 } 跑单测。

service 单例 vs 多例

数据库、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 目录组织的核心就三句话:

  1. 三层物理隔离——main / preload / renderer 物理分目录,TypeScript paths / 构建配置 / 依赖白名单都能按目录区分
  2. main 内按职责拆——入口最小化,windows / ipc / services / menu / tray 各司其职
  3. 跨进程共享类型——shared/ 目录装 IPC channel 常量 + TypeScript interface,杜绝字符串散落和类型重复

目录结构一旦定型,加新功能就是"在对应目录加文件"——不需要回头改入口,团队协作也不会撞车。

References

  1. Electron 官方文档 - Application Architecture —— Electron 官方, 2026
  2. Electron 官方文档 - Process Model —— Electron 官方, 2026
  3. electron-vite 模板 —— electron-vite, GitHub, 2026