Rò rỉ bộ nhớ - Sát thủ vô hình

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:

  1. Thêm 10 liên hệ → Tạo ra 10 trình lắng nghe sự kiện click
  2. Xóa tất cả → DOM thẻ bị gỡ bỏ, nhưng trình lắng nghe vẫn còn
  3. 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:

  • addEventListener khiế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:

  1. Mở trang, thêm 3 thẻ liên hệ
  2. Xóa tất cả thẻ
  3. Kích hoạt thu gom rác thủ công (GC)
  4. 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 addEventListenersetInterval
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.

Read more