Angular Learning
Nền tảng Angular

Signals, Zone.js và Change Detection

Hiểu Angular biết lúc nào phải cập nhật giao diện như thế nào, vì sao Signals xuất hiện và khi nào Zone.js còn tham gia.

Đọc trang này để trả lời một câu hỏi

Khi một giá trị JavaScript đổi, vì sao chữ trên màn hình Angular lại đổi theo? Câu trả lời có ba phần: change detection cập nhật DOM, Zone.js từng là cách phổ biến để đánh thức Angular sau async work, và Signals thông báo trực tiếp state nào vừa đổi.

Mục lục

Mô hình cần nhớ trước

Hãy tách ba việc sau. Nếu trộn chúng vào nhau, Signals, Zone.js và change detection sẽ rất khó hiểu.

direction: right

n0: "State đổi\nmessage = 'Xong'" {
  width: 152
  height: 58
}

n1: "Angular được báo\nHãy kiểm tra" {
  width: 152
  height: 58
}

n2: "Angular đồng bộ view\n{{ message }} → DOM" {
  width: 184
  height: 58
}

n0 -> n1
n1 -> n2
State đổimessage = 'Xong'Angular được báoHãy kiểm traAngular đồng bộ view{{ message }} → DOM
State đổimessage = 'Xong'Angular được báoHãy kiểm traAngular đồng bộ view{{ message }} → DOM
Ba bước từ state đến DOM
  • State là dữ liệu trong component hoặc service, ví dụ message, items, isLoading.
  • Notification là tín hiệu để Angular biết có thể cần kiểm tra giao diện.
  • Change detection là lượt Angular đọc template, so sánh giá trị và ghi phần khác biệt vào DOM.

Zone.js và Signals chủ yếu liên quan đến bước notification. Change detection là bước cập nhật giao diện thực tế.

Change detection thực sự là gì?

DOM không tự theo dõi field JavaScript

Giả sử component có một field và template hiển thị field đó:

@Component({
  selector: 'app-status',
  template: `<p>{{ message }}</p>`,
})
export class StatusComponent {
  message = 'Đang chờ';
}

Ở lần render đầu, Angular đặt text của thẻ <p> thành Đang chờ. Sau đó, JavaScript chạy dòng này:

this.message = 'Đã tải xong';

Dòng gán chỉ thay đổi dữ liệu trong bộ nhớ. Trình duyệt không hề biết field message liên quan đến thẻ <p> nào. Angular phải chủ động quay lại template để đọc message lần nữa.

Một lượt change detection làm những gì?

Khi Angular quyết định kiểm tra StatusComponent, nó thực hiện gần đúng các bước sau:

  1. Đánh giá expression trong template, ở đây là message.
  2. So sánh giá trị mới với giá trị lần kiểm tra trước.
  3. Chỉ ghi vào DOM nếu giá trị khác.
direction: right

before: "Lần trước\nĐang chờ" {
  width: 130
  height: 58
}

now: "Hiện tại\nĐã tải xong" {
  width: 130
  height: 58
}

compare: "Angular đối chiếu\nbinding" {
  width: 160
  height: 58
}

dom: "Đổi text của <p>" {
  width: 152
  height: 58
}

before -> compare
now -> compare
compare -> dom: "Khác nhau"
Lần trướcĐang chờHiện tạiĐã tải xongAngular đối chiếubindingĐổi text của <p> Khác nhau
Lần trướcĐang chờHiện tạiĐã tải xongAngular đối chiếubindingĐổi text của <p> Khác nhau
Binding khác giá trị mới cần ghi DOM

Vì vậy, change detection không phải “render lại toàn bộ trang”. Angular kiểm tra các view được chọn, rồi chỉ sửa những binding có giá trị khác. Nhưng Angular vẫn cần một lý do để bắt đầu lượt kiểm tra; nó không chạy liên tục sau mọi dòng JavaScript.

Câu hỏi đúng khi debug

Đừng hỏi “Angular có detect thay đổi không?”. Hãy hỏi: sau khi state đổi, điều gì đã thông báo cho Angular kiểm tra view này?

Trước Signals: Zone.js đánh thức Angular

Trước Angular v16, Angular chưa có Signals tích hợp sẵn. Ứng dụng vẫn cập nhật UI bằng template binding và change detection. Trong cấu hình Angular truyền thống, Zone.js là cách phổ biến để báo Angular sau nhiều việc bất đồng bộ.

Zone.js làm được gì?

Zone.js bọc (patch) callback của nhiều API bất đồng bộ quen thuộc, chẳng hạn event listener, setTimeout và Promise. Khi callback chạy bên trong Angular zone, Zone.js có thể báo cho Angular rằng công việc async đã hoàn tất. Angular từ đó lên lịch change detection.

Ví dụ sau thường tự đổi UI trong ứng dụng có Zone.js:

@Component({
  selector: 'app-status',
  template: `<p>{{ message }}</p>`,
})
export class StatusComponent implements OnInit {
  message = 'Đang chờ';

  ngOnInit(): void {
    setTimeout(() => {
      this.message = 'Đã tải xong';
    }, 1_000);
  }
}
direction: right

n0: "setTimeout hoàn tất" {
  width: 176
  height: 58
}

n1: "Zone.js thấy callback" {
  width: 192
  height: 58
}

n2: "Angular chạy\nchange detection" {
  width: 152
  height: 58
}

n3: "Đọc {{ message }}\nĐổi text <p>" {
  width: 160
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
setTimeout hoàn tấtZone.js thấy callbackAngular chạychange detectionĐọc {{ message }}Đổi text <p>
setTimeout hoàn tấtZone.js thấy callbackAngular chạychange detectionĐọc {{ message }}Đổi text <p>
Zone.js thông báo cơ hội chạy change detection

Đây là giá trị lớn nhất của Zone.js: bạn không phải tự gọi API cập nhật giao diện sau mỗi timer, Promise, HTTP response hay event phổ biến.

Zone.js không làm gì?

Zone.js không phải state manager và không biến message thành dữ liệu reactive.

Nó không biết:

  • Callback vừa chạy có thật sự đổi state không.
  • Field nào đã đổi.
  • Template hoặc component nào đang đọc field đó.
  • DOM node nào cần cập nhật.

Zone.js chỉ truyền một thông điệp tổng quát: “một việc async vừa xong; Angular nên kiểm tra.” Change detection mới đọc template và quyết định có cần sửa DOM hay không.

Vì sao cách này có giới hạn?

Hãy xem callback chỉ ghi log:

setTimeout(() => {
  console.log('Không thay đổi dữ liệu hiển thị');
}, 1_000);

Zone.js không thể biết trước callback này không thay đổi UI. Nó vẫn chỉ có thể đánh thức Angular để kiểm tra. Trong ứng dụng lớn có nhiều callback, việc dựa vào tín hiệu rộng như vậy tạo công việc không cần thiết và làm thời điểm cập nhật khó suy luận hơn.

Một giới hạn khác: không phải callback nào cũng ở trong Angular zone. Ví dụ code dùng NgZone.runOutsideAngular(), Web Worker hoặc một thư viện bên thứ ba có thể gọi callback mà Zone.js không tạo notification như bạn kỳ vọng. Khi đó field đã đổi nhưng UI chưa chắc được kiểm tra.

OnPush giúp Angular bỏ qua nhiều subtree không cần thiết, nhưng không thay đổi thông tin ban đầu mà Zone.js cung cấp: Angular vẫn bắt đầu từ phỏng đoán “có lẽ có gì đó đã đổi”.

Signals giải quyết phần nào?

Signals xuất hiện dưới dạng developer preview ở Angular v16. Mục tiêu không phải thay thế DOM renderer hay xóa change detection. Mục tiêu là để state có thể thông báo trực tiếp: “tôi vừa đổi, và đây là các consumer đã đọc tôi.”

Signal tạo liên kết từ state đến view

Đổi field thường thành signal:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-status',
  template: `<p>{{ message() }}</p>`,
})
export class StatusComponent {
  readonly message = signal('Đang chờ');

  finish(): void {
    this.message.set('Đã tải xong');
  }
}

Khi Angular render {{ message() }}, nó ghi nhận: template của StatusComponent đã đọc signal message. Khi message.set(...) chạy, Angular đã có mối liên kết cần thiết để đánh dấu view đó.

direction: right

n0: "Render đầu\nĐọc {{ message() }}" {
  width: 176
  height: 58
}

n1: "Ghi dependency\nStatusComponent dùng\nmessage" {
  width: 184
  height: 79
}

n2: "message.set\nĐã tải xong" {
  width: 130
  height: 58
}

n3: "Đánh dấu view\ncần kiểm tra" {
  width: 130
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
Render đầuĐọc {{ message() }}Ghi dependencyStatusComponent dùngmessagemessage.setĐã tải xongĐánh dấu viewcần kiểm tra
Render đầuĐọc {{ message() }}Ghi dependencyStatusComponent dùngmessagemessage.setĐã tải xongĐánh dấu viewcần kiểm tra
Signal ghi nhận consumer rồi đánh dấu view (1/2)
direction: right

n3: "Đánh dấu view\ncần kiểm tra" {
  width: 130
  height: 58
}

n4: "Đọc lại binding\nCập nhật DOM" {
  width: 144
  height: 58
}

n3 -> n4
Đánh dấu viewcần kiểm traĐọc lại bindingCập nhật DOM
Đánh dấu viewcần kiểm traĐọc lại bindingCập nhật DOM
Signal ghi nhận consumer rồi đánh dấu view (2/2)

Signal tạo ra notification dựa trên dữ liệu thực sự đổi, không dựa trên việc một timer hay Promise tình cờ vừa kết thúc. Đây là lý do Signals hợp với state đồng bộ của UI, như tab đang chọn, trạng thái tải hoặc danh sách đang hiển thị.

Signal không thay thế change detection

Đây là điểm dễ nhầm nhất:

  • message.set(...) không tự viết text vào thẻ <p>.
  • Signal không tự biết HTML của bạn trông như thế nào.
  • Signal báo/đánh dấu consumer phụ thuộc.
  • Change detection vẫn là bước đọc {{ message() }} và cập nhật DOM nếu giá trị khác.

Có thể nhớ flow đầy đủ như sau:

direction: right

n0: "Người dùng click" {
  width: 152
  height: 58
}

n1: "message.set('Đã lưu')" {
  width: 192
  height: 58
}

n2: "Signal thông báo\nview đã đọc message()" {
  width: 192
  height: 58
}

n3: "Change detection\ncho view phù hợp" {
  width: 152
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
Người dùng clickmessage.set('Đã lưu')Signal thông báoview đã đọc message()Change detectioncho view phù hợp
Người dùng clickmessage.set('Đã lưu')Signal thông báoview đã đọc message()Change detectioncho view phù hợp
Click ghi signal rồi đồng bộ view phụ thuộc (1/2)
direction: right

n3: "Change detection\ncho view phù hợp" {
  width: 152
  height: 58
}

n4: "DOM hiển thị Đã lưu" {
  width: 176
  height: 58
}

n3 -> n4
Change detectioncho view phù hợpDOM hiển thị Đã lưu
Change detectioncho view phù hợpDOM hiển thị Đã lưu
Click ghi signal rồi đồng bộ view phụ thuộc (2/2)

Ngoài template, computed theo dõi signal nguồn để tạo giá trị dẫn xuất; effect chạy side effect như lưu localStorage. Chúng là consumer của signal, không phải cơ chế thay thế render.

Signals không thay thế RxJS

Signal biểu diễn giá trị hiện tại: “bây giờ danh sách là gì?”, “tab nào đang được chọn?”. RxJS Observable biểu diễn dòng giá trị theo thời gian: debounce input, hủy request cũ, retry HTTP hoặc WebSocket.

Một ứng dụng có thể nhận dữ liệu bằng RxJS rồi ghi kết quả mới nhất vào signal để template đọc. Đừng thay pipeline debounceTime và switchMap bằng nhiều effect; hai công cụ giải quyết hai bài toán khác nhau.

So sánh ba khái niệm

Khái niệmCâu hỏi nó trả lờiVí dụKhông làm gì
Zone.js“Có async work vừa xong không?”timer, Promise, event callbackKhông biết state/view cụ thể nào đổi
Signal“State nào đổi, ai đã đọc nó?”message.set('Xong')Không trực tiếp ghi DOM
Change detection“Binding trong view có giá trị mới không?”kiểm tra {{ message() }}Không tự biết khi nào phải bắt đầu nếu không có notification

Một câu để nhớ

Zone.js quan sát công việc. Signals quan sát dữ liệu. Change detection đồng bộ template với DOM.

OnPush thay đổi điều gì?

ChangeDetectionStrategy.OnPush cho phép Angular bỏ qua component khi không có lý do hợp lệ để kiểm tra nó. Một lý do hợp lệ thường là input mới, event trong template, AsyncPipe phát giá trị, markForCheck() hoặc signal đã được template đọc thay đổi.

Signals phối hợp rất tự nhiên với OnPush:

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>{{ count() }}</p>
    <button type="button" (click)="increase()">+1</button>
  `,
})
export class CounterComponent {
  readonly count = signal(0);

  increase(): void {
    this.count.update((value) => value + 1);
  }
}

Template đã đọc count(). Vì vậy, mỗi lần count.update(...) chạy, Angular đánh dấu đúng component này để kiểm tra. Bạn không cần gọi ChangeDetectorRef.markForCheck() chỉ vì đã cập nhật signal.

OnPush không bắt buộc để dùng Signals và Signals không tự động làm mọi ứng dụng nhanh hơn. Lợi ích đến từ state rõ ràng, dependency rõ ràng và template tránh tính toán nặng không cần thiết.

Zoneless: khi Angular không dùng Zone.js

Angular có thể chạy zoneless, tức không dùng Zone.js để quan sát mọi async task. Trong Angular v21 trở lên, zoneless là mặc định cho ứng dụng mới. Với Angular v20, ứng dụng có thể bật nó bằng provideZonelessChangeDetection(); ở dự án cũ, hãy kiểm tra đúng phiên bản trước khi đổi cấu hình.

Điểm chính không phải “bỏ Zone.js để dùng Signals”. Điểm chính là thay notification ngầm từ việc patch toàn cục bằng các notification tường minh mà Angular hiểu.

Angular nhận notification bằng cách nào?

Khi zoneless, các notification phổ biến gồm:

  • Cập nhật signal đã được template đọc.
  • Callback của event listener được bind trong template, ví dụ (click).
  • AsyncPipe nhận giá trị mới.
  • Gọi ChangeDetectorRef.markForCheck().
  • Angular gán input mới bằng API của framework.

Ví dụ callback của thư viện bên ngoài cập nhật một field thường. Trong zoneless, hãy báo Angular rõ ràng:

import { ChangeDetectorRef, Component, inject } from '@angular/core';

@Component({
  selector: 'app-upload-status',
  template: `<p>{{ status }}</p>`,
})
export class UploadStatusComponent {
  private readonly cdr = inject(ChangeDetectorRef);
  status = 'Chưa tải lên';

  connect(uploader: { onDone(callback: (fileName: string) => void): void }): void {
    uploader.onDone((fileName) => {
      this.status = `Đã tải: ${fileName}`;
      this.cdr.markForCheck();
    });
  }
}

Nếu status là signal được template đọc, this.status.set(...) đã là notification rõ ràng và thường dễ đọc hơn cho state UI.

Kiểm tra dự án cũ trước khi gỡ Zone.js

Không chỉ xóa zone.js khỏi polyfills rồi hy vọng mọi thứ vẫn chạy. Hãy kiểm tra các callback từ thư viện bên thứ ba, reactive forms, test và SSR. Những nơi trước đây vô tình dựa vào Zone.js phải dùng notification hợp lệ, chẳng hạn signal, AsyncPipe hoặc markForCheck().

Đừng dùng detectChanges như phản xạ

detectChanges() ép kiểm tra view ngay lúc đó. Nó có thể cần trong trường hợp đặc biệt, nhưng thường che mất câu hỏi gốc: state đổi rồi, notification nào đã thiếu? Với callback thông thường, ưu tiên signal hoặc markForCheck().

Khi UI không cập nhật: kiểm tra theo thứ tự này

  1. Template có đọc đúng state không? Với signal, phải viết {{ message() }}, không phải {{ message }}.
  2. State có thực sự đổi qua API đúng không? Dùng set hoặc update; không mutate object/array rồi mong signal tự nhận ra.
  3. Sau thay đổi có notification không? Signal template đã đọc, event template, AsyncPipe hoặc markForCheck() là các cách phổ biến.
  4. Callback đến từ đâu? Kiểm tra runOutsideAngular, Web Worker và thư viện bên thứ ba, nhất là ở ứng dụng zoneless.
  5. View có bị hủy hoặc bị tách khỏi cây render không? Kiểm tra điều kiện @if, route và lifecycle trước khi nghi ngờ change detection.
  6. Có lỗi runtime không? Một exception trước set() hoặc markForCheck() có thể khiến cập nhật chưa bao giờ xảy ra.

Tóm tắt

  • Change detection là lượt Angular đọc binding trong template, so sánh giá trị và chỉ sửa DOM khi cần.
  • Zone.js từng là cơ chế mặc định để báo Angular sau nhiều async callback. Nó biết công việc vừa xong, không biết state hay view cụ thể nào đổi.
  • Signals thêm một dependency graph cho state: template đọc signal sẽ được ghi nhận, signal đổi sẽ thông báo đúng consumer.
  • Signals không thay change detection và không thay RxJS. Signals phù hợp với giá trị state hiện tại; RxJS phù hợp với stream theo thời gian.
  • Với Angular hiện đại, nhất là zoneless, hãy làm điểm cập nhật thành notification có chủ đích thay vì chỉ dựa vào timer hoặc Promise.

Tiếp theo, xem Signals để học cách tạo, đọc và cập nhật state UI bằng signal, set và update. Nếu cần hiểu sâu cơ chế cũ hoặc đang debug dự án dựa trên zones, xem Zone.js chi tiết.

On this page