写 Electron 的人多半都干过这件事:加一个再普通不过的功能------比如「点个按钮,让主进程读一下本地文件」------结果要改三个文件,写两遍同一个字符串,再手动补一份类型声明。然后某天手一抖把 'file:read' 敲成了 'file:raed',编译器一声不吭,运行时按钮点下去毫无反应,你对着控制台找了二十分钟。
这个库就是为了让上面这段话再也不会发生。
先说清楚疼在哪
原生 Electron 里加一个 IPC 调用,标准动作是这样的。
主进程注册:
ts
// main.ts
ipcMain.handle('user:getById', async (_e, id: number) => {
return db.users.find(id)
})
preload 里架桥:
ts
// preload.ts
contextBridge.exposeInMainWorld('api', {
user: {
getById: (id: number) => ipcRenderer.invoke('user:getById', id),
},
})
再手写一份类型,不然渲染层一片 any:
ts
// global.d.ts
interface Window {
api: { user: { getById(id: number): Promise<User> } }
}
渲染层终于能用了:
ts
const u = await window.api.user.getById(1)
四个文件,'user:getById' 这个字符串出现两次而且必须一字不差,User 类型靠你自觉手动同步。功能越多,这套「三处对齐」的仪式就越折磨人。它没有任何技术含量,但每一处都能让你悄无声息地翻车。
它怎么解决:你只写一个函数
换成 electron-ipc-auto-import,上面那一坨变成一个文件:
ts
// src/main/ipc/user.ts
export async function getById(id: number): Promise<User> {
return db.users.find(id)
}
渲染层直接调:
ts
const u = await window.ipc.user.getById(1)
没了。preload 不用碰,类型声明不用写,字符串一个都不用记。文件名 user.ts 自动成了命名空间,导出的函数名就是方法名。你把 getById 的参数从 number 改成 string,渲染层那行立刻飘红------因为类型是从你那个函数本人推导出来的,不是手抄的。
加第二个功能?再 export 一个函数。删功能?删掉函数。整个心智负担就是「写普通的 TypeScript 函数」。
背后没有黑魔法
我知道「自动」两个字容易让人警惕,所以讲清楚原理:全部发生在编译期。
构建时,插件用 ts-morph 静态扫描你的 handler 目录,读出每个导出函数的签名和 JSDoc,然后生成两段代码------主进程那段把函数挨个绑到 ipcMain.handle,preload 那段把对应的 ipcRenderer.invoke 包好、contextBridge 暴露出去------再顺手生成一份 .d.ts 给渲染层。运行时没有反射、没有 eval、没有动态字符串拼 channel,渲染层能调的方法是构建时就钉死的那几个,多一个都调不到。
也就是说,它生成的正是你本来要手写的那些代码,只是不用你写了。出了问题,你能直接去看生成的虚拟模块长什么样,而不是对着一坨运行时魔法干瞪眼。
顺带一提,生成的 preload 在 sandbox: true 下也能正常跑,主进程抛出的错误会原样穿回渲染层------name、message、stack 还有你挂在 error 上的自定义字段都在,try/catch 抓到的是一个真正的 Error,而不是被 Electron 序列化得面目全非的字符串。
几个你大概会用得上的点
- 不只服务于 Vite。 底层是 unplugin,Vite / webpack / Rspack / esbuild / Rollup 都有对应入口,配置长得几乎一样,换构建工具不用换思路。
- 命名空间跟着目录走。 默认按文件名分组(
ipc/user.ts→window.ipc.user.*);想按文件夹、或者干脆全平铺,一个namespace选项切换。 - 留了拦截的口子。 想在所有调用前统一校验参数、记日志、做鉴权?
registerIpcHandlers({ validate })一处搞定。错误怎么序列化也能自定义。 - JSDoc 会跟着走。 你写在 handler 上的注释,会出现在渲染层的类型提示里,IDE 里 hover 一下就能看到。
三分钟上手
bash
pnpm add -D electron-ipc-auto-import
构建配置里挂上插件(以 electron-vite 为例,三个 target 都加):
ts
// electron.vite.config.ts
import electronIpc from 'electron-ipc-auto-import/vite'
const ipc = () =>
electronIpc({
dirs: ['src/main/ipc'],
dts: 'src/preload/ipc-auto-import.d.ts',
})
export default defineConfig({
main: { plugins: [ipc()] },
preload: { plugins: [ipc()] },
renderer: { plugins: [ipc()] },
})
主进程开机时注册一次:
ts
// src/main/index.ts
import { registerIpcHandlers } from 'virtual:electron-ipc/main'
app.whenReady().then(() => {
registerIpcHandlers()
createWindow()
})
preload 一行:
ts
// src/preload/index.ts
import 'virtual:electron-ipc/preload'
然后开始往 src/main/ipc/ 里扔函数就行了。
它不打算做的事
为了不浪费你的时间,也把边界说在前面:这是个 请求-响应(invoke/handle) 的库,不是大一统 RPC 框架。主进程往渲染层主动推送(webContents.send 那种事件流)它不管,长连接、流式传输也不在范围内。它只想把「渲染层调一个主进程函数」这件最高频的事做到极致省心。
如果你的 80% IPC 都是「我问,主进程答」,那它基本能帮你把这部分代码量砍到只剩业务逻辑本身。
灵感来自 unplugin-auto-import------既然导入语句能自动,IPC 这种更机械的样板,凭什么还要手写。
仓库在 github.com/yyues/elect...,觉得有用的话点个 star,有想法欢迎来提 issue。