Ứng dụng của bạn chạy ổn định trong mười phút. Sau đó, thao tác cuộn bắt đầu bị giật. Sau nửa giờ, tab tiêu tốn tới một gigabyte bộ nhớ. Cuối cùng, trang web bị sập với lỗi out-of-memory và bạn chẳng có lấy một stack trace nào để kiểm tra.
Đây không phải là vấn đề về hiệu suất render. React DevTools Profiler sẽ trông có vẻ bình thường vì vấn đề không nằm ở việc các component vẽ lại (redraw) thường xuyên như thế nào. Vấn đề nằm ở những gì vẫn còn tồn tại sau khi chúng bị unmount. Một tham chiếu lạc lõng đâu đó trong JavaScript heap đang giữ chặt cả một cây gồm các DOM node, closures và state. Trình duyệt không thể thu hồi bất kỳ thứ gì trong số đó, vì vậy bộ nhớ cứ tăng dần cho đến khi tiến trình bị sụp đổ.
Đọc mã nguồn sẽ không giúp bạn tìm ra lỗi rò rỉ. Lỗi nằm ở khoảng cách giữa những gì bạn nghĩ đã unmount và những gì bộ thu gom rác (garbage collector) thực sự nhìn thấy. V8 chỉ giải phóng các đối tượng có số đường dẫn duy trì (retaining paths) bằng không. Nếu một event listener bị bỏ sót, một observer chưa được xóa, hoặc một closure tồn tại lâu dài giữ dù chỉ một con trỏ tới một fiber hoặc một DOM node, thì toàn bộ cây con của component đó vẫn sẽ tồn tại. Bạn unmount một modal, nhưng các node bị tách rời (detached nodes) của nó vẫn nằm trong bộ nhớ vì một listener trên window vẫn trỏ tới một handler được định nghĩa bên trong modal đó.
Để chứng minh lỗi rò rỉ nằm ở đâu, bạn cần nhìn vào heap, chứ không phải trình soạn thảo mã nguồn.
Tại sao Heap lại nói lên sự thật
Chrome DevTools cung cấp cho bạn một cái nhìn trực tiếp vào những gì bộ thu gom rác nhìn thấy. Tab Memory có thể ghi lại các heap snapshots: danh mục đầy đủ của mọi đối tượng, DOM node và closure hiện đang được giữ trong bộ nhớ JavaScript. Bằng cách so sánh hai snapshot—một trước khi nghi ngờ có rò rỉ và một sau đó—bạn có thể cô lập chính xác những đối tượng nào đã không được giải phóng.
Đây không phải là lý thuyết trừu tượng. Một component React bị rò rỉ duy nhất có thể giữ lại hàng ngàn đối tượng HTMLElement bị tách rời. Những đối tượng này không còn gắn với tài liệu (document) đang hiển thị, nhưng các tham chiếu JavaScript ngăn cản chúng được thu gom. Chúng
Các observer chưa được dọn dẹp. IntersectionObserver và ResizeObserver rất mạnh mẽ, nhưng chúng tạo ra các tham chiếu gốc (native references) nằm ngoài sự kiểm soát của React. Nếu bạn khởi tạo một observer bên trong một component và quên gọi disconnect() trong giai đoạn dọn dẹp (cleanup phase), observer đó sẽ giữ lấy DOM node mục tiêu, và DOM node đó lại giữ các React fibers, props và state.
Bẫy closure. Khi bạn định nghĩa một hàm bên trong một component và truyền nó vào một thư viện bên thứ ba, một bộ nhớ đệm toàn cục (global cache), hoặc thậm chí là setTimeout, hàm đó sẽ đóng gói (closes over) mọi biến trong phạm vi từ vựng (lexical scope) của nó. Nếu chủ sở hữu bên ngoài vẫn giữ hàm đó, nó cũng sẽ giữ luôn toàn bộ phạm vi component của bạn.
Các mô hình dọn dẹp thực sự hiệu quả
Khắc phục rò rỉ bộ nhớ có nghĩa là cắt đứt mọi đường dẫn duy trì (retaining path) mà bạn tìm thấy trong bản chụp (snapshot).
Luôn trả về một hàm dọn dẹp từ useEffect. Nếu bạn thêm một listener trong effect, hãy xóa nó tại đó.
Sử dụng useCallback cho bất kỳ handler nào bạn gắn vào DOM hoặc window. Nếu không có nó, mỗi lần render sẽ tạo ra một tham chiếu hàm mới. Nếu bạn gọi addEventListener với một tham chiếu và sau đó gọi removeEventListener với một tham chiếu khác, việc xóa sẽ thất bại một cách âm thầm. Listener ban đầu sẽ tồn tại vĩnh viễn trên window. useCallback giúp giữ tham chiếu ổn định để việc thêm và xóa khớp chính xác với nhau.
Xử lý các observer với cùng một kỷ luật như vậy. Lưu trữ instance của observer trong một ref hoặc biến cục bộ bên trong effect. Trong hàm dọn dẹp, hãy gọi observer.disconnect(). Đừng mặc định rằng việc unmount component sẽ tiêu diệt observer. Nó không làm vậy.
Nếu component của bạn công khai bất kỳ thứ gì lên một namespace toàn cục hoặc một singleton service, hãy xóa các tham chiếu đó khi unmount. Công cụ V8 (V8 engine) chỉ có thể thu hồi bộ nhớ khi một đối tượng thực sự không thể truy cập được nữa. Việc để lại một hook trên window hoặc một mục trong một Map ở cấp độ module sẽ tạo ra một chiếc cầu nối vô hình khiến heap liên tục tăng lên.
Bài học thực sự
Rò rỉ bộ nhớ không làm ứng dụng của bạn bị sập ngay lập tức. Chúng tích tụ dần qua từng node bị tách rời (detached node) trong các phiên làm việc dài của người dùng. Cách khắc phục không phải là nâng cấp thư viện hay dùng một cờ biên dịch (compiler flag). Đó là thói quen chứng minh logic dọn dẹp của bạn bằng các bản chụp heap (heap snapshots).
Hãy lấy một mức cơ sở (baseline), kích hoạt luồng nghi ngờ, ép buộc thu gom rác (garbage collection) và so sánh. Nếu độ chênh lệch (delta) cho thấy sự tăng trưởng, hãy kiểm tra đường dẫn duy trì, tìm listener hoặc observer không nên tồn tại và cắt đứt tham chiếu đó. Chạy lại bài kiểm tra. Khi độ chênh lệch giữ ở mức đi ngang, nghĩa là bạn đã thực sự giải quyết được vấn đề. Ứng dụng của bạn sẽ duy trì được sự phản hồi, và người dùng sẽ không bị mất dữ liệu do tab trình duyệt bị đóng băng.
