原创 四月友人A 2025-08-23 09:01 重庆
昨天被产品经理叫到办公室,说用户反馈我们的后台管理系统越用越卡,Chrome 任务管理器显示内存占用已经飙到 2GB 了。我 tm 当场就懵了,这不是在打我脸吗?
不要再用 removeEventListener 了!这个 API 救了我的命昨天被产品经理叫到办公室,说用户反馈我们的后台管理系统越用越卡,Chrome 任务管理器显示内存占用已经飙到 2GB 了。我 tm 当场就懵了,这不是在打我脸吗?回到工位一番排查,发现罪魁祸首竟然是那些没清理干净的事件监听器。看着满屏的
addEventListener和对应的清理代码,我突然想起了之前看到过但一直没用的AbortController。试了一下,卧槽,真香。先看看我写的这坨屎这种写法有多恶心?我来告诉你:「写到手酸」 - 每个方法都得 bind 一遍,复制粘贴都嫌烦「容易遗漏」 - 加了事件监听器,销毁的时候经常忘记清理某几个「维护困难」 - 想加个新事件?得在两个地方改代码最要命的是,这个 DataGrid 会被频繁创建销毁(用户切换页面、筛选数据等),每次忘记清理就是一次内存泄漏。AbortController 拯救了我的职业生涯// 我之前写的"杰作",现在看着都想删库跑路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);}}
你没看错,destroy 方法只需要一行代码。当初看到这个效果时,我特么激动得想发朋友圈。线上踩坑记录不过用 AbortController 也不是一帆风顺的。记得刚开始用的时候,我直接这样写: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();}}
结果 modal 第二次打开的时候,ESC 键失效了。原因很简单:// 错误示范,别学我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!}}
controller.abort()之后,这个 controller 就废了,不能重复使用。正确的写法应该是:真实项目:拖拽排序的坑前段时间做一个看板功能,需要实现卡片拖拽排序。用传统方式写的话,光是事件监听器的管理就能把人逼疯: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();// 重新创建一个新的controllerthis.controller = new AbortController();}}
这种写法的好处是,每次拖拽开始时创建独立的 controller,拖拽结束时自动清理相关事件。不会出现事件监听器累积的问题。以前用传统方式,我得手动管理 mousemove 和 mouseup 的清理,经常出现拖拽结束后事件还在监听的 bug。React 项目中的应用在 React 项目里,我封装了一个 hook: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;// 只在容器上监听mousedownthis.container.addEventListener('mousedown', (e) => {const card = e.target.closest('.card');if (!card) return;this.startDrag(card, e);}, { signal });}startDrag(card, startEvent) {// 为每次拖拽创建独立的controllerconst 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();}}
兼容性和实际使用建议AbortController 在主流浏览器中支持得还不错,Chrome 66+、Firefox 57+、Safari 11.1 + 都能用。我们项目的用户主要是企业客户,浏览器版本都比较新,所以直接用了。如果你需要兼容老浏览器,可以加个简单的判断: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 这个 API 我很早就知道,但一直以为只能用来取消 fetch 请求。直到那次内存泄漏的事故,我才真正开始研究它的其他用法。现在回头看,这个 API 真的改变了我写事件处理代码的方式。代码变得更简洁,bug 更少,维护成本也大大降低。当然,不是说传统的 addEventListener 就一无是处。在某些需要精确控制单个事件监听器的场景下,传统方式可能还是有必要的。但对于大部分日常开发,AbortController 绝对是更好的选择。如果你也经常被事件监听器的管理搞得头疼,试试这个方法吧。保证你用了就回不去了。❝写这篇文章的时候,我又想起了那个 2GB 内存占用的 bug。现在想想,要是早点用 AbortController,也不至于被产品经理叫到办公室 "喝茶" 了。😅❞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 = [];}}}
AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~
