Rò rỉ bộ nhớ - Sát thủ vô hình
Có bao giờ bạn nhận được phản ánh từ khách hàng rằng trang web đang dùng mở ra vài phút là lại bị đơ, Task Manager của Chrome hiển thị memory tăng vọt lên 1.2GB. Vậy tại sao lại xảy ra vấn đề này?
Hãy thử xem qua đoạn mã này:
mounted() {
document.addEventListener('click', this.handleGlobalClick)
}
Bạn có thấy đoạn code vô hại này không? Chỉ là chúng ta đang lắng nghe sự kiện click thôi mà, có vấn đề gì ở đây nhỉ? Đó là việc thiếu đi removeEventListener , chúng ta chỉ gắn listener mà không xóa bỏ nó, rò rỉ bộ nhớ cứ thế âm thầm diễn ra và hạ gục trang web của bạn.
Vậy hãy cùng tôi tìm hiểu sâu hơn về vấn đề này nhé
I. “Ghost listener” trong biểu mẫu động
Chúng ta có hệ thống quản lý thông tin khách hàng cho phép người dùng thêm thẻ động. Mỗi thẻ đều lắng nghe sự kiện click toàn cục để tự đóng:

Quy trình thao tác của người dùng:
- Thêm 10 liên hệ → Tạo ra 10 trình lắng nghe sự kiện click
- Xóa tất cả → DOM thẻ bị gỡ bỏ, nhưng trình lắng nghe vẫn còn
- Lặp lại 5 lần → Tích lũy 50 trình lắng nghe "ma"
Mỗi lần click trên trang, 50 hàm này đều được thực thi. Tệ hơn - chúng đều giữ tham chiếu đến component đã bị hủy.
II. Tại sao không gỡ trình lắng nghe sự kiện lại gây rò rỉ bộ nhớ?
1. Trình nghe sự kiện không thể được thu hồi
Hãy nhìn qua sơ đồ quan hệ tham chiếu bộ nhớ:

Điểm then chốt:
addEventListenerkhiến phần tử đích (document) giữ tham chiếu đến hàm lắng nghe- Nếu hàm lắng nghe là phương thức của component, nó thường thông qua closure tham chiếu đến toàn bộ instance component
- Ngay cả khi component bị hủy, DOM bị gỡ bỏ, chỉ cần trình nghe chưa bị xóa, GC (Garbage Collector) sẽ không dám thu hồi instance này
Đây chính là mô hình rò rỉ điển hình do "tham chiếu vòng + tham chiếu từ đối tượng gốc bên ngoài" gây ra.
2. Reachability của V8
JavaScript engine (như V8) sử dụng reachability để xác định có thu hồi object hay không:
- Chỉ những object không thể truy cập được từ "đối tượng gốc" (root - như window, document) mới bị thu hồi.
Mà document.addEventListener() tương đương với việc gắn một chuỗi tham chiếu lên đối tượng gốc toàn cục document, khiến component đáng lẽ đã "chết" lại "sống lại từ cõi chết".
III. Bắt ma bằng Chrome DevTools
Mở Chrome DevTools→ Panel Memory → Take heap snapshot:
- Mở trang, thêm 3 thẻ liên hệ
- Xóa tất cả thẻ
- Kích hoạt thu gom rác thủ công (GC)
- Chụp nhanh heap lần nữa
Trong chế độ Comparison, bạn sẽ phát hiện:
- Detached
<div>: 3 phần tử đã tách khỏi DOM - Closure hoặc Function: 3 hàm xử lý sự kiện tương ứng
- VueComponent: 3 instance component chưa được thu hồi
Đây chính là những "component ma" - chúng đã không còn trên trang nhưng vẫn chiếm dụng bộ nhớ.
IV. Cú pháp đúng: Lắng nghe sự kiện và hủy sự kiện phải đi cùng nhau

Ngoài ra bạn có thể sử dụng tùy chọn sự kiện { once: true } để tự động dọn dẹp:

Nhưng trong các trường hợp polling hoặc lắng nghe liên tục, việc quản lý thủ công vẫn là bắt buộc.
V. Ngoài event listener, còn những nguyên nhân gây rò rỉ bộ nhớ phổ biến nào khác?
1. Timer chưa được dọn dẹp (phổ biến nhất)

Chuỗi tham chiếu tương tự: window → setInterval → callback → component
2. Tham chiếu closure đến object lớn

Chỉ cần hàm process tồn tại, hugeData sẽ không bị thu hồi.
3. Tham chiếu DOM chưa được giải phóng

Ngay cả khi component bị hủy, globalRef vẫn trỏ đến DOM cũ, và các sự kiện, thuộc tính liên quan của nó không thể được dọn dẹp.
4. Sử dụng WeakMap/WeakSet không đúng cách
Bạn nghĩ WeakMap có thể tự động dọn dẹp? Sai! Chỉ có khóa (key) là object mới có tham chiếu yếu:

Cách dùng đúng là sử dụng instance component làm key:

VI. Các framework chính giúp chúng ta tránh rò rỉ bộ nhớ như thế nào?
| Framework | Cơ chế tự động dọn dẹp | Trường hợp vẫn cần xử lý thủ công |
|---|---|---|
| Vue | Sự kiện template (@click) tự động hủy |
addEventListener, setInterval |
| React | useEffect trả về hàm dọn dẹp (tương tự beforeDestroy) |
Không return đúng hàm dọn dẹp |
| Angular | @HostListener tự động hủy binding |
Thao tác DOM nguyên bản, callback thư viện bên thứ ba |
Hãy nhớ: Framework chỉ có thể quản lý những sự kiện "nó biết". Một khi bạn sử dụng API nguyên bản, trách nhiệm lại thuộc về developer.
VII. Bonus: một vài cách xử lý vấn đề nâng cao
1. Rò rỉ callback từ SDK bên thứ ba
Như API bản đồ, trình phát video.
Giải pháp: Đóng gói instance SDK vào component, trong beforeDestroy gọi map.destroy() hoặc player.dispose()
2. Kết nối WebSocket chưa đóng

3. Texture Canvas/WebGL chưa được giải phóng
Tài nguyên đồ họa chiếm dụng trực tiếp bộ nhớ GPU. Cần thủ công gl.deleteTexture(), canvas.remove().
VIII. Checklist giúp bạn hạn chế viết code gây rò rỉ bộ nhớ
Mỗi khi viết code có thể gây rò rỉ, hãy tự hỏi:
✅ Có có hàm "dọn dẹp" tương ứng không?
✅ Chuỗi tham chiếu có kéo dài vòng đời object ngoài ý muốn không?
✅ Có thể tối ưu bằng WeakMap/WeakSet không?
✅ Có thể dùng { once: true } hoặc AbortController để quản lý tự động không?
Tổng kết
Rò rỉ bộ nhớ không phải là vấn đề "có xảy ra hay không" mà là "khi nào nó bùng phát". Nó giống như bệnh mãn tính, giai đoạn đầu không có dấu hiệu, đến khi người dùng phàn nàn về độ trễ thì thường đã tích tụ quá lâu.
Lập trình viên frontend tốt không phải là người viết animation hào nhoáng, mà là người có thể nhìn thấy vòng đời tài nguyên tiềm ẩn trong từng dòng code.