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
|
const Renderer = (window.require && window.require('electron')) || window.electron || {}; const ipc = Renderer.ipcRenderer || undefined;
const isEE = !!ipc;
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
|
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); }); }); }
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
| const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
on: (channel, callback) => { const subscription = (_, ...args) => callback(...args); ipcRenderer.on(channel, subscription); 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) { 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 { 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; } } 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
| export const ipcApiRoute = { 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);
|
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
| const { Controller } = require('ee-core'); const TtsService = require('../service/tts');
class TtsController extends Controller { 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() { } async 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 this.ctx.app this.ctx.window 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 并发控制的实现细节。