Tab trình duyệt của người dùng bị treo sau ba mươi phút. Giao diện (UI) bị giật lag. Sau đó, trình duyệt bị sập với lỗi hết bộ nhớ (out-of-memory error).
Bạn xem xét mã nguồn của component từng dòng một và không thấy có gì sai sót. Đó chính là điều gây ức chế nhất về rò rỉ bộ nhớ trong React. Lỗi không nằm ở cú pháp JSX hay logic hook của bạn. Nó nằm ở khoảng cách giữa component của bạn và bộ thu gom rác (garbage collector) của trình duyệt. Một event listener lạc lõng hoặc một closure tồn tại lâu ngăn cản bộ thu gom giải phóng bộ nhớ. Bạn unmount một component, nhưng một tham chiếu duy nhất còn sót lại khiến toàn bộ cây component vẫn tồn tại trong heap. Bạn không thể tìm thấy những lỗi rò rỉ này chỉ bằng cách đọc mã nguồn. Vấn đề luôn ẩn nấp dưới bề mặt, vô hình trước mắt và im lặng trong các bài kiểm tra đơn vị (unit tests).
Tại sao rò rỉ bộ nhớ lại ẩn nấp ngay trước mắt
Các engine JavaScript như V8 quản lý bộ nhớ một cách tự động. Khi không còn đường dẫn tham chiếu nào từ root đến một object, engine sẽ đánh dấu object đó là rác và thu hồi không gian đó. Quá trình này hoạt động tốt cho đến khi một tham chiếu ẩn tồn tại lâu hơn dự kiến của bạn.
Trong React, nguy cơ thường xuất hiện tại ranh giới giữa các component và DOM. Bạn có thể gắn một listener resize vào window bên trong một modal, hoặc đăng ký (subscribe) một WebSocket trong một widget của dashboard. Khi người dùng đóng modal hoặc chuyển trang, component sẽ bị unmount. Nếu việc đăng ký vẫn còn tồn tại, engine sẽ thấy một tham chiếu hợp lệ từ đối tượng window toàn cục xuống trình xử lý (handler) của bạn, và từ trình xử lý đó quay ngược lại closure của component. Component, các props, state và toàn bộ cây con các node DOM của nó sẽ bị giữ chặt trong bộ nhớ. Qua hàng trăm lần tương tác, các đối tượng bị giữ chặt này tích tụ lại. Mức sử dụng bộ nhớ tăng theo mô hình hình răng cưa mà không bao giờ giảm xuống hoàn toàn.
Vấn đề của các Dashboard doanh nghiệp
Điều này xảy ra nhiều nhất trong các dashboard doanh nghiệp, nơi người dùng ở lại một trang trong nhiều giờ. Hãy nghĩ về các bảng điều khiển giám sát, chế độ xem phân tích hoặc các hệ thống quản lý ticket. Một người dùng mở một modal chi tiết, lọc một tập dữ liệu lớn hoặc chuyển đổi các tab trong một ứng dụng đơn trang (single-page app). Mỗi tương tác riêng lẻ đều có vẻ ổn. Tuy nhiên, theo thời gian, các node mồ côi và các listener bị tách rời sẽ tích tụ lại. Ứng dụng chậm lại không phải do một lần render tốn kém duy nhất, mà vì heap tăng lên đủ lớn để kích hoạt các khoảng dừng thu gom rác thường xuyên và tốn kém.
Đừng đoán mò về các hook useEffect của bạn nữa. Cách duy nhất để biết liệu có lỗi rò rỉ hay không là đo lường heap một cách trực tiếp. Chrome DevTools sẽ cung cấp cho bạn khả năng quan sát đó.
Săn tìm rò rỉ bộ nhớ với Chrome DevTools
Bạn cần một chuỗi các bước có thể tái lập và một vài phút tập trung cao độ. Mở ứng dụng của bạn trong Chrome, khởi chạy DevTools và chuyển sang tab Memory.
Ghi lại mức cơ sở (baseline). Chọn Heap snapshot và nhấn Take snapshot. Thao tác này sẽ chụp lại mọi đối tượng hiện đang tồn tại trong JavaScript heap và cho bạn thấy mức bộ nhớ ban đầu. Hãy thực hiện việc này sau khi trang đã ổn định ở trạng thái nghỉ ban đầu, không phải trong lúc đang tải trang, để bạn chỉ đo lường sự tăng trưởng do các hành động của người dùng gây ra.
Kích hoạt hành động. Thực hiện chính xác tương tác UI mà bạn nghi ngờ là nguyên nhân gây rò rỉ. Mở và đóng một modal, bật/tắt một biểu đồ phức tạp, hoặc thay đổi route rồi quay lại. Sau khi hoàn tất, hãy đưa ứng dụng về trạng thái hiển thị ban đầu. Bước này rất quan trọng. Bạn muốn UI trông giống hệt như lúc thực hiện snapshot cơ sở. Nếu heap đã tăng lên mặc dù UI trông có vẻ trống rỗng, bạn đã có bằng chứng mạnh mẽ về việc rò rỉ bộ nhớ.
Ép buộc thu gom rác (Force garbage collection). Nhấp vào biểu tượng thùng rác trong tab Memory. Thao tác này sẽ kích hoạt một chu kỳ GC đầy đủ và xóa các đối tượng tạm thời vốn dĩ vẫn tồn tại cho đến lần thu gom tiếp theo. Những gì còn lại chính là các lỗi rò rỉ thực sự — những đối tượng đáng lẽ đã được thu gom nhưng vẫn bị giữ lại bởi các tham chiếu vô ý.
Chụp snapshot thứ hai. Nhấp vào Take snapshot một lần nữa. Bây giờ bạn đã có hai "bức ảnh" về heap được chụp trong cùng một điều kiện UI.
So sánh kết quả. Thay đổi chế độ xem từ Summary sang Comparison. Thiết lập baseline là snapshot đầu tiên và snapshot so sánh là snapshot thứ hai. Chế độ Comparison sẽ liệt kê mọi danh mục đối tượng và cho bạn thấy delta — sự thay đổi thuần giữa số lượng đối tượng trong hai lần chụp.
Sắp xếp theo Delta. Tìm các danh mục tăng lên đáng kể. Đặc biệt tập trung vào Detached HTMLElement và các React fiber nodes. Một HTML element bị tách rời (detached) là một DOM node không còn được gắn vào cây tài liệu đang hoạt động, nhưng một tham chiếu JavaScript nào đó vẫn đang giữ nó. Đây chính là bằng chứng đanh thép. Chúng không nên tồn tại sau khi bạn đã đóng modal hoặc unmount một component.
Đọc Retaining Path
Khi bạn chọn một phần tử bị tách rời (detached element) trong snapshot, Chrome sẽ hiển thị đường dẫn giữ lại (retaining path) ở bảng phía dưới. Đường dẫn này là một chuỗi các tham chiếu từ gốc (root) xuống đến đối tượng được chọn. Hãy theo dõi nó thật kỹ. Bạn sẽ thường thấy một trình lắng nghe sự kiện (event listener), một IntersectionObserver, một ID setInterval, hoặc một closure trỏ đến một dòng cụ thể trong component của bạn.
Hãy tìm những cái tên mà bạn nhận ra. Nếu bạn thấy một listener được gắn vào window với tên hàm từ mã nguồn của mình, bạn đã tìm thấy điểm neo (anchor). Đối tượng đang giữ listener đó chính là thứ đang giữ cho toàn bộ component của bạn tồn tại. Đôi khi chuỗi này chạy qua một thư viện bên thứ ba. Trong những trường hợp đó, hãy kiểm tra xem thư viện có yêu cầu một lệnh hủy (teardown call) rõ ràng mà bạn đã quên gọi trong hàm cleanup hay không.
Khắc phục các nguyên nhân gốc rễ
Một khi bạn đã xác định được retaining path, việc khắc phục thường mang tính kỹ thuật lặp đi lặp lại nhưng đòi hỏi sự kỷ luật của cả đội ngũ.
Sử dụng các hàm cleanup. Luôn trả về một hàm cleanup trong useEffect khi bạn thêm các listener vào window hoặc document. Nếu effect của bạn đăng ký một sự kiện resize, hãy hủy đăng ký đó trước khi component unmount. Hàm cleanup sẽ chạy khi React dỡ bỏ (tear down) component, cung cấp cho bạn một hook đảm bảo để cắt đứt các kết nối bên ngoài.
Ổn định các tham chiếu. Bao bọc các handler của bạn trong useCallback. Điều này đảm bảo bạn truyền chính xác cùng một tham chiếu hàm vào removeEventListener giống như tham chiếu bạn đã truyền vào addEventListener ban đầu. Nếu bạn đăng ký một hàm inline như window.addEventListener('resize', () => { ... }) và sau đó cố gắng gỡ bỏ nó bằng một hàm inline khác, các tham chiếu sẽ không khớp nhau. Listener sẽ vẫn được gắn chặt. Closure bên trong nó sẽ giữ cho state của component luôn tồn tại. useCallback với một mảng dependency ổn định sẽ ngăn chặn sự sai lệch danh tính (identity mismatch) này.
Cắt đứt các liên kết toàn cục. Hãy nhớ rằng các đối tượng mục tiêu sự kiện (event target objects) của trình duyệt, như window và document, tồn tại trong suốt vòng đời của trang web. Bất kỳ tham chiếu nào từ chúng vào component của bạn đều đóng vai trò như một điểm neo toàn cục. Việc gỡ bỏ listener sẽ phá vỡ điểm neo đó và cho phép V8 engine dọn dẹp state của component và các DOM nodes trong chu kỳ garbage collection tiếp theo.
Hãy xem xét một modal theo dõi chiều rộng của cửa sổ. Nếu không có cleanup, mỗi khi người dùng mở modal, một listener mới sẽ được gắn vào. Những listener cũ không bao giờ được gỡ bỏ vì các instance của component mà chúng thuộc về đã biến mất, nhưng bản thân các hàm đó lại là hàm ẩn danh và bị thất lạc. Việc sử dụng một named handler được bao bọc trong useCallback, cộng với một hàm cleanup gọi removeEventListener, sẽ đóng vòng lặp một cách gọn gàng.
Bài học thực tế
Rò rỉ bộ nhớ (memory leaks) trong React hiếm khi tự thông báo bằng một thông báo lỗi rõ ràng. Chúng tự thông báo bằng một tab trình duyệt ngày càng trở nên nặng nề hơn khi mở càng lâu. Đừng lãng phí thời gian suy đoán xem hook nào là "thủ phạm". Hãy mở tab Memory, ép buộc garbage collection và so sánh các snapshot. Hãy để heap profiler chỉ cho bạn retaining path chính xác. Sau đó, hãy viết hàm cleanup, ổn định tham chiếu callback và cắt đứt liên kết toàn cục. Người dùng sẽ không nhận thấy sự khắc phục một cách trực tiếp, nhưng họ sẽ nhận thấy rằng dashboard vẫn chạy mượt mà cho đến cuối ngày.
