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
- Change detection thực sự là gì?
- Trước Signals: Zone.js đánh thức Angular
- Signals giải quyết phần nào?
- So sánh ba khái niệm
- OnPush thay đổi điều gì?
- Zoneless: khi Angular không dùng Zone.js
- Khi UI không cập nhật: kiểm tra theo thứ tự này
- Tóm tắt
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.
- 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:
- Đánh giá expression trong template, ở đây là
message. - So sánh giá trị mới với giá trị lần kiểm tra trước.
- Chỉ ghi vào DOM nếu giá trị khác.
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);
}
}Đâ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 đó.
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:
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ệm | Câu hỏi nó trả lời | Ví dụ | Không làm gì |
|---|---|---|---|
| Zone.js | “Có async work vừa xong không?” | timer, Promise, event callback | Khô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). AsyncPipenhậ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
- Template có đọc đúng state không? Với signal, phải viết
{{ message() }}, không phải{{ message }}. - State có thực sự đổi qua API đúng không? Dùng
sethoặcupdate; không mutate object/array rồi mong signal tự nhận ra. - Sau thay đổi có notification không? Signal template đã đọc, event template,
AsyncPipehoặcmarkForCheck()là các cách phổ biến. - 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. - 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. - Có lỗi runtime không? Một exception trước
set()hoặcmarkForCheck()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.