Electron 开发的一个痛点:前端代码只能在 Electron 里跑,调试 UI 必须启动完整的主进程。MiMo TTS Studio 通过一套 IPC 封装实现了 浏览器环境降级为 Mock 数据 的能力——在 Chrome 里打开 frontend/dist 就能调试绝大部分界面。


Q1:核心思路是什么?isElectron 标志位怎么工作的?

A: 一句话概括:所有跨进程调用统一走一个 IPC 封装层,检测环境决定是真正通信还是返回 Mock 数据。

1
2
3
4
5
6
7
8
9
10
// utils/ipcRenderer.js — 整个封装层的入口

// 尝试获取 Electron 的 ipcRenderer 对象
const Renderer = (window.require && window.require('electron')) || window.electron || {};
const ipc = Renderer.ipcRenderer || undefined;

// 环境标志位——整个前端代码都通过这个变量判断
const isEE = !!ipc; // EE = Electron Environment

export { Renderer, ipc, isEE, /* ... */ };

检测链路:

1
2
3
4
5
6
7
8
渲染进程加载时:
window.require 存在?
├── 是 → 这是 Electron 环境 → 拿到 ipcRenderer → isEE = true
└── 否 → 这是普通浏览器 → ipc = undefined → isEE = false

后续所有代码:
if (isEE) { ipc.invoke(...) } // Electron: 走真实 IPC
else { return mockData } // 浏览器: 返回 Mock

window.require 是 Electron 注入的特殊属性——只有开启了 nodeIntegration: false + contextIsolation: true + preload 脚本的情况下才存在。普通浏览器里这个属性不存在。


Q2:invoke 和 safeInvoke 有什么区别?什么时候用哪个?

A: 两种封装对应不同的容错策略:

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
33
34
35
36
37
38
/**
* invoke: 带超时的标准 IPC 调用
* 失败时会抛出异常(需要 try-catch)
*/
async function invoke(channel, params, timeout = 60000) {
if (!ipc?.invoke) {
throw new Error('IPC not available: not in Electron environment');
}

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`IPC timeout: ${channel} (${timeout}ms)`));
}, timeout);

ipc.invoke(channel, params)
.then(result => {
clearTimeout(timer);
resolve(result);
})
.catch(error => {
clearTimeout(timer);
reject(error);
});
});
}

/**
* safeInvoke: 静默失败版本
* 失败时不抛异常,返回 null(适合"有则好,没有也行"的场景)
*/
async function safeInvoke(channel, params, timeout) {
try {
return await invoke(channel, params, timeout);
} catch (e) {
console.warn(`[IPC] safeInvoke failed: ${channel}`, e.message);
return null; // 吞掉错误,返回空值
}
}
场景 用谁 原因
TTS 生成(必须成功) invoke 需要知道错误原因并展示给用户
读取配置(需要数据) invoke 配置读不到应该报错
写日志(可有可无) safeInvoke 日志写失败不影响主流程
发送统计上报 safeInvoke 上报失败不能阻塞用户操作

Q3:Preload Bridge 怎么暴露 API?最小权限原则是什么?

A: Bridge 是主进程和渲染进程之间的唯一通道。它的设计原则:

只暴露必要的、安全的方法,不暴露 Node.js API 或原始 ipcRenderer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// electron/preload/bridge.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
// ✅ 只暴露两个方法

// 方法 1:请求-响应模式(对应 ipcRenderer.invoke)
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),

// 方法 2:事件订阅(对应 ipcRenderer.on),带自动取消
on: (channel, callback) => {
const subscription = (_, ...args) => callback(...args);
ipcRenderer.on(channel, subscription);
// 返回取消函数,方便调用方 cleanup
return () => ipcRenderer.removeListener(channel, subscription);
},
});

为什么不暴露这些?

未暴露的 API 如果暴露的风险
ipcRenderer.send 单向推送可被 XSS 利用来发送恶意消息
ipcRenderer.postMessage 消息端口可被利用建立隐蔽通道
require('fs') 直接操作文件系统 = RCE(远程代码执行)
process.env 泄露系统环境变量(含可能的密钥)
globalThis 可以绕过沙箱限制

实际效果:

1
2
3
4
5
渲染进程中执行:
window.electronAPI.invoke('tts:generate', {...}) ✅ 正常工作
window.electronAPI.invoke('require:fs') ❌ 不存在此 channel
window.require('fs') ❌ ReferenceError
process.env.API_KEY ❌ ReferenceError

Q4:各 Store 是怎么适配双环境的?以 ttsStore 为例

A: 最典型的例子就是 doGenerateTTS —— 同一个函数同时支持 Electron 和浏览器两种路径:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
async function doGenerateTTS(outputPath, outputFilename, options = {}) {
const lineStore = useLineStore();
lineStore.isGenerating = true;

try {
const params = { /* 构建参数 */ };

if (ipc?.invoke) {
// ===== Electron 环境:走真实 IPC =====
const result = await ipc.invoke(ipcApiRoute.ttsGenerate, params);
if (result?.success && result?.audioPath) {
lineStore.lastGeneration = result;
lineStore.associatedAudio = {
type: 'audio',
path: result.audioPath,
label: result.filename,
metadata: { duration: result.duration }
};
}
return result;

} else {
// ===== 浏览器环境:模拟响应 =====
// 延迟 1.5 秒模拟网络请求
await new Promise(resolve => setTimeout(resolve, 1500));

const demoFilename = outputFilename || `tts_${Date.now()}.wav`;
const result = {
success: true,
audioPath: '/audio/demo_tone.wav', // 占位路径
duration: 3.0,
filename: demoFilename,
format: configStore.exportFormat,
provider: params.provider
};

lineStore.lastGeneration = result;
return result; // UI 会正常更新,只是没有真实音频
}
} catch (error) {
console.error('TTS generation error:', error);
return { success: false, error: error.message };
} finally {
lineStore.isGenerating = false;
}
}

这带来的开发体验提升

1
2
3
4
5
6
7
8
传统 Electron 开发流程:
改代码 → 等 Vite 编译 → 等 Electron 启动 → 等 main.js 加载 → 刷新页面
(每次循环 ~15~30 秒)

双环境兼容后:
改代码 → Vite HMR 自动刷新(< 1 秒)
Chrome DevTools 直接调试 Vue 组件
需要 TTS 时再切回 Electron 验证完整流程

不是所有功能都能 Mock:

  • ✅ UI 交互、状态管理、表单验证 → 浏览器完美支持
  • ⚠️ 文件对话框、文件读写 → 返回假数据,UI 不会崩但文件操作无效
  • ❌ TTS 真实音频生成、原生窗口控制 → 必须在 Electron 中运行

Q5:IPC 频道命名有什么规范?怎么避免冲突?

A: 所有频道集中定义在一个文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// api/index.js — IPC 频道路由表
export const ipcApiRoute = {
// TTS 相关
ttsGenerate: 'tts:generate',
ttsGetVoices: 'tts:getVoices',
ttsHealthCheck: 'tts:healthCheck',

// 文件操作
fileShowOpenDialog: 'file:showOpenDialog',
fileReadText: 'file:readText',
fileWriteText: 'file:writeText',
fileScanDir: 'file:scanDir',
readFileAsBase64: 'file:readFileAsBase64',

// 配置
configGet: 'config:get',
configSave: 'config:save',
};

命名规则:模块:动作

部分 规则 示例
模块名 小写英文,简短 tts, file, config
动作 camelCase generate, showOpenDialog
分隔符 冒号 : tts:generate

为什么不用字符串常量散落各处?

1
2
3
4
5
6
7
8
// ❌ 反例:频道名字符串散落在各处
await ipc.invoke('tts:generate', params); // 这里
await ipc.invoke('tts:generate', otherParams); // 还有这里
await ipc.invoke('tts-generate', params); // 拼错了!不会报运行时错误,但主进程收不到

// ✅ 正确:集中定义,引用常量
import { ipcApiRoute } from '@/api';
await ipc.invoke(ipcApiRoute.ttsGenerate, params); // IDE 自动补全 + 拼写检查

Q6:主进程 Controller 是怎么自动路由的?ee-core 帮了什么忙?

A: ee-core 的约定优于配置——Controller 文件名和函数名直接映射为 IPC Channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// electron/controller/tts.js — 文件名 = "tts"
const { Controller } = require('ee-core');
const TtsService = require('../service/tts');

class TtsController extends Controller {
// 函数名 = "generate" → 自动注册为 ipcMain.handle('tts:generate')
async generate() {
const { text, voiceId, speed, ... } = this.ctx.request.body || {};
const service = new TtsService(this.ctx.app);
return await service.generate(this.ctx.request.body);
}

async getVoices() { /* → 'tts:getVoices' */ }
async healthCheck() { /* → 'tts:healthCheck' */ }
}

映射关系:

1
2
3
controller/tts.js 的 generate()     →  channel = 'tts:generate'
controller/file.js 的 readText() → channel = 'file:readText'
controller/config.js's save() → channel = 'config:save'

不需要手写任何路由注册代码——ee-core 在启动时自动扫描 controller/ 目录完成注册。

this.ctx 是什么?

ee-core 给每个 Controller 方法注入了上下文对象:

1
2
3
4
5
6
7
8
class SomeController extends Controller {
async someMethod() {
this.ctx.request.body // IPC 传过来的参数(已解析)
this.ctx.app // Electron app 实例
this.ctx.window // 当前 BrowserWindow 引用
this.ctx.logger // 日志实例
}
}

总结

层级 组件 职责
Preload bridge.js contextBridge 暴露 invoke/on 两个方法
封装层 utils/ipcRenderer.js isEE 环境检测 + invoke/safeInvoke + 超时保护
路由定义 api/index.js 频道名称集中管理(模块:动作)
业务调用 各 Store if (isEE) ipc.invoke() else mock 双路径
接收端 controller/*.js ee-core 自动路由映射到 Service 层
核心逻辑 service/*.js 纯业务逻辑,不依赖 Electron API

这套封装让前端开发和 Electron 主进程开发可以并行推进——前端同学用 Chrome 开发 UI,后端同学写 Service 和 Controller,最后联调只需确认 IPC 接口对得上。

下一篇深入 批量并发信号量池 —— 手写 MAX=3 并发控制的实现细节。