Electron IPC 类型安全:从 any 到完全类型化

用 TypeScript 泛型封装 Electron IPC,彻底消灭 any,preload 契约集中管理

问题

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

invokeon 都通过 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 报错直接定位,主进程也不再裸奔。