Listener không được gỡUnremoved event listener
Đăng ký mà không huỷSubscriptions that outlive their ownerMỗi lần mount mà không có cleanup tương ứng là thêm một listener ma, kéo theo cả component đã chết.Every mount without a matching cleanup adds a ghost listener that drags the dead component along.
- Gặp ởSeen in
- SPA chuyển route qua lại nhiều lần, mỗi lần mount lại cùng một màn hình.An SPA where users bounce between routes, remounting the same screen.
- ĐượcYou gain
- Heap quay về mức nền sau mỗi lần unmount.The heap returns to baseline after every unmount.
- MấtYou pay
- removeEventListener cần đúng cùng tham chiếu hàm; hàm inline tạo mới mỗi lần render sẽ không gỡ được.removeEventListener needs the exact same function reference; an inline function recreated each render will not match.
Đánh đổiTrade-off Chọn gỡ listener tường minh là đổi vài dòng cleanup trong mỗi effect lấy một heap ổn định bất kể người dùng điều hướng bao nhiêu lần.Choosing explicit listener removal trades a few cleanup lines per effect for a stable heap no matter how often users navigate.
Dấu hiệuTell-tale sign: Trong heap snapshot, số instance của một component tăng đúng bằng số lần bạn mở rồi đóng nó.In a heap snapshot, the instance count of a component grows by exactly the number of times you opened and closed it.
Cơ chế, code và mẹoMechanism, code and tips
Nó là gìWhat it is. Component đăng ký listener lên một đối tượng sống lâu hơn nó — window, document, một store hay socket — nhưng không gỡ ra khi unmount. Listener là một closure, nên nó giữ lại component cùng mọi thứ component đang tham chiếu.A component registers a listener on something that lives longer than it does — window, document, a store, a socket — and never removes it on unmount. The listener is a closure, so it keeps the component and everything the component references.
// Leaks: nothing removes the listener
useEffect(() => {
window.addEventListener('resize', onResize);
}, []);
// Fixed: one abort() removes every listener tied to the signal
useEffect(() => {
const ctrl = new AbortController();
window.addEventListener('resize', onResize, { signal: ctrl.signal });
return () => ctrl.abort();
}, []);
Hoạt động thế nàoHow it works. GC chỉ thu hồi những gì không còn đường tham chiếu từ root, và window chính là một root. Chuỗi giữ chặt trông thế này: window → danh sách listener → closure → state, props, DOM của component đã unmount. Chừng nào listener còn đó, cả chuỗi còn sống.The GC only frees what is unreachable from a root, and window is a root. The retaining chain is: window → listener list → closure → state, props and DOM of the unmounted component. As long as the listener stays, so does the whole chain.
Mẹo triển khaiImplementation tips
- Ưu tiên option { signal } của AbortController: không phải giữ tham chiếu hàm, một lệnh abort() gỡ hết.
- Trong console của Chrome, getEventListeners(window) cho biết đang có bao nhiêu listener trên window.
- Test leak nhanh nhất: mở rồi đóng màn hình 20 lần, chụp heap, so với snapshot đầu tiên.
- Prefer the { signal } option: no function reference to keep, one abort() removes everything.
- In the Chrome console, getEventListeners(window) shows how many listeners window currently holds.
- Fastest leak test: open and close the screen 20 times, take a heap snapshot, compare with the first one.