事件总线挂了半天才发现是内存泄漏——我在微前端里给事件加了命名空间和类型约束
如果只是“事件总线挂了”,通常不是挂了,是某条监听没注销,慢慢把内存吃光了——这个问题我排查了整整半天。
事情不复杂。我们项目是微前端架构,主应用加载多个子应用,子应用间用 EventBus 通信。某天下午测试环境开始间歇性白屏,Chrome DevTools 的 Memory Timeline 呈锯齿状上升,最后稳定在 1.2GB 左右。翻了半天代码,发现子应用卸载时,有 3 条全局事件监听根本没移除——其中一个还是每 30ms 触发一次的 scroll 事件处理器,里面闭包引用了整个 Vue 组件实例。
这就是典型的“你以为自己注销了,其实没有”。而且微前端场景下,这个问题会被放大:子应用反复加载卸载,事件监听不断堆积,而开发时往往只测首次加载,根本发现不了。
为什么全局 EventBus 在微前端里更容易内存泄漏
微前端的核心特征是子应用生命周期独立。以 single-spa 为例,子应用有 mount / unmount 两个关键钩子。mount 时注册事件监听,unmount 时必须移除——这个逻辑说起来简单,但实际代码里很容易漏。
更致命的是,多数团队用的 EventBus 实现极其简陋:一个全局 EventEmitter 实例,挂在 window 下,所有子应用共享。子应用 A 注册 window.bus.on('user:login', callback),卸载时只调了 window.bus.off('user:login'),但没传具体的 callback 引用——这种写法在某些 EventEmitter 实现里根本不会移除监听,或者只移除了最后一个。
还有一个隐性坑:子应用卸载时,如果 callback 里引用了子应用的某个对象(比如 store、组件实例),而这个 callback 还挂在全局 bus 上,GC 就无法回收整个子应用的内存。V8 的堆快照里会看到大量 Detached DOM 和闭包引用链,起点往往是 window.bus._events 里的某个函数。
我们那次 scroll 事件的例子更典型:子应用 B 在主内容区注册了 window.addEventListener('scroll', handler),handler 里通过闭包引用了子应用 B 的 Vuex store。子应用 B 卸载时,只清了自己的路由和 DOM,完全忘了这个全局 scroll 监听。每次 B 加载一次,就多一个 scroll handler,而且每个 handler 都死死拽着上一次加载的 store 对象不放。半个小时内用户反复切换路由 20 次,内存直接飙到 1.2GB。
命名空间:让事件有“归属”,卸载时能一键清理
排查完那次事故后,我做的第一件事就是给事件加命名空间。思路很简单:每个子应用注册事件时,统一加上自己的 namespace 前缀,卸载时按命名空间批量移除。
具体实现上,我们封装了一个 ScopedEventBus 类:
class ScopedEventBus {
private namespace: string;
private bus: EventEmitter;
constructor(namespace: string, bus: EventEmitter) {
this.namespace = namespace;
this.bus = bus;
}
on(event: string, handler: (...args: any[]) => void) {
this.bus.on(`${this.namespace}:${event}`, handler);
}
emit(event: string, ...args: any[]) {
this.bus.emit(`${this.namespace}:${event}`, ...args);
}
off(event: string, handler?: (...args: any[]) => void) {
this.bus.off(`${this.namespace}:${event}`, handler);
}
// 关键:卸载时清除该命名空间下所有事件
destroy() {
const events = this.bus.eventNames();
events.forEach((event) => {
if (typeof event === 'string' && event.startsWith(`${this.namespace}:`)) {
this.bus.removeAllListeners(event);
}
});
}
}
子应用在 mount 时创建自己的 bus 实例:
// 子应用 A 入口
let bus: ScopedEventBus;
export async function mount() {
bus = new ScopedEventBus('app-a', window.__globalBus);
bus.on('data:updated', handleDataUpdate);
}
export async function unmount() {
bus.destroy(); // 一键清除 app-a:* 下所有监听
}
这个方案的好处是防呆:开发者不需要记住自己注册了哪些事件,unmount 时调一次 destroy() 就行了。而且事件名带命名空间后,在 DevTools 里打 window.__globalBus.eventNames() 能立刻看出哪些事件属于哪个子应用,排查问题的时候不用猜。
但命名空间只能解决“归属”和“批量清理”的问题,解决不了另一个更隐蔽的坑:事件 payload 的类型错乱。
类型约束:让事件签名在编译期就定死
我们团队用的是 TypeScript,但老 EventBus 的事件名和 payload 完全没类型约束:
bus.on('order:paid', (data) => {
// data 是 any,完全靠注释说明结构
console.log(data.orderId);
});
这种代码在跨团队协作时简直是灾难。子应用 A emit 了一个 order:paid 事件,payload 结构是 { orderId: string, amount: number }。子应用 B 的开发者看文档(如果有的话)或者猜着写,解构了 data.order_id(下划线命名),上线后静默失败,订单状态永远不更新。
我们后来换了一种严格类型化的 EventBus 实现,核心是用 TypeScript 的 mapped type 和 interface 把事件名到 payload 类型做映射:
// 全局事件类型定义文件 event-types.ts
interface AppAEvents {
'order:paid': { orderId: string; amount: number; paidAt: number };
'order:cancelled': { orderId: string; reason: string };
}
interface AppBEvents {
'inventory:updated': { sku: string; stock: number };
}
// 合并为全局事件映射表
interface GlobalEventMap extends AppAEvents, AppBEvents {}
// 类型安全的 EventBus
class TypedEventBus<EventMap extends Record<string, any>> {
private bus = new EventEmitter();
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
) {
this.bus.on(event as string, handler);
}
emit<K extends keyof EventMap>(
event: K,
payload: EventMap[K]
) {
this.bus.emit(event as string, payload);
}
off<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
) {
this.bus.off(event as string, handler);
}
}
使用时:
const bus = new TypedEventBus<GlobalEventMap>();
bus.on('order:paid', (payload) => {
// payload 自动推导为 { orderId: string; amount: number; paidAt: number }
console.log(payload.amount.toFixed(2)); // 类型安全
});
bus.emit('order:paid', {
orderId: '123',
amount: 99.9,
paidAt: Date.now(),
// 缺少字段或类型不对都会编译报错
});
这个方案的核心是把所有事件签名集中在一个 interface 文件里,作为“事件契约”。新增事件必须先改这个 interface,否则编译不过。这倒逼开发者在下笔写 emit 之前想清楚事件名和 payload 结构,也避免了不同子应用对同一事件的理解偏差。
我们还在 CI 里加了一个检查脚本:用 ts-morph 解析 event-types.ts 的 AST,如果有人直接修改了 GlobalEventMap 但没有添加 JSDoc 注释说明事件用途和触发时机,MR 会自动打上 warning 标签。这个机制不强硬,但足够让 cr 的人注意到。
命名空间 + 类型约束的组合:实际落地的完整方案
把上面两个方案合并,最终我们的事件总线长这样:
// global-bus.ts
import { TypedEventBus } from './typed-event-bus';
import { GlobalEventMap } from './event-types';
export const globalBus = new TypedEventBus<GlobalEventMap>();
// scoped-bus.ts
export class ScopedTypedEventBus<T extends Record<string, any>> {
private namespace: string;
private bus: TypedEventBus<any>;
constructor(namespace: string, bus: TypedEventBus<any>) {
this.namespace = namespace;
this.bus = bus;
}
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void) {
this.bus.on(`${this.namespace}:${event as string}`, handler);
}
emit<K extends keyof T>(event: K, payload: T[K]) {
this.bus.emit(`${this.namespace}:${event as string}`, payload);
}
destroy() {
// 清除逻辑同上
}
}
子应用定义自己的事件类型:
// app-a-events.ts
export interface AppAEvents {
'data:updated': { source: string; timestamp: number };
'modal:closed': { modalId: string };
}
子应用入口:
import { ScopedTypedEventBus } from '@/shared/scoped-bus';
import { AppAEvents } from './app-a-events';
let bus: ScopedTypedEventBus<AppAEvents>;
export function mount() {
bus = new ScopedTypedEventBus('app-a', window.__globalBus);
bus.on('data:updated', (payload) => {
// payload 类型完全确定
});
}
export function unmount() {
bus.destroy();
}
这个方案上线半年后,我们再没遇到过事件相关的内存泄漏。中间有一次子应用 B 的新人没在 unmount 里调 destroy(),code review 时直接被类型系统拦下来了——因为他用的事件类型没在 interface 里定义,他想绕过去的时候发现绕不动,只能老老实实读文档。
如果不想自己造轮子,这些替代方案也值得看
自己封装 EventBus 能完全掌控细节,但维护成本不低。如果团队规模不大或者不想在基础设施上花太多时间,下面几个方案可以替代。
mitt:一个 200 字节的 EventEmitter 替代品,API 极简,天然支持传入 handler 引用做精确移除,不会出现只传事件名清不掉监听的问题。缺点是没有类型映射,得自己包一层泛型。
RxJS Subject:如果团队已经在用 RxJS,用 Subject 做事件总线比 EventEmitter 更灵活。pipe(takeUntil(unmount$)) 的写法天然适合生命周期管理,内存泄漏风险极低。
CustomEvent + dispatchEvent:浏览器原生方案,不需要任何第三方依赖。配合 AbortController 可以在 DOM 元素上注册事件并自动清理。但只适用于 DOM 事件模型,不能传递非序列化对象。
single-spa 的 cross-microfrontend imports:single-spa 5.x 提供了官方推荐的跨应用通信方式,本质是共享模块引用而非事件驱动。适合数据同步场景,但不适合“通知”类事件。
无论选哪种方案,核心原则不变:事件的生命周期必须和子应用的生命周期严格绑定,卸载时清理必须可验证(而不是靠开发者自觉)。
常见问题
加了命名空间后,主应用怎么监听子应用的事件?
主应用直接用全局 bus 监听带命名空间的事件名,比如 globalBus.on('app-a:data:updated', handler)。更好的做法是让主应用也走 ScopedTypedEventBus,namespace 设为 main,这样所有应用的事件都统一管理,不会出现主应用直接操作原始事件名的情况。
类型映射文件越来越大怎么办?
按子应用拆分,每个子应用维护自己的事件类型 interface,然后在一个统一的 global-event-map.ts 里用交叉类型合并。CI 上用脚本检查合并后的类型有没有重名事件,防止两个子应用无意中定义了同名但不同 payload 的事件。
scroll / resize 这类 DOM 事件怎么纳入统一管理?
这类事件不适合走 EventBus,因为 EventBus 是应用层通信,DOM 事件是浏览器层。解决方案是封装一个 useGlobalEventListener hook(React 下)或 composable(Vue 下),内部用 addEventListener 注册,并在组件/子应用卸载时通过 AbortController 或手动 removeEventListener 清理。关键是把 DOM 事件的清理逻辑也收拢到生命周期里,不能散落在业务代码各处。
mitt 或 RxJS 方案能完全避免内存泄漏吗?
不能。工具只是提供了更好的 API,最终还是要开发者正确调用 off 或 unsubscribe。RxJS 的 takeUntil 模式相对更安全,但如果 unmount$ 这个 Subject 本身没有被正确 complete,同样会泄漏。没有银弹,只有更严格的代码审查和更清晰的清理规范。