Hiểu về dọn dẹp bộ nhớ trong Angular hiện đại: Khi nào dùng `onDestroy`, khi nào dùng `takeUntilDestroyed`?
Có một đoạn code mà bạn sẽ gặp rất nhiều trong các codebase Angular từ phiên bản 16 trở đi:
this.userService.getProfile()
.pipe(
take(1),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(profile => {
this.userProfile = profile;
});
Nhìn lướt qua, không ít người sẽ đặt ra hai câu hỏi rất hợp lý:
- "Ủa,
take(1)đã tự đóng stream và unsubscribe ngay khi nhận giá trị đầu tiên rồi, gắn thêmtakeUntilDestroyedđể làm gì cho thừa?" - "Nếu
take(1)đứng trước mà API chưa kịp trả về dữ liệu (chưa emit), thì cáitakeUntilDestroyednằm phía sau có kịp chạy khi component bị hủy không? Thứ tự trongpipe()có ý nghĩa gì ở đây?"
Nếu bạn chỉ đọc documentation của từng operator riêng lẻ, câu trả lời thường là: "đây là best practice để tránh memory leak". Nhưng câu trả lời đó chưa đủ thỏa đáng.
Vấn đề thực sự nằm ở khoảng mù thời gian (timing gap) và cách RxJS truyền tín hiệu hủy ngược dòng.
1. Khoảng mù thời gian: Khi take(1) trở nên vô dụng
take(1) là một operator tuyệt vời: nó lấy đúng một giá trị rồi tự trigger complete. Nhưng nó chỉ làm được điều đó sau khi đã có ít nhất một giá trị đi qua nó.
Hãy tưởng tượng kịch bản này trên production:
Người dùng đang ở trang chi tiết sản phẩm. Trang này gọi một API lấy thông tin tốn khoảng 2 giây. Nhưng mới được 500ms, họ nhận ra mình vào nhầm và lập tức bấm Back quay lại trang chủ.
Lúc này, Component chi tiết sản phẩm đã bị Angular hủy (destroy) hoàn toàn khỏi DOM.
Chuyện gì xảy ra với subscription của bạn?
-
Nếu chỉ dùng
take(1): Vì chưa có dữ liệu nào trả về,take(1)vẫn đang kiên nhẫn nằm chờ. Subscription vẫn sống. 1.5 giây sau, backend trả về kết quả.take(1)thấy giá trị, chuyển tiếp nó vào hàmsubscribe(). Đoạn code cập nhật state chạy, cố gắng kích hoạt Change Detection hoặc set dữ liệu cho một component đã "chết". Nhẹ thì tốn RAM và CPU vô ích, nặng thì sinh ra lỗi logic ngầm mà bạn rất khó tái hiện khi debug ở môi trường local mạng nhanh. -
Khi có thêm
takeUntilDestroyed: Ngay tại thời điểm 500ms khi người dùng chuyển trang, component bị hủy.takeUntilDestroyedlập tức gửi tín hiệu: "Dừng lại, hủy đăng ký ngay". Không có callback nào trong.subscribe()được chạy nữa. Đặc biệt, với AngularHttpClient, việc hủy subscription này sẽ kích hoạt luôn việc abort HTTP request ở tầng trình duyệt (bạn sẽ thấy request chuyển sang trạng tháicanceledtrong tab Network).
takeUntilDestroyed ở đây không hề thừa. Nó là tấm lưới bảo hiểm cho trường hợp: Component chết trước khi dữ liệu kịp tới.
2. Dữ liệu đi xuôi, nhưng lệnh hủy đi ngược
Câu hỏi tiếp theo: Tại sao take(1) đứng trước mà takeUntilDestroyed đứng sau vẫn có thể can thiệp được khi take(1) chưa xong việc? Thứ tự trong pipe() không có tác dụng sao?
Có, thứ tự trong pipe() cực kỳ quan trọng. Nhưng để hiểu tại sao nó hoạt động, ta cần thay đổi mental model về RxJS.
Một chuỗi pipe() không chỉ là một cái ống dẫn nước một chiều từ nguồn xuống đích. Nó hoạt động theo hai chiều ngược nhau:
[HTTP Request Source] ──(1. Dữ liệu chảy XUỐNG)──> [take(1)] ─────────> [takeUntilDestroyed] ──> subscribe()
[HTTP Request Source] <──(2. Lệnh hủy truyền NGƯỢC)── [take(1)] <─── [takeUntilDestroyed]
- Chiều xuôi (Downstream - Dữ liệu): Chạy từ trên xuống dưới (hoặc trái qua phải). Nguồn phát giá trị đi qua
take(1)đi quatakeUntilDestroyedvào hàmsubscribe. - Chiều ngược (Upstream - Đăng ký & Hủy bỏ): Chạy từ dưới lên trên.
- Ngay khi bạn gọi
.subscribe(), kết nối được thiết lập ngược từ dưới lên trên. Tất cả các operator trong chuỗi đều được kích hoạt và nằm ở trạng thái lắng nghe cùng một lúc. - Khi Component bị hủy,
takeUntilDestroyednhận tín hiệu từ Angular. Thay vì đẩy dữ liệu xuống, nó gửi lệnh teardown/unsubscribe ngược lên trên. - Lệnh này đi ngược qua
take(1), báo chotake(1)dừng lại, rồi đi tiếp lênHTTP Request Sourceđể ngắt kết nối.
- Ngay khi bạn gọi
Đó là lý do dù đứng sau, takeUntilDestroyed vẫn có thể "giật dây" hủy toàn bộ chuỗi phía trước nó.
3. Cái bẫy vị trí: Tại sao luôn phải đặt ở cuối cùng?
Chính vì cơ chế truyền ngược dòng này, vị trí của takeUntilDestroyed trong pipe quyết định thứ gì được dọn dẹp và thứ gì bị bỏ quên.
Hãy xem ví dụ với switchMap:
// ❌ CÁCH VIẾT SAI: Đặt takeUntilDestroyed ở trước
this.searchTerm$.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(term => this.searchService.search(term))
).subscribe(results => this.results = results);
Điều gì xảy ra khi component bị hủy trong lúc this.searchService.search(term) đang chạy?
takeUntilDestroyednhận tín hiệu hủy.- Nó truyền lệnh hủy ngược lên trên ngắt
searchTerm$. - Nhưng
switchMapvà cái inner Observable bên trong nó (this.searchService.search) nằm ở phía sautakeUntilDestroyed. Lệnh hủy truyền ngược dòng sẽ không bao giờ chạm tới được chúng. - Kết quả: API tìm kiếm bên trong vẫn chạy tiếp, dữ liệu trả về vẫn cố emit vào subscriber. Leak vẫn hoàn leak.
Quy tắc vàng: Bất kỳ operator nào làm nhiệm vụ ngắt luồng (takeUntil, takeUntilDestroyed) đều phải nằm ở vị trí CUỐI CÙNG của .pipe(). Đặt ở cuối để khi nó giật dây hủy, toàn bộ các operator và inner stream đứng trước nó đều bị kéo sập đồng loạt.
4. takeUntilDestroyed vs destroyRef.onDestroy: Đừng nhầm lẫn sân chơi
Từ Angular 16, chúng ta còn có một API khác:
this.destroyRef.onDestroy(() => {
window.removeEventListener('online', this.handleOnline);
window.removeEventListener('offline', this.handleOffline);
});
Cả hai cách đều dùng destroyRef. Vậy ranh giới nằm ở đâu?
destroyRef.onDestroylà API cấp thấp của Angular, thay thế cho lifecycle hookngOnDestroy. Nó nhận vào một callback thuần JavaScript. Nó không quan tâm bạn dùng thư viện gì. Bất kỳ thứ gì cần cleanup bằng tay: DOM Event listener,setInterval, đóng WebSocket, hủy instance của Chart.js/Leaflet... bạn nhét vào đây.takeUntilDestroyedthực chất chỉ là một tiện ích bọc lại (wrapper). Bên trong source code của Angular,takeUntilDestroyedcũng chỉ gọithis.destroyRef.onDestroyđể phát tín hiệucompletecho stream. Nó sinh ra để bạn không phải viết code subscribe/unsubscribe thủ công trong thế giới RxJS.
Một quy tắc phân chia rất đơn giản trong thực tế:
- Đang làm việc với RxJS stream Dùng
takeUntilDestroyed. - Đang làm việc với Native API, Browser Event, hoặc Third-party Libs Dùng
destroyRef.onDestroy.
(Tất nhiên, nếu bạn bọc window.addEventListener thành RxJS bằng fromEvent(window, 'online'), thì lúc đó bạn lại quay về dùng takeUntilDestroyed được bình thường).
Tóm lại: Bạn nên mang gì vào dự án hôm nay?
take(1)không thay thế được cơ chế unsubscribe khi component bị hủy. Nó chỉ đóng stream khi có dữ liệu đến. Nếu có rủi ro component bị destroy trong lúc stream đang pending, hãy dùng cả hai.- RxJS xử lý dữ liệu từ trên xuống, nhưng truyền lệnh hủy từ dưới lên. Hiểu được điều này, bạn sẽ tự tin giải thích được tại sao các operator teardown lại hoạt động được dù nằm sau.
- Luôn đặt
takeUntilDestroyedở dòng cuối cùng của.pipe(), ngay trước khi bước vào.subscribe(). Đặt sai vị trí đồng nghĩa với việc bạn tự mở đường cho memory leak ở các inner stream phía sau.
All rights reserved