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

事件总线

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 函数都是"孤立"的,于是就会安全地将它们占用的内存回收。问题解决!

相关推荐
!win !16 小时前
前端跨标签页通信方案(上)
前端·javascript
xw516 小时前
前端跨标签页通信方案(上)
前端·javascript
烛阴16 小时前
Python数据可视化:从零开始教你绘制精美雷达图
前端·python
全栈前端老曹16 小时前
【前端组件封装教程】第3节:Vue 3 Composition API 封装基础
前端·javascript·vue.js·vue3·组合式api·组件封装
LinXunFeng16 小时前
Flutter 拖拉对比组件,换装图片前后对比必备
前端·flutter·开源
BD_Marathon16 小时前
【PySpark】安装测试
前端·javascript·ajax
stu_kk16 小时前
Ecology9明细表中添加操作按钮与弹窗功能技术分享
前端·oa
dkgee16 小时前
如何禁止Chrome的重新启动即可更新窗口弹窗提示
前端·chrome
天若有情67317 小时前
新闻通稿 | 软件产业迈入“智能重构”新纪元:自主进化、人机共生与责任挑战并存
服务器·前端·后端·重构·开发·资讯·新闻
香香爱编程17 小时前
electron对于图片/视频无法加载的问题
前端·javascript·vue.js·chrome·vscode·electron·npm