事件总线
js
class EventBus {
constructor() {
// 使用一个 Map 来存储事件,键是事件名,值是回调函数数组
// 使用 Map 比普通对象更健壮,因为键可以是任何类型
this.events = new Map();
}
/**
* 订阅事件
* @param {string} eventName 事件名称
* @param {function} callback 回调函数
*/
on(eventName, callback) {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
this.events.get(eventName).push(callback);
}
/**
* 发布事件
* @param {string} eventName 事件名称
* @param {...any} args 传递给回调的参数
*/
emit(eventName, ...args) {
if (!this.events.has(eventName)) {
// 如果没有订阅者,可以选择静默失败或打印警告
// console.warn(`No listeners for event: ${eventName}`);
return;
}
// 创建一个回调数组的副本,防止在回调中修改原始数组导致问题
const callbacks = [...this.events.get(eventName)];
callbacks.forEach(callback => {
try {
callback(...args);
} catch (e) {
console.error(`Error in event handler for ${eventName}:`, e);
}
});
}
/**
* 取消订阅事件
* @param {string} eventName 事件名称
* @param {function} callback 要移除的回调函数
*/
off(eventName, callback) {
if (!this.events.has(eventName)) {
return;
}
const callbacks = this.events.get(eventName);
const updatedCallbacks = callbacks.filter(cb => cb !== callback);
// 如果移除后还有回调,则更新数组;否则直接删除该事件
if (updatedCallbacks.length > 0) {
this.events.set(eventName, updatedCallbacks);
} else {
this.events.delete(eventName);
}
}
/**
* 订阅一个只执行一次的事件
* @param {string} eventName 事件名称
* @param {function} callback 回调函数
*/
once(eventName, callback) {
const wrapper = (...args) => {
// 执行原始回调
callback(...args);
// 执行后立即移除自身
this.off(eventName, wrapper);
};
// 订阅这个包装后的函数
this.on(eventName, wrapper);
}
}
事件总线带来的问题
- 内存泄漏 :在 Vue、React 等框架中,如果一个组件订阅了事件总线,但在组件销毁时没有通过 off 方法取消订阅,那么这个组件的实例和它所引用的回调函数将无法被垃圾回收,从而导致内存泄漏。务必在组件的销毁生命周期钩子(如 Vue 的 beforeUnmount 或 React 的 useEffect 的清理函数)中调用 off。
事件总线可能带来内存泄露详细分析
2. 事件总线场景下的引用链分析
现在,我们把这个原理应用到"组件订阅事件总线"的场景中。
参与者:
- 事件总线 (bus) :通常是一个全局单例。这意味着它在应用的整个生命周期中都存在,是 GC 的一个"根"对象,或者很容易从根访问到。它永远不会被回收。
- 组件实例 (MyComponent) :由框架(Vue/React)创建的对象,包含了它的 state, props, methods 等。当组件被卸载时,框架会移除对它的引用,我们期望它能被 GC 回收。
- 回调函数 (handler) :通常是组件的一个方法或一个在组件内部定义的函数。
当组件订阅事件时,发生了什么?
codeJavaScript
kotlin
// 在 MyComponent 内部
mounted() { // 或者 useEffect
this.handler = (data) => {
// 使用 this 来访问组件的数据
this.someData = data;
this.someMethod();
};
bus.on('some-event', this.handler);
}
这里建立了一个关键的、双向关联的引用链:
-
事件总线 -> 回调函数 :
bus.on('some-event', this.handler) 这行代码执行后,全局的 bus 对象的内部 events 映射中,'some-event' 这个键对应的值(一个数组)现在包含了对 this.handler 这个函数的引用。
- 链条:window -> bus -> bus.events['some-event'][] -> handler
-
回调函数 -> 组件实例 :
这个回调函数 handler 是在 MyComponent 的上下文中定义的(无论是箭头函数还是普通函数 function() {}.bind(this))。为了能在执行时访问 this.someData 和 this.someMethod(),它通过一个叫做**闭包(Closure)**的机制,隐式地持有了对 MyComponent 实例(this)的引用。
- 链条:handler -> this (即 MyComponent 实例)
完整的引用链就形成了:
window (根) → bus (全局单例) → events 数组 → handler (回调函数) → MyComponent (组件实例)
3. 组件卸载与内存泄漏的发生
现在,用户导航到其他页面,MyComponent 需要被销毁/卸载。
-
框架的操作:Vue 或 React 会执行卸载流程,将组件从 DOM 中移除,并断开框架自身对该组件实例的引用。
-
GC 的检查:垃圾回收器开始工作。它从 window 等根开始扫描。
-
发现问题:GC 沿着上面的引用链进行遍历:
- window 是根,可达。
- bus 是全局的,可达。
- bus.events 数组是 bus 的一部分,可达。
- handler 函数被 bus.events 数组引用,因此它也是可达的。
- MyComponent 实例被 handler 函数(通过闭包)引用,因此它也是可达的!
结论: 尽管框架已经"抛弃"了 MyComponent 实例,但由于全局的事件总线 bus 还牢牢地抓着指向它的回调函数,而这个回调函数又抓着组件实例本身,导致 GC 认为这个组件实例仍然是"有用的",不能回收。
这就是内存泄漏。 组件实例及其内部所有的数据(state, props, methods)都将永久地保留在内存中,即使它在界面上早已消失。如果用户反复进入和离开这个组件所在的页面,内存中就会堆积大量死掉的组件实例,最终可能导致应用性能下降甚至崩溃。
4. 解决方案:在销毁时 off
解决方案就是在组件销毁前,手动断开这个引用链。
codeJavaScript
javascript
// 在 MyComponent 内部
beforeUnmount() { // 或者 useEffect 的清理函数
// 使用当初注册时完全相同的函数引用来取消订阅
bus.off('some-event', this.handler);
}
当 bus.off() 执行时:
- 事件总线 bus 会在其内部的 events 数组中找到并移除对 handler 函数的引用。
- 引用链 bus -> handler 被切断。
现在,handler 函数不再被全局的 bus 引用了。如果没有其他地方引用它,handler 自身就变得不可达了。
于是,MyComponent 实例也不再通过 handler 被间接引用。一旦框架也释放了对它的引用,MyComponent 实例就变得完全不可达。
在下一次 GC 运行时,它会发现 MyComponent 实例和它的 handler 函数都是"孤立"的,于是就会安全地将它们占用的内存回收。问题解决!