Electron + Vite + TS 模块化重构踩坑实录

前言

随着 Electron 应用功能的增加,将所有主进程逻辑堆在 main.ts 中会迅速演变成一场维护噩梦。为了代码的可读性和可扩展性,我决定将其模块化,拆分成 windowManageripcHandlers 等模块。然而,这个看似简单的重构过程却引出了一连串的经典“踩坑”之旅。本文旨在记录这些问题的根源并提供解决方案,希望能帮助遇到同样困惑的开发者。

我们的目标结构如下:

1
2
3
4
5
6
7

src/main/
├── index.ts \# 应用主入口
├── windowManager.ts \# 窗口管理器
├── ipcHandlers.ts \# IPC 事件处理器
└── constants.ts \# 全局常量

坑点一:ReferenceError: __dirname is not defined

这是从 CommonJS 迁移到 ES Module (ESM) 时最常遇到的问题。

报错信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

UnhandledPromiseRejectionWarning: ReferenceError: \_\_dirname is not defined
at createWindow (file:///.../dist-electron/index.js:28:26)

````

**问题根源:**
在 Node.js 的 ES Module 规范中,`__dirname` 和 `__filename` 这两个全局变量是**不存在的**。而 `electron-vite` 模板默认使用 ESM。当你将代码拆分到 `windowManager.ts` 等新模块后,这些模块同样是 ESM,直接使用 `__dirname` 就会导致引用错误。

**解决方案:**
在**每一个**需要使用 `__dirname` 的 ESM 文件中,都必须手动通过 `import.meta.url` 来模拟生成它。

```typescript
// 在需要使用 __dirname 的文件顶部添加
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// 然后就可以在文件中正常使用 __dirname 了
// 例如,在 windowManager.ts 中设置 preload 路径
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'), //
}
````

## 坑点二:`TypeError: The "path" argument must be of type string. Received undefined`

这个错误通常与变量的初始化顺序有关。

**报错信息:**

TypeError [ERR_INVALID_ARG_TYPE]: The “path” argument must be of type string. Received undefined
at Object.join (node:path:433:7)

1
2
3
4
5
6
7
8
9
10

**问题根源:**
在我的 `constants.ts` 文件中,我尝试在定义 `APP_ROOT` 之前就使用它来构造其他路径常量。

**错误代码示例 (`constants.ts`):**

```typescript
// 错误示例
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); // 此时 process.env.APP_ROOT 是 undefined
process.env.APP_ROOT = path.join(__dirname, '..');

解决方案:
严格保证代码的执行顺序。先定义基础变量,再使用这些变量去构造复合变量。

修正后代码 (constants.ts):

1
2
3
4
5
6
7
8
9
10
11
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname_constants = path.dirname(fileURLToPath(import.meta.url));

// 1. 先定义 APP_ROOT
export const APP_ROOT = path.join(__dirname_constants, '..', '..');

// 2. 再用 APP_ROOT 去定义其他常量
export const RENDERER_DIST = path.join(APP_ROOT, 'dist');
// ...

坑点三:Error: No handler registered for 'channel-name'

这是一个经典的 IPC (进程间通信) 问题。

报错信息:

1
Error occurred in handler for 'dialog:openImage': Error: No handler registered for 'dialog:openImage'

问题根源:
我在 ipcHandlers.ts 中定义了一个 registerIpcHandlers() 函数来集中注册所有的 ipcMain.handleipcMain.on 事件,但在主入口 index.ts 中,我忘记了调用这个函数。导致渲染进程发起请求时,主进程中对应的“服务”根本没有“上线”。

解决方案:
确保在应用准备就绪后 (app.whenReady()),第一时间调用注册函数。

修正后代码 (index.ts):

1
2
3
4
5
6
7
8
9
10
11
12
import { app } from 'electron';
import { createMainWindow } from './windowManager';
import { registerIpcHandlers } from './ipcHandlers';

app.whenReady().then(() => {
// 👇 在创建窗口之前,先注册所有的 IPC 服务
registerIpcHandlers();

createMainWindow();

// ...
});

坑点四:Not allowed to load local resource: file:///...

当渲染进程尝试直接加载本地文件时,会触发 Chromium 内核的安全策略。

问题根源:
出于安全考虑,渲染进程(本质上是一个网页)被禁止直接访问本地文件系统。ipcMain.handle('dialog:openImage') 最初返回了一个本地文件路径 (如 D:\images\a.png) 给渲染进程,当渲染进程试图将 img.src 设置为这个路径时,就被安全策略拦截了。

解决方案(最佳实践):
不要传递路径,而是传递文件内容。让拥有文件系统权限的主进程读取文件,将其转换为 Data URL,再把这个 URL 发送给渲染进程。Data URL 是网页可以直接识别和显示的格式。

修正后代码 (ipcHandlers.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { readFileSync } from 'fs';
import path from 'path';

ipcMain.handle('dialog:openImage', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({ /* ... */ });
if (!canceled && filePaths.length > 0) {
const filePath = filePaths[0];
// 1. 读取文件 Buffer
const buffer = readFileSync(filePath);
// 2. 转换为 Base64
const base64 = buffer.toString('base64');
// 3. 获取文件类型
const extension = path.extname(filePath).substring(1);
// 4. 构造并返回 Data URL
return `data:image/${extension};base64,${base64}`;
}
return undefined;
});

渲染进程拿到这个 Data URL 字符串后,可以直接赋值给 img.src,问题迎刃而解。

总结

这次重构之旅虽然波折,但收获颇丰。核心要点可以总结为:

  1. 拥抱 ES Module:在 Node.js 环境下使用 ESM 时,要主动告别 __dirname 的“肌肉记忆”,习惯使用 import.meta.url 模式。
  2. 注重执行顺序:无论是变量定义还是函数调用,代码的上下文和执行时机都至关重要。
  3. 坚守安全边界:始终牢记主进程和渲染进程的权限差异,让主进程作为数据处理和系统交互的唯一可信来源,是构建健壮 Electron 应用的基石。

希望这篇总结能为你点亮前行路上的“坑”,让你的 Electron 开发之旅更加顺畅。


Electron + Vite + TS 模块化重构踩坑实录
https://blog.yonagi.top/2025/09/17/7c5cda8442eb/
作者
Yonagi
发布于
2025年9月18日
许可协议