0

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ý:

  1. "Ủa, take(1) đã tự đóng stream và unsubscribe ngay khi nhận giá trị đầu tiên rồi, gắn thêm takeUntilDestroyed để làm gì cho thừa?"
  2. "Nếu take(1) đứng trước mà API chưa kịp trả về dữ liệu (chưa emit), thì cái takeUntilDestroyed nằm phía sau có kịp chạy khi component bị hủy không? Thứ tự trong pipe() 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)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àm subscribe(). Đ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. takeUntilDestroyed lậ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 Angular HttpClient, 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ái canceled trong 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]
  1. 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ị \rightarrow đi qua take(1) \rightarrow đi qua takeUntilDestroyed \rightarrow vào hàm subscribe.
  2. 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, takeUntilDestroyed nhậ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 cho take(1) dừng lại, rồi đi tiếp lên HTTP Request Source để ngắt kết nố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?

  1. takeUntilDestroyed nhận tín hiệu hủy.
  2. Nó truyền lệnh hủy ngược lên trên \rightarrow ngắt searchTerm$.
  3. Nhưng switchMap và cái inner Observable bên trong nó (this.searchService.search) nằm ở phía sau takeUntilDestroyed. Lệnh hủy truyền ngược dòng sẽ không bao giờ chạm tới được chúng.
  4. 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.onDestroy là API cấp thấp của Angular, thay thế cho lifecycle hook ngOnDestroy. 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.
  • takeUntilDestroyed thực chất chỉ là một tiện ích bọc lại (wrapper). Bên trong source code của Angular, takeUntilDestroyed cũng chỉ gọi this.destroyRef.onDestroy để phát tín hiệu complete cho 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 \rightarrow Dùng takeUntilDestroyed.
  • Đang làm việc với Native API, Browser Event, hoặc Third-party Libs \rightarrow 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?

  1. 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.
  2. 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.
  3. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí