手写事件总线、事件总线可能带来的内存泄露问题

事件总线

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);
  }
}

事件总线带来的问题

  1. 内存泄漏 :在 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);
}

这里建立了一个关键的、双向关联的引用链:

  1. 事件总线 -> 回调函数

    bus.on('some-event', this.handler) 这行代码执行后,全局的 bus 对象的内部 events 映射中,'some-event' 这个键对应的值(一个数组)现在包含了对 this.handler 这个函数的引用。

    • 链条:window -> bus -> bus.events['some-event'][] -> handler
  2. 回调函数 -> 组件实例

    这个回调函数 handler 是在 MyComponent 的上下文中定义的(无论是箭头函数还是普通函数 function() {}.bind(this))。为了能在执行时访问 this.someData 和 this.someMethod(),它通过一个叫做**闭包(Closure)**的机制,隐式地持有了对 MyComponent 实例(this)的引用。

    • 链条:handler -> this (即 MyComponent 实例)

完整的引用链就形成了:

window (根) → bus (全局单例) → events 数组 → handler (回调函数) → MyComponent (组件实例)

3. 组件卸载与内存泄漏的发生

现在,用户导航到其他页面,MyComponent 需要被销毁/卸载。

  1. 框架的操作:Vue 或 React 会执行卸载流程,将组件从 DOM 中移除,并断开框架自身对该组件实例的引用。

  2. GC 的检查:垃圾回收器开始工作。它从 window 等根开始扫描。

  3. 发现问题: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() 执行时:

  1. 事件总线 bus 会在其内部的 events 数组中找到并移除对 handler 函数的引用。
  2. 引用链 bus -> handler 被切断

现在,handler 函数不再被全局的 bus 引用了。如果没有其他地方引用它,handler 自身就变得不可达了。

于是,MyComponent 实例也不再通过 handler 被间接引用。一旦框架也释放了对它的引用,MyComponent 实例就变得完全不可达。

在下一次 GC 运行时,它会发现 MyComponent 实例和它的 handler 函数都是"孤立"的,于是就会安全地将它们占用的内存回收。问题解决!

相关推荐
岁月宁静3 小时前
在 Vue 3.5 中优雅地集成 wangEditor,并定制“AI 工具”下拉菜单(总结/润色/翻译)
前端·javascript·vue.js
执沐3 小时前
基于HTML 使用星辰拼出爱心,并附带闪烁+流星+点击生成流星
前端·html
#做一个清醒的人4 小时前
【electron6】Web Audio + AudioWorklet PCM 实时采集噪音和模拟调试
前端·javascript·electron·pcm
拉不动的猪4 小时前
图文引用打包时的常见情景解析
前端·javascript·后端
浩男孩4 小时前
🍀继分页器组件后,封装了个抽屉组件
前端
Dolphin_海豚4 小时前
@vue/reactivity
前端·vue.js·面试
该用户已不存在4 小时前
程序员的噩梦,祖传代码该怎么下手?
前端·后端
namehu4 小时前
前端性能优化之:图片缩放 🚀
前端·性能优化·微信小程序
摸鱼的春哥4 小时前
【编程】是什么编程思想,让老板对小伙怒飙英文?Are you OK?
前端·javascript·后端