Xử lý Race Condition: Làm chủ RxJS và Lập trình Reactive trong Angular

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Sự ức chế khi giao diện bị mất đồng bộ

Tôi từng dành sáu giờ đồng hồ để debug một thanh tìm kiếm như bị “ma ám”. Trên lý thuyết, logic rất đơn giản: người dùng nhập văn bản, tôi gọi API, và giao diện cập nhật. Nhưng trong quá trình kiểm tra, một lỗi kỳ lạ đã xảy ra. Nếu tôi gõ ‘iphone’ với tốc độ bình thường, kết quả cho từ ‘ipho’ đôi khi lại đến sau kết quả cho từ ‘iphone’. Điều này dẫn đến việc dữ liệu chính xác bị ghi đè bởi thông tin cũ. Giao diện của tôi bị nhấp nháy, hiển thị sai giá trị và cuối cùng là bị treo.

Có lẽ bạn cũng đã từng gặp trường hợp này. Đó có thể là một biểu mẫu nhiều bước, nơi Bước 3 phụ thuộc vào Bước 2, nhưng người dùng nhấp ‘Tiếp theo’ hai lần và kích hoạt việc gửi dữ liệu kép. Hoặc có thể bạn đang lấy dữ liệu từ ba microservices khác nhau, và mã nguồn của bạn trở thành một mạng lưới chằng chịt các lời gọi .subscribe() lồng nhau. Những mô hình này gần như không thể hủy bỏ hoặc quản lý hiệu quả.

Chúng ta đang đối mặt với vấn đề kinh điển về luồng dữ liệu bất đồng bộ. Dựa trên kinh nghiệm của tôi với các ứng dụng doanh nghiệp, việc làm chủ luồng dữ liệu này chính là ranh giới giữa việc xây dựng các bản mẫu ‘Hello World’ và việc phát hành các ứng dụng Angular tin cậy, mượt mà.

Nguyên nhân gốc rễ: Tư duy tuyến tính trong một thế giới song song

Logic mệnh lệnh (Imperative logic) thường là thiết lập mặc định của chúng ta: Làm A, sau đó làm B, rồi làm C. Điều này hoạt động hoàn hảo cho các tác vụ đồng bộ. Tuy nhiên, khi các sự kiện xảy ra ở các khoảng thời gian không thể đoán trước — như các cú nhấp chuột liên tục hoặc các tin nhắn WebSocket biến động — logic ‘sau đó’ sẽ bị phá vỡ.

Promises cung cấp rất ít khả năng kiểm soát đối với trạng thái trung gian. Một khi Promise đã được kích hoạt, bạn không thể dễ dàng hủy bỏ nó. Nếu người dùng thực hiện năm yêu cầu trong hai giây, một hệ thống dựa trên Promise không có cách nào tích hợp sẵn để bỏ qua bốn yêu cầu đầu tiên và chỉ quan tâm đến yêu cầu mới nhất. Sự thiếu kiểm soát này gây ra race conditions, nơi thứ tự hoàn thành không khớp với thứ tự ý định ban đầu.

Angular được xây dựng từ đầu trên nền tảng RxJS (Reactive Extensions cho JavaScript). Nếu chúng ta chống lại bản chất reactive này bằng cách sử dụng các cờ trạng thái thủ công và subscribe lồng nhau, chúng ta sẽ kết thúc with một mã nguồn mong manh và khó quản lý.

So sánh các giải pháp: Promises vs. RxJS Operators

Để hiểu tại sao RxJS là tiêu chuẩn công nghiệp cho Angular, hãy xem cách các phương pháp khác nhau xử lý một tính năng tìm kiếm đơn giản.

Cách 1: Cái bẫy của Promise

// Đoạn mã này trông sạch sẽ nhưng tiềm ẩn race condition
async onSearch(query: string) {
  this.loading = true;
  try {
    const results = await this.apiService.search(query).toPromise();
    this.results = results;
  } finally {
    this.loading = false;
  }
}

Vấn đề: Hãy tưởng tượng ‘ABC’ trả về sau 100ms trong khi ‘AB’ mất 500ms. Giao diện sẽ hiển thị kết quả cho ‘AB’ sau cùng. Người dùng của bạn sẽ thấy dữ liệu cũ, mặc dù họ đã gõ xong từ đầy đủ. Không có cách nào để dừng yêu cầu ‘AB’ một khi nó đang được thực hiện.

Cách 2: Subscribe lồng nhau

// Tránh mô hình "Callback Hell" này
this.searchSubject.subscribe(query => {
  this.apiService.search(query).subscribe(results => {
    this.results = results;
  });
});

Vấn đề: Đây thực chất là Cách 1 với nhiều mã thừa hơn. Bạn vẫn không có quyền kiểm soát race conditions. Tệ hơn nữa, bạn đang tạo ra rò rỉ bộ nhớ (memory leaks) vì các subscription này không được theo dõi hoặc đóng lại.

Cách 3: Các Higher-Order Mapping Operator

Đây là nơi RxJS tỏa sáng. Chúng ta sử dụng các operator để xử lý ‘inner observable’ (lời gọi API) dựa trên ‘outer observable’ (thao tác nhập của người dùng). Trong các dự án gần đây của tôi, việc chuyển sang mô hình này đã giảm các lỗi liên quan đến API gần 60%.

  • mergeMap: Kích hoạt mọi yêu cầu và giữ lại tất cả kết quả. Sử dụng cái này khi mọi hành động đơn lẻ đều phải hoàn thành, chẳng hạn như lưu danh sách các mục.
  • concatMap: Xếp hàng các yêu cầu. Nó đợi lời gọi API đầu tiên kết thúc trước khi bắt đầu lời gọi tiếp theo. Nó hoàn hảo cho các chuỗi hành động mà thứ tự là quan trọng, như cập nhật cơ sở dữ liệu.
  • switchMap: “Kẻ hủy bỏ”. Nếu có một yêu cầu mới đến, nó sẽ ngay lập tức hủy yêu cầu trước đó. Đây là lựa chọn hàng đầu cho các thanh tìm kiếm.
  • exhaustMap: Operator “lờ đi”. Nếu một yêu cầu đang chạy, nó sẽ bỏ qua mọi đầu vào mới cho đến khi yêu cầu đầu tiên kết thúc. Sử dụng cái này cho các nút đăng nhập để ngăn chặn việc gửi dữ liệu do nhấp đúp chuột.

Chiến lược Luồng dữ liệu Khai báo (Declarative Stream)

Cách mạnh mẽ nhất để xử lý các luồng bất đồng bộ trong Angular là tránh các lời gọi .subscribe() thủ công trong component của bạn. Thay vào đó, hãy định nghĩa dữ liệu của bạn như một luồng (stream) và sử dụng AsyncPipe trong template. Điều này sẽ quản lý toàn bộ vòng đời cho bạn.

Đây là cách tôi cấu trúc một luồng tìm kiếm và bộ lọc trong môi trường thực tế:

// component.ts
readonly searchResults$ = this.searchSubject.pipe(
  // 1. Đợi 300ms sau khi người dùng ngừng gõ
  debounceTime(300),
  // 2. Chỉ tìm kiếm nếu văn bản thực sự thay đổi
  distinctUntilChanged(),
  // 3. Bỏ qua chuỗi trống hoặc truy vấn quá ngắn
  filter(query => query.length > 2),
  // 4. Hủy các yêu cầu đang chờ và chuyển sang yêu cầu mới nhất
  switchMap(query => this.apiService.search(query).pipe(
    // 5. Bắt lỗi tại đây để luồng chính không bị gián đoạn
    catchError(err => {
      this.errorService.notify(err);
      return of([]); 
    })
  )),
  // 6. Lưu kết quả vào bộ nhớ đệm cho những người subscribe muộn
  shareReplay(1)
);

Trong HTML, việc triển khai rất gọn gàng:

<div *ngIf="searchResults$ | async as results; else loading">
  <ul>
    <li *ngFor="let item of results">{{ item.name }}</li>
  </ul>
</div>
<ng-template #loading>Đang tìm kiếm...</ng-template>

Lợi ích:

  1. Không rò rỉ bộ nhớ: AsyncPipe tự động dọn dẹp khi component bị hủy.
  2. Giao diện vững chắc: switchMap đảm bảo chỉ kết quả API mới nhất được đưa đến template của bạn.
  3. Hiệu quả phía máy chủ: debounceTime ngăn chặn việc gọi backend 10 lần cho một từ có 10 chữ cái.
  4. Khả năng phục hồi: Xử lý catchError bên trong switchMap đảm bảo một yêu cầu thất bại sẽ không làm hỏng thanh tìm kiếm trong suốt phần còn lại của phiên làm việc.

Kiểm soát luồng dữ liệu

RxJS có một đường cong học tập khá dốc. Tôi đã dành nhiều tháng để cố gắng ghi nhớ các operator trước khi logic của chúng thực sự thấm nhuần. Lời khuyên tốt nhất của tôi là hãy ngừng xem dữ liệu như các biến tĩnh. Hãy bắt đầu xem nó như một đường ống. Nước chảy vào từ một đầu (sự kiện người dùng), đi qua các bộ lọc (operator), và đi ra ở đầu kia sẵn sàng cho giao diện người dùng.

Hãy tập trung vào việc làm chủ bốn mapping operator: switchMap, mergeMap, concatMap, và exhaustMap. Một khi bạn hiểu cách chúng quản lý các inner observables, 90% các lỗi bất đồng bộ phức tạp của bạn sẽ biến mất. Mã nguồn của bạn sẽ ngắn hơn, dễ đoán hơn và dễ bảo trì hơn đáng kể.

Share: