不要再用addEventListener了!这个API救了我的命

不要再用addEventListener了!这个API救了我的命

昨天被产品经理叫到办公室,说用户反馈我们的后台管理系统越用越卡,Chrome任务管理器显示内存占用已经飙到2GB了。我tm当场就懵了,这不是在打我脸吗?

回到工位一番排查,发现罪魁祸首竟然是那些没清理干净的事件监听器。看着满屏的addEventListener和对应的清理代码,我突然想起了之前看到过但一直没用的AbortController

试了一下,卧槽,真香。

先看看我写的这坨屎

kotlin 复制代码
// 我之前写的"杰作",现在看着都想删库跑路
export default class DataGrid {
  constructor(container, options) {
    this.container = container;
    this.options = options;
    
    // 绑定this,一个都不能少,不然就报错
    this.handleResize = this.handleResize.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.handleClick = this.handleClick.bind(this);
    this.handleKeydown = this.handleKeydown.bind(this);
    this.handleContextMenu = this.handleContextMenu.bind(this);
    
    this.init();
  }
  
  init() {
    // 事件监听器注册大会
    window.addEventListener('resize', this.handleResize);
    this.container.addEventListener('scroll', this.handleScroll);
    this.container.addEventListener('click', this.handleClick);
    document.addEventListener('keydown', this.handleKeydown);
    this.container.addEventListener('contextmenu', this.handleContextMenu);
    
    // 还有定时器要管理
    this.resizeTimer = null;
    this.scrollTimer = null;
  }
  
  destroy() {
    // 清理环节,经常漏几个
    window.removeEventListener('resize', this.handleResize);
    this.container.removeEventListener('scroll', this.handleScroll);
    this.container.removeEventListener('click', this.handleClick);
    document.removeEventListener('keydown', this.handleKeydown);
    // 草,contextmenu忘记清理了
    
    if (this.resizeTimer) clearTimeout(this.resizeTimer);
    if (this.scrollTimer) clearTimeout(this.scrollTimer);
  }
}

这种写法有多恶心?我来告诉你:

  1. 写到手酸 - 每个方法都得bind一遍,复制粘贴都嫌烦
  2. 容易遗漏 - 加了事件监听器,销毁的时候经常忘记清理某几个
  3. 维护困难 - 想加个新事件?得在两个地方改代码

最要命的是,这个DataGrid会被频繁创建销毁(用户切换页面、筛选数据等),每次忘记清理就是一次内存泄漏。

AbortController拯救了我的职业生涯

kotlin 复制代码
export default class DataGrid {
  constructor(container, options) {
    this.container = container;
    this.options = options;
    this.controller = new AbortController();
    
    this.init();
  }
  
  init() {
    const { signal } = this.controller;
    
    // 所有事件监听器统一管理,爽到飞起
    window.addEventListener('resize', (e) => {
      clearTimeout(this.resizeTimer);
      this.resizeTimer = setTimeout(() => this.handleResize(e), 200);
    }, { signal });
    
    this.container.addEventListener('scroll', (e) => {
      this.handleScroll(e);
    }, { signal, passive: true });
    
    this.container.addEventListener('click', (e) => {
      this.handleClick(e);
    }, { signal });
    
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Delete' && this.selectedRows.length > 0) {
        this.deleteSelectedRows();
      }
    }, { signal });
    
    this.container.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      this.showContextMenu(e);
    }, { signal });
  }
  
  destroy() {
    // 一行代码解决所有问题!
    this.controller.abort();
  }
}

你没看错,destroy方法只需要一行代码。当初看到这个效果时,我特么激动得想发朋友圈。

线上踩坑记录

不过用AbortController也不是一帆风顺的。记得刚开始用的时候,我直接这样写:

javascript 复制代码
// 错误示范,别学我
class Modal {
  show() {
    this.controller = new AbortController();
    const { signal } = this.controller;
    
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.hide();
    }, { signal });
  }
  
  hide() {
    this.controller.abort();
    // 没有重新创建controller!
  }
}

结果modal第二次打开的时候,ESC键失效了。原因很简单:controller.abort()之后,这个controller就废了,不能重复使用。

正确的写法应该是:

javascript 复制代码
class Modal {
  constructor() {
    this.controller = new AbortController();
  }
  
  show() {
    this.setupEvents();
  }
  
  setupEvents() {
    const { signal } = this.controller;
    
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.hide();
    }, { signal });
    
    document.addEventListener('click', (e) => {
      if (e.target === this.overlay) this.hide();
    }, { signal });
  }
  
  hide() {
    this.controller.abort();
    // 重新创建一个新的controller
    this.controller = new AbortController();
  }
}

真实项目:拖拽排序的坑

前段时间做一个看板功能,需要实现卡片拖拽排序。用传统方式写的话,光是事件监听器的管理就能把人逼疯:

ini 复制代码
class DragSort {
  constructor(container) {
    this.container = container;
    this.isDragging = false;
    this.dragElement = null;
    
    this.initDrag();
  }
  
  initDrag() {
    const dragController = new AbortController();
    this.dragController = dragController;
    const { signal } = dragController;
    
    // 只在容器上监听mousedown
    this.container.addEventListener('mousedown', (e) => {
      const card = e.target.closest('.card');
      if (!card) return;
      
      this.startDrag(card, e);
    }, { signal });
  }
  
  startDrag(card, startEvent) {
    // 为每次拖拽创建独立的controller
    const moveController = new AbortController();
    const { signal } = moveController;
    
    this.isDragging = true;
    this.dragElement = card;
    
    const startX = startEvent.clientX;
    const startY = startEvent.clientY;
    const rect = card.getBoundingClientRect();
    
    // 创建拖拽副本
    const ghost = card.cloneNode(true);
    ghost.style.position = 'fixed';
    ghost.style.left = rect.left + 'px';
    ghost.style.top = rect.top + 'px';
    ghost.style.pointerEvents = 'none';
    ghost.style.opacity = '0.8';
    document.body.appendChild(ghost);
    
    // 拖拽过程中的事件
    document.addEventListener('mousemove', (e) => {
      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;
      
      ghost.style.left = (rect.left + deltaX) + 'px';
      ghost.style.top = (rect.top + deltaY) + 'px';
      
      // 检测插入位置
      this.updateDropIndicator(e);
    }, { signal });
    
    // 拖拽结束
    document.addEventListener('mouseup', (e) => {
      this.endDrag(ghost);
      // 自动清理本次拖拽的所有事件
      moveController.abort();
    }, { signal, once: true });
    
    // 防止文本选中
    document.addEventListener('selectstart', (e) => {
      e.preventDefault();
    }, { signal });
    
    // 防止右键菜单
    document.addEventListener('contextmenu', (e) => {
      e.preventDefault();
    }, { signal });
  }
  
  destroy() {
    this.dragController?.abort();
  }
}

这种写法的好处是,每次拖拽开始时创建独立的controller,拖拽结束时自动清理相关事件。不会出现事件监听器累积的问题。

以前用传统方式,我得手动管理mousemove和mouseup的清理,经常出现拖拽结束后事件还在监听的bug。

React项目中的应用

在React项目里,我封装了一个hook:

javascript 复制代码
import { useEffect, useRef } from 'react';

function useEventController() {
  const controllerRef = useRef();
  
  useEffect(() => {
    controllerRef.current = new AbortController();
    
    return () => {
      controllerRef.current?.abort();
    };
  }, []);
  
  const addEventListener = (target, event, handler, options = {}) => {
    if (!controllerRef.current) return;
    
    const element = target?.current || target;
    if (!element) return;
    
    element.addEventListener(event, handler, {
      signal: controllerRef.current.signal,
      ...options
    });
  };
  
  return { addEventListener };
}

// 使用起来贼爽
function MyComponent() {
  const { addEventListener } = useEventController();
  const buttonRef = useRef();
  
  useEffect(() => {
    addEventListener(window, 'resize', (e) => {
      console.log('窗口大小变了');
    });
    
    addEventListener(buttonRef, 'click', (e) => {
      console.log('按钮被点了');
    });
  }, []);
  
  return <button ref={buttonRef}>点我</button>;
}

兼容性和实际使用建议

AbortController在主流浏览器中支持得还不错,Chrome 66+、Firefox 57+、Safari 11.1+都能用。我们项目的用户主要是企业客户,浏览器版本都比较新,所以直接用了。

如果你需要兼容老浏览器,可以加个简单的判断:

kotlin 复制代码
class EventManager {
  constructor() {
    this.useAbortController = 'AbortController' in window;
    
    if (this.useAbortController) {
      this.controller = new AbortController();
    } else {
      this.handlers = [];
    }
  }
  
  on(target, event, handler, options = {}) {
    if (this.useAbortController) {
      target.addEventListener(event, handler, {
        signal: this.controller.signal,
        ...options
      });
    } else {
      // 降级到传统方式
      this.handlers.push({ target, event, handler, options });
      target.addEventListener(event, handler, options);
    }
  }
  
  destroy() {
    if (this.useAbortController) {
      this.controller.abort();
    } else {
      this.handlers.forEach(({ target, event, handler, options }) => {
        target.removeEventListener(event, handler, options);
      });
      this.handlers = [];
    }
  }
}

最后

说实话,AbortController这个API我很早就知道,但一直以为只能用来取消fetch请求。直到那次内存泄漏的事故,我才真正开始研究它的其他用法。

现在回头看,这个API真的改变了我写事件处理代码的方式。代码变得更简洁,bug更少,维护成本也大大降低。

当然,不是说传统的addEventListener就一无是处。在某些需要精确控制单个事件监听器的场景下,传统方式可能还是有必要的。但对于大部分日常开发,AbortController绝对是更好的选择。

如果你也经常被事件监听器的管理搞得头疼,试试这个方法吧。保证你用了就回不去了。

ps:写这篇文章的时候,我又想起了那个2GB内存占用的bug。现在想想,要是早点用AbortController,也不至于被产品经理叫到办公室"喝茶"了。😅

相关推荐
JuneXcy12 分钟前
11.Layout-Pinia优化重复请求
前端·javascript·css
天下无贼!22 分钟前
【自制组件库】从零到一实现属于自己的 Vue3 组件库!!!
前端·javascript·vue.js·ui·架构·scss
PineappleCoder1 小时前
JS 作用域链拆解:变量查找的 “俄罗斯套娃” 规则
前端·javascript·面试
知识分享小能手1 小时前
Vue3 学习教程,从入门到精通,Vue3 中使用 Axios 进行 Ajax 请求的语法知识点与案例代码(23)
前端·javascript·vue.js·学习·ajax·vue·vue3
533_1 小时前
[echarts] 更新数据
前端·javascript·echarts
讨厌吃蛋黄酥1 小时前
利用Mock实现前后端联调的解决方案
前端·javascript·后端
zzywxc7872 小时前
在处理大数据列表渲染时,React 虚拟列表是提升性能的关键技术,但在实际实现中常遇到渲染抖动和滚动定位偏移等问题。
前端·javascript·人工智能·深度学习·react.js·重构·ecmascript
_Kayo_8 小时前
VUE2 学习笔记14 nextTick、过渡与动画
javascript·笔记·学习
咔咔一顿操作10 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3