Electron 架构工程化:打造类 NestJS 的自动化 IPC 通信链路
背景与现状
本项目基于 electron-vite 构建,遵循官方标准的 Main(主进程)、Preload(预加载脚本) 和 Renderer(渲染进程) 三层架构。虽然该结构定义了职责边界,但在大型项目中仍面临挑战:主进程逻辑若缺乏管理易沦为臃肿的单体泥潭;Preload 层沦为繁琐的“传声筒”,每增加一个 IPC 接口都需要手动同步维护映射,导致开发体验割裂且维护成本极高。
核心优化思路 为解决上述痛点,我们借鉴了 NestJS 的 IoC(控制反转)与 DI(依赖注入) 设计哲学,对架构进行了深度重构:
- 主进程工程化:利用 TypeScript 装饰器(Decorator)与元数据(Reflect Metadata)实现
@Controller式的路由收集,自动挂载 IPC 监听,将业务逻辑解耦到独立的 Service 层。 - IPC 通信自动化:打破传统的“手动暴露 API”模式。通过主进程在初始化阶段自动同步接口定义元数据,驱动 Preload 层实现自动化桥接(Auto-bridge)。开发者只需在主进程定义接口,UI 层即可通过类型安全的 Proxy 直接调用,彻底消除冗余的“胶水代码”。
ps: 本实现借鉴了 NestJS 的模块化与装饰器设计思路,并非完整复刻,部分概念为类比用法
Main层
之前的设计:入口存在一个controllerManage和serviceManage,在这里面集中注册对应的service服务模块和controller控制器,controller负责注册ipc通信,而service则提供服务;文件按文件类型划分,即service放services文件夹,controller放controllers文件夹。这样做在后期维护debugger的时候非常难受,通常会在几个文件夹里来回跳转。
优化后:按nestjs 模块划分结构,即每一个module放该模块对应的service和controller以及nestjs的module文件,这里我们直接叫index;
serviceManage和controllerManage保持不变,同样是在初始化触发所有service和controller的注册;但我们这里要实现自动注册ipc,所以还是要保留register方法,不过register现在只做收集不做注册任务;
export class ControllerManage implements Controller {
name = 'controll_manage'
private controllers: Map<string, ControllerInstance> = new Map()
register(controller?: ControllerInstance) {
if (!controller) {
throw new Error('缺少控制器')
}
const name = (controller.constructor as WithControllerName).__controllerName
if (!name) {
throw new Error('控制器缺少 @Controller(name) 装饰器')
}
if (this.controllers.has(name)) {
console.warn(`控制器 ${name} 已存在`)
}
this.controllers.set(name, controller)
}
...
cleanup() {
// 先移除 getAllHandle,避免 preload 在清理后仍能拿到旧表
removeIpc(REGISTRY_CHANNEL)
for (const [ctrlName, channels] of Array.from(this.channelByController)) {
for (const channel of channels) {
removeIpc(channel)
}
const ctrl = this.controllers.get(ctrlName)
if (ctrl?.dispose) {
ctrl.dispose()
}
console.log(`控制器 ${ctrlName} 注销成功`)
}
this.channelByController.clear()
}
dispose() {
this.cleanup()
}
}
而serviceMange则同样细分为register和setup以及cleanup,最后暴露一个getSerivce方法来根据传入的key获取对应的service服务;
import { Service } from '@type/service'
export class ServiceManage implements Service {
name = 'service_manage'
private services: Map<string, Service> = new Map()
/**
* 注册子实例来实现Ioc 手动DI
* @param service 服务实例
*/
register(service: Service) {
if (this.services.has(service.name)) {
//
console.warn(`服务(${service.name})已存在,将会被覆盖`)
}
this.services.set(service.name, service)
}
/**
* 返回指定名称的服务实例
* @param name 服务名
* @returns 服务实例
*/
getService<T extends Service>(name: string): T | undefined {
if (this.services.has(name)) {
return this.services.get(name) as T
}
throw new Error(`服务(${name})不存在`)
}
async setup() {
const allServices = this.services.values()
for (const srv of allServices) {
await srv.setup?.()
console.log(`服务${srv.name}初始化成功`)
}
}
async cleanup() {
const allServices = this.services.values()
for (const srv of allServices) {
if (srv.cleanup) {
await srv.cleanup()
console.log(`服务${srv.name}清理完成`)
}
}
}
}
模块功能
module类需要被约束成要实现register方法的ModuleDefinition
export type IContext = {
controller: ControllerManage
service: ServiceManage
}
export interface ModuleDefinition {
register: (ctx: IContext) => void
}
即:
export class WindowModule implements ModuleDefinition {
register(ctx: IContext) {...}
}
而service则需要被约束需要实现Service
export interface Service {
/**
* 服务名称,用于标识服务
*/
readonly name: string
/**
* 初始化服务
*/
setup(): void | Promise<void>
/**
* 清理资源
*/
cleanup?(): Promise<void>
}
至于controller,本来也有约束实现,要求其必须实现register和dispose,但后来进阶使用了装饰器收集注册ipc channel,所以这里不做约束。
装饰器实现
我们要实现装饰器收集ipc注册的channel,最后在controllerManange里统一注册;
我期望的是像nestjs那样,在controller层里通过@Controller('xx')来定义路由prefix,里面的path收集我们还要再实现一个Handle装饰器收集,利用元数据收集到需要注册的channel再集中注册;
创建metadata.ts文件用于记录收集注册数据:
/** 用 @Controller(name) 的 name 当 key,避免 WeakMap 因构造函数引用不一致取不到值 */
export const controllerMeta = new Map<
string,
{
name: string
handlers: { method: string; handlerName: string }[]
}
>()
/** 仅类型用,实际 handlers 挂在类的 __ipcHandlers 上 */
export type HandlerItem = { method: string; handlerName: string }
定义Controller装饰器
import { controllerMeta } from '@/framework/metadata'
/**
* 用 @Controller(name) 的 name 当 key 存 meta;
* handlers 直接从类的 __ipcHandlers 读,不依赖 WeakMap 引用。
*/
export function Controller(name: string) {
return function (target: any, context: ClassDecoratorContext) {
// 这里的 context.metadata 和 Handle 里的 context.metadata 是同一个对象
const handlers = (context.metadata as any)?.__ipcHandlers ?? []
controllerMeta.set(name, {
name,
handlers
})
// 如果你还需要在类或原型上挂载数据,依然可以操作 target
target.prototype.name = name
;(target as any).__controllerName = name
console.log(`[Controller] ${name} 已绑定 ${handlers.length} 个处理函数`)
}
}
export interface WithControllerName {
__controllerName?: string
}
定义Handle装饰器
/** 装饰器挂在类上的 handlers,避免 WeakMap key 不一致 */
export interface WithIpcHandlers {
__ipcHandlers?: { method: string; handlerName: string }[]
}
export function Handle(name?: string) {
return function (_, context: ClassMethodDecoratorContext) {
// Stage 3 标准:元数据直接挂在 context.metadata 上
// 注意:metadata 在整个类定义期间是共享的
const metadata = context.metadata as any
if (!metadata.__ipcHandlers) {
metadata.__ipcHandlers = []
}
metadata.__ipcHandlers.push({
method: name ?? String(context.name),
handlerName: String(context.name)
})
}
}
这里我们不要求使用@Handle装饰器的时候传入名称,没有传入则默认取函数name;
在modules模块里我们就可以直接这么使用了,比如:
import WindowServer from './window.service'
import { Controller, Handle } from '@/framework'
@Controller('window')
export default class WindowController {
constructor(private windowServer: WindowServer) {}
@Handle()
close() {
this.windowServer.close()
}
@Handle()
minimize() {
this.windowServer.minimizeWindow()
}
@Handle()
maximize() {
this.windowServer.maximizeWindow()
}
/** 由 ControllerManage.cleanup 统一移除 IPC;子类可在此做本 controller 的其它资源释放 */
dispose() {
//
}
}
以上我们实现了ipc channel的收集,接下来我们需要在controllerManage里集中注册
定义一个ipc.ts文件,在这个文件里实现ipc.handle和removeHander
import { ipcMain } from 'electron'
export function registerIpc(channel: string, handler: (...args: any[]) => any) {
ipcMain.handle(channel, async (_, ...args) => {
try {
const result = await handler(...args)
return {
success: true,
data: result
}
} catch (err: any) {
console.error(`IPC Error [${channel}]`, err)
return {
success: false,
message: err?.message ?? 'unknown error'
}
}
})
}
export function removeIpc(channel: string): void {
ipcMain.removeHandler(channel)
}
在ControllerManage里集中注册
export class ControllerManage implements Controller {
...
setup() {
const list = Array.from(this.controllers.values())
for (const ctrl of list) {
const name = (ctrl.constructor as WithControllerName).__controllerName
if (!name) continue
const meta = controllerMeta.get(name)
if (!meta) continue
const moduleName = meta.name
const channels: string[] = []
preloadRegistry[moduleName] ??= []
for (const h of meta.handlers) {
const channel = `${moduleName}:${h.method}`
preloadRegistry[moduleName].push(h.method)
registerIpc(channel, ctrl[h.method].bind(ctrl))
channels.push(channel)
console.log(`IPC registered → ${channel}`)
}
this.channelByController.set(name, channels)
}
this.registerHandlerRegistry()
}
}
在for循环结束之后注册一个通信方法(REGISTRY_CHANNEL)与preload通信,将注册的ipc channel暴露给prelaod层,即this.registerHandlerRegistry()
/** 注册 ipc channel,返回所有收集的 handle name 供 preload 使用 */
private registerHandlerRegistry() {
registerIpc(REGISTRY_CHANNEL, () => preloadRegistry)
}
Preload
执行初始化方法来与main层通信并获取所有注册的channel,并实现注册
async function initApi() {
const handlerRegistry = await invoke('getAllHandle') // main 返回上面的对象
const api: Record<string, any> = {}
for (const module in handlerRegistry) {
api[module] = {}
for (const method of handlerRegistry[module]) {
api[module][method] = (...args: any[]) => invoke(`${module}:${method}`, ...args)
}
}
contextBridge.exposeInMainWorld('api', api)
}
initApi()
invoke.ts
import { ipcRenderer } from 'electron'
export async function invoke(channel: string, ...args: any[]) {
const res = await ipcRenderer.invoke(channel, ...args)
if (!res.success) {
throw new Error(res.message)
}
return res.data
}
以上 实现了electron 主线程Like Nestjs化
还有一点漏说了,我们这里实现了统一错误处理,可以直接throw抛出错误,因为我们已经在registerIpc使用try catch捕获了所有错误并返回响应式处理!
但invoke里我们又重新抛出去了,所以需要renderer层自己捕获,throw 出去的好处是渲染层可以直接用 try/catch 捕获,不需要每次都判断 res.success!
如果觉得不合理的可以自己优化invoke返回
最后,我可能描述的术语存在问题或者是表达的意思不正确,但是思路是没问题的。
这个架构还缺少了preload注册 自动推断channel返回类型,暂时没思路,目前通过手动声明 Window.api 类型来获得 TS 提示,比如:
import { ElectronAPI } from '@electron-toolkit/preload'
import type { WindowService } from '@type/module/window'
declare global {
interface Window {
electron: ElectronAPI
api: {
window: WindowService
}
}
}
如果有更好的自动推断方案欢迎 PR
如果看不懂我的表达,可以直接看仓库代码,我已经提交到github仓库