问题
Electron IPC 的默认写法是这样的:
// main.ts — 地狱
ipcMain.handle('get-config', async () => { /* ... */ })
// renderer — 更地狱
const result = await ipcRenderer.invoke('get-config') // any
字符串 channel 名散落全项目,参数类型全是 any,重构时改了 main 忘改 renderer,
运行时才发现。这不是工程,这是赌博。
解决思路
定义一个中心契约,所有 IPC channel 的入参和返回值都在这里声明。
// src/shared/ipc-contract.ts
export interface IpcContract {
// invoke 风格:请求-响应
'config:get': {
request: void
response: AppConfig
}
'config:set': {
request: Partial<AppConfig>
response: { ok: boolean; error?: string }
}
'device:list': {
request: void
response: DeviceInfo[]
}
'device:connect': {
request: { vid: number; pid: number }
response: { ok: boolean }
}
}
// 主进程推送给渲染进程的事件(单向)
export interface IpcEvents {
'device:status-changed': { connected: boolean; deviceId?: string }
'config:updated': AppConfig
'log:line': { level: 'info' | 'warn' | 'error'; message: string }
}
两个接口分开:IpcContract 用于 invoke(双向),IpcEvents 用于主进程 → 渲染进程的单向推送。
类型化 invoke 封装
渲染侧(preload 里)
// src/preload/bridge.ts
import { contextBridge, ipcRenderer } from 'electron'
import type { IpcContract, IpcEvents } from '../shared/ipc-contract'
type InvokeChannel = keyof IpcContract
type Req<C extends InvokeChannel> = IpcContract[C]['request']
type Res<C extends InvokeChannel> = IpcContract[C]['response']
// invoke 封装:request 为 void 时不需要传参
const invoke = <C extends InvokeChannel>(
channel: C,
...args: Req<C> extends void ? [] : [Req<C>]
): Promise<Res<C>> => ipcRenderer.invoke(channel, ...args)
主进程侧(双端类型安全)
主进程的 ipcMain.handle 也可以用同样的泛型约束,让两端都受类型保护:
// src/main/ipc-handler.ts
import { ipcMain } from 'electron'
import type { IpcContract } from '../shared/ipc-contract'
type InvokeChannel = keyof IpcContract
type Req<C extends InvokeChannel> = IpcContract[C]['request']
type Res<C extends InvokeChannel> = IpcContract[C]['response']
// 类型化的 handle 封装
function handle<C extends InvokeChannel>(
channel: C,
handler: (req: Req<C>) => Promise<Res<C>> | Res<C>
) {
ipcMain.handle(channel, (_event, req) => handler(req))
}
// 使用:channel 名和参数类型全部受约束
handle('config:get', async () => {
return loadConfig() // 返回类型必须匹配 AppConfig ✅
})
handle('config:set', async (req) => {
// req 类型是 Partial<AppConfig>,不是 any ✅
await saveConfig(req)
return { ok: true }
})
handle('device:connect', async ({ vid, pid }) => {
// 解构直接有类型提示 ✅
const ok = await connectDevice(vid, pid)
return { ok }
})
事件推送(on/send)的类型化
invoke 是请求-响应模型。但有些场景是主进程主动推送给渲染进程:设备状态变化、后台任务进度、日志流……这需要用 ipcMain.emit + webContents.send + ipcRenderer.on。
主进程:发送事件
// src/main/events.ts
import { BrowserWindow } from 'electron'
import type { IpcEvents } from '../shared/ipc-contract'
type EventChannel = keyof IpcEvents
// 类型化的 send 封装
function sendToRenderer<C extends EventChannel>(
win: BrowserWindow,
channel: C,
payload: IpcEvents[C]
) {
win.webContents.send(channel, payload)
}
// 使用示例
sendToRenderer(mainWindow, 'device:status-changed', { connected: true, deviceId: 'abc' })
// payload 类型受 IpcEvents 约束,传错会报错 ✅
渲染侧:监听事件(也在 preload 里封装)
// preload 里加上 on 封装
import type { IpcEvents } from '../shared/ipc-contract'
type EventChannel = keyof IpcEvents
const on = <C extends EventChannel>(
channel: C,
listener: (payload: IpcEvents[C]) => void
): (() => void) => {
// 包一层让返回值是"取消订阅"函数,方便组件 onUnmounted 清理
const wrapped = (_event: Electron.IpcRendererEvent, payload: IpcEvents[C]) =>
listener(payload)
ipcRenderer.on(channel, wrapped)
return () => ipcRenderer.off(channel, wrapped)
}
完整的 preload.ts
把 invoke 和 on 都通过 contextBridge 暴露出去:
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
import type { IpcContract, IpcEvents } from '../shared/ipc-contract'
type InvokeChannel = keyof IpcContract
type Req<C extends InvokeChannel> = IpcContract[C]['request']
type Res<C extends InvokeChannel> = IpcContract[C]['response']
type EventChannel = keyof IpcEvents
const bridge = {
// 请求-响应
invoke<C extends InvokeChannel>(
channel: C,
...args: Req<C> extends void ? [] : [Req<C>]
): Promise<Res<C>> {
return ipcRenderer.invoke(channel, ...args)
},
// 主进程推送事件订阅,返回取消函数
on<C extends EventChannel>(
channel: C,
listener: (payload: IpcEvents[C]) => void
): () => void {
const wrapped = (_: Electron.IpcRendererEvent, payload: IpcEvents[C]) =>
listener(payload)
ipcRenderer.on(channel, wrapped)
return () => ipcRenderer.off(channel, wrapped)
},
}
contextBridge.exposeInMainWorld('bridge', bridge)
// 全局类型声明(配合 tsconfig paths 或放在 global.d.ts)
declare global {
interface Window {
bridge: typeof bridge
}
}
在 Vue 组件里使用:
// DeviceStatus.vue
import { ref, onMounted, onUnmounted } from 'vue'
const connected = ref(false)
let unsubscribe: (() => void) | null = null
onMounted(() => {
unsubscribe = window.bridge.on('device:status-changed', ({ connected: c }) => {
connected.value = c
// payload 类型完整推断,无需断言 ✅
})
})
onUnmounted(() => {
unsubscribe?.() // 清理,避免内存泄漏
})
运行时验证:为什么仅靠 TypeScript 不够
TypeScript 的类型检查只在编译期有效。IPC 跨越进程边界,数据以序列化形式传输——渲染进程发来的数据在主进程眼里就是一个 JSON 反序列化结果,编译器管不到。
攻击场景:恶意扩展或 XSS 注入代码直接调用 ipcRenderer.invoke('config:set', { evil: '...' }),绕过 TypeScript,主进程收到的是任意 payload。
解决方案:用 Zod 在主进程侧做运行时 schema 验证。
// src/shared/ipc-schemas.ts
import { z } from 'zod'
export const IpcSchemas = {
'config:set': z.object({
theme: z.enum(['light', 'dark']).optional(),
language: z.string().optional(),
autoUpdate: z.boolean().optional(),
}),
'device:connect': z.object({
vid: z.number().int().min(0).max(0xffff),
pid: z.number().int().min(0).max(0xffff),
}),
} satisfies Partial<Record<keyof IpcContract, z.ZodTypeAny>>
在 handle 封装里加入验证层:
// src/main/ipc-handler.ts(加了验证的版本)
import { IpcSchemas } from '../shared/ipc-schemas'
function handle<C extends InvokeChannel>(
channel: C,
handler: (req: Req<C>) => Promise<Res<C>> | Res<C>
) {
ipcMain.handle(channel, async (_event, req) => {
// 如果有 schema,先验证
const schema = IpcSchemas[channel as keyof typeof IpcSchemas]
if (schema) {
const result = schema.safeParse(req)
if (!result.success) {
// 验证失败返回错误,而不是崩溃
console.error(`[IPC] Invalid payload for ${channel}:`, result.error.flatten())
throw new Error(`Invalid IPC payload: ${channel}`)
}
req = result.data // 用 zod 解析后的值(已过滤多余字段)
}
return handler(req)
})
}
这样就形成了双重保险:TypeScript 在编译期保护开发体验,Zod 在运行时保护主进程安全。
使用效果
// renderer — 完全类型化
const config = await window.bridge.invoke('config:get')
// ^? AppConfig ✅
await window.bridge.invoke('device:connect', { vid: 0x1234, pid: 0x5678 })
// ^? { vid: number; pid: number } ✅
await window.bridge.invoke('config:get', { wrong: true })
// ^? TS Error: 应为 void ✅
await window.bridge.invoke('nonexistent-channel')
// ^? TS Error: 不在 IpcContract 里 ✅
小结
| 层次 | 工具 | 作用 |
|---|---|---|
| 契约定义 | IpcContract + IpcEvents | 单一事实来源,所有 channel 在这里 |
| 编译期检查 | TypeScript 泛型 | channel 名、参数、返回值全部类型安全 |
| 运行时验证 | Zod schema | 防止恶意或异常 payload 进入主进程 |
| 清理机制 | on() 返回取消函数 | 配合 Vue onUnmounted 防内存泄漏 |
10 分钟的前期投入,换来整个项目 IPC 层零 any,重构时 TS 报错直接定位,主进程也不再裸奔。