昨天被產(chǎn)品經(jīng)理叫到辦公室,說用戶反饋我們的后臺(tái)管理系統(tǒng)越用越卡,Chrome任務(wù)管理器顯示內(nèi)存占用已經(jīng)飆到2GB了。我tm當(dāng)場(chǎng)就懵了,這不是在打我臉嗎?
回到工位一番排查,發(fā)現(xiàn)罪魁禍?zhǔn)拙谷皇悄切]清理干凈的事件監(jiān)聽器??粗鴿M屏的addEventListener
和對(duì)應(yīng)的清理代碼,我突然想起了之前看到過但一直沒用的AbortController
。
試了一下,臥槽,真香。
先看看我寫的這坨屎
export default class DataGrid {
constructor(container, options) {
this.container = container;
this.options = options;
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);
if (this.resizeTimer) clearTimeout(this.resizeTimer);
if (this.scrollTimer) clearTimeout(this.scrollTimer);
}
}
這種寫法有多惡心?我來告訴你:
- 寫到手酸 - 每個(gè)方法都得bind一遍,復(fù)制粘貼都嫌煩
- 容易遺漏 - 加了事件監(jiān)聽器,銷毀的時(shí)候經(jīng)常忘記清理某幾個(gè)
- 維護(hù)困難 - 想加個(gè)新事件?得在兩個(gè)地方改代碼
最要命的是,這個(gè)DataGrid會(huì)被頻繁創(chuàng)建銷毀(用戶切換頁(yè)面、篩選數(shù)據(jù)等),每次忘記清理就是一次內(nèi)存泄漏。
AbortController拯救了我的職業(yè)生涯
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();
}
}
你沒看錯(cuò),destroy方法只需要一行代碼。當(dāng)初看到這個(gè)效果時(shí),我特么激動(dòng)得想發(fā)朋友圈。
線上踩坑記錄
不過用AbortController也不是一帆風(fēng)順的。記得剛開始用的時(shí)候,我直接這樣寫:
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();
}
}
結(jié)果modal第二次打開的時(shí)候,ESC鍵失效了。原因很簡(jiǎn)單:controller.abort()
之后,這個(gè)controller就廢了,不能重復(fù)使用。
正確的寫法應(yīng)該是:
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();
this.controller = new AbortController();
}
}
真實(shí)項(xiàng)目:拖拽排序的坑
前段時(shí)間做一個(gè)看板功能,需要實(shí)現(xiàn)卡片拖拽排序。用傳統(tǒng)方式寫的話,光是事件監(jiān)聽器的管理就能把人逼瘋:
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
// 只在容器上監(jiān)聽mousedown
this.container.addEventListener('mousedown', (e) => {
const card = e.target.closest('.card')
if (!card) return
this.startDrag(card, e)
}, { signal })
}
startDrag(card, startEvent) {
// 為每次拖拽創(chuàng)建獨(dú)立的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()
// 創(chuàng)建拖拽副本
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'
// 檢測(cè)插入位置
this.updateDropIndicator(e)
}, { signal })
// 拖拽結(jié)束
document.addEventListener('mouseup', (e) => {
this.endDrag(ghost)
// 自動(dòng)清理本次拖拽的所有事件
moveController.abort()
}, { signal, once: true })
// 防止文本選中
document.addEventListener('selectstart', (e) => {
e.preventDefault()
}, { signal })
// 防止右鍵菜單
document.addEventListener('contextmenu', (e) => {
e.preventDefault()
}, { signal })
}
destroy() {
this.dragController?.abort()
}
}
這種寫法的好處是,每次拖拽開始時(shí)創(chuàng)建獨(dú)立的controller,拖拽結(jié)束時(shí)自動(dòng)清理相關(guān)事件。不會(huì)出現(xiàn)事件監(jiān)聽器累積的問題。
以前用傳統(tǒng)方式,我得手動(dòng)管理mousemove和mouseup的清理,經(jīng)常出現(xiàn)拖拽結(jié)束后事件還在監(jiān)聽的bug。
React項(xiàng)目中的應(yīng)用
在React項(xiàng)目里,我封裝了一個(gè)hook:
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('按鈕被點(diǎn)了');
});
}, []);
return <button ref={buttonRef}>點(diǎn)我</button>;
}
兼容性和實(shí)際使用建議
AbortController在主流瀏覽器中支持得還不錯(cuò),Chrome 66+、Firefox 57+、Safari 11.1+都能用。我們項(xiàng)目的用戶主要是企業(yè)客戶,瀏覽器版本都比較新,所以直接用了。
如果你需要兼容老瀏覽器,可以加個(gè)簡(jiǎn)單的判斷:
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 = [];
}
}
}
最后
說實(shí)話,AbortController這個(gè)API我很早就知道,但一直以為只能用來取消fetch請(qǐng)求。直到那次內(nèi)存泄漏的事故,我才真正開始研究它的其他用法。
現(xiàn)在回頭看,這個(gè)API真的改變了我寫事件處理代碼的方式。代碼變得更簡(jiǎn)潔,bug更少,維護(hù)成本也大大降低。
當(dāng)然,不是說傳統(tǒng)的addEventListener就一無是處。在某些需要精確控制單個(gè)事件監(jiān)聽器的場(chǎng)景下,傳統(tǒng)方式可能還是有必要的。但對(duì)于大部分日常開發(fā),AbortController絕對(duì)是更好的選擇。
如果你也經(jīng)常被事件監(jiān)聽器的管理搞得頭疼,試試這個(gè)方法吧。保證你用了就回不去了。
轉(zhuǎn)自https://juejin.cn/post/7533211262761009188
該文章在 2025/8/5 8:36:28 編輯過