Zone.js chi tiết
Hiểu Zone.js bọc async API ra sao, Angular dùng nó để đánh thức change detection như thế nào và cách làm việc với NgZone.
Phạm vi bài này
Bài này giải thích cơ chế Zone.js trong các ứng dụng Angular truyền thống. Với Angular v21 trở lên, ứng dụng mới mặc định chạy zoneless; tuy vậy Zone.js vẫn rất quan trọng khi bạn bảo trì dự án Angular cũ hoặc thư viện còn phụ thuộc vào NgZone.
Mục lục
- Zone.js sinh ra để giải quyết vấn đề gì?
- Zone.js làm việc như thế nào?
- Angular dùng Zone.js để chạy change detection
- NgZone: ranh giới giữa Angular và code bên ngoài
- Các tình huống thường gặp
- Giới hạn và chi phí của Zone.js
- Zone.js và zoneless
- Checklist chẩn đoán
- Tóm tắt
Zone.js sinh ra để giải quyết vấn đề gì?
JavaScript chạy bất đồng bộ qua nhiều API: click của người dùng, setTimeout, Promise, HTTP response hoặc callback của thư viện. Sau khi một callback đổi state, framework cần biết lúc nào phải đồng bộ state đó ra DOM.
Nếu không có cơ chế hỗ trợ, mỗi callback phải tự báo framework:
setTimeout(() => {
this.message = 'Đã tải xong';
// Framework cần được báo để kiểm tra lại template.
}, 1_000);Điều này vừa lặp lại vừa dễ quên. Zone.js cung cấp một lớp quan sát chung quanh nhiều API async. Angular truyền thống dùng lớp này để nhận một thông báo đại ý: “một callback của ứng dụng vừa hoàn thành; có thể cần kiểm tra giao diện.”
Đừng nhầm vai trò
Zone.js không quản lý state, không thay đổi HTML và không hiểu binding Angular. Nó chỉ giúp Angular biết thời điểm async work có thể làm state thay đổi.
Zone.js làm việc như thế nào?
Angular zone là gì?
Trong ứng dụng còn dùng Zone.js, Angular tạo một zone con riêng và theo dõi zone đó. Zone con này được gọi ngắn gọn là Angular zone. NgZone là service Angular dùng để tạo và làm việc với nó.
Khi Angular khởi động ứng dụng, nó chạy phần tạo component đầu tiên, render đầu tiên và lifecycle hook bên trong Angular zone. Hãy hình dung gần đúng như sau; đây không phải mã nguồn Angular thật:
const ngZone = new NgZone();
ngZone.run(() => {
// Angular tạo và khởi động ứng dụng ở đây.
// Component, template và ngOnInit() bắt đầu từ đây.
startAngularApplication();
});ngZone.run(...) không tạo thread mới, không tạo một JavaScript runtime khác và không bọc từng dòng TypeScript của bạn. Nó chỉ đặt Zone.current thành Angular zone trong lúc hàm bên trong đang chạy. Callback async được đăng ký trong lúc đó sẽ được Zone.js ghi nhớ là callback thuộc Angular zone.
Nói đơn giản: Angular zone là “khu vực callback mà Angular muốn nghe ngóng”. Nếu callback chạy trong khu vực này và hoàn tất, Angular có thể biết để lên lịch change detection. Callback chạy ngoài khu vực này thì Angular không nhận notification theo cơ chế Zone.js.
Chỉ áp dụng cho ứng dụng dùng Zone.js
Ở ứng dụng zoneless, không có Angular zone theo nghĩa Zone.js này để đánh thức change detection. Angular dựa vào signal, AsyncPipe, event template hoặc markForCheck() thay vì theo dõi async task qua zone.
Một app có chỉ một Angular zone không?
Với một ứng dụng Angular thông thường được bootstrap một lần, có thể hình dung nó có một NgZone chính, tức một Angular zone chính để Angular theo dõi. Phần lớn code component chạy trong zone này, nên phần lớn timer, Promise và event callback mà code đó đăng ký cũng được gắn với nó.
Nhưng đừng kết luận “cả app chỉ có đúng một zone” hoặc “mọi callback async chắc chắn thuộc Angular zone”. Zone.js còn có root zone và có thể có các zone con khác. Quan trọng hơn, các trường hợp sau nằm ngoài Angular zone hoặc không được theo dõi theo cách đó:
- Code được đăng ký trong
ngZone.runOutsideAngular(). - Callback do thư viện khởi tạo ngoài Angular zone.
- API không được Zone.js patch như bạn mong đợi.
- Ứng dụng chạy zoneless.
Nói ngắn gọn: một app Angular truyền thống thường có một Angular zone chính; callback được đăng ký khi đang ở trong zone đó sẽ kế thừa zone đó. Đây là lý do đa số setTimeout trong component tự làm mới UI, nhưng không phải quy luật đúng cho mọi callback.
Zone là ngữ cảnh chạy của callback
Cụm “ngữ cảnh chạy” có thể hiểu bằng luồng callback cụ thể. Zone.js không sửa source code component mỗi lần bạn gọi timer.
- Khi nạp polyfills, trước khi app chạy:
zone.jspatch API global một lần. Nó giữsetTimeoutgốc rồi cài một hàm bọc vàowindow.setTimeout. - Khi Angular bootstrap: Angular tạo
NgZonerồi chạy phần khởi tạo ứng dụng quangZone.run(...). Vì vậy, trong lifecycle hook của component,Zone.currentlà Angular zone. - Khi component gọi
setTimeout(callback, 1000): wrapper của Zone.js chạy trước. Nó lấyZone.current(Angular zone), tạo một task chứa callback của bạn và nhớ zone sở hữu task đó. Sau đó nó đưa wrapper callback cho timer gốc của trình duyệt. - Khi timer hoàn tất: trình duyệt gọi wrapper callback. Wrapper dùng zone đã nhớ để chạy callback của bạn. Các hook của Angular zone vì thế biết task này bắt đầu/kết thúc.
Vì thế, Zone.js làm hai việc khác nhau: nó patch API global lúc polyfills được nạp, và nó lưu zone sở hữu cho từng callback lúc callback được đăng ký. Nó không sửa file TypeScript hay thân callback của bạn.
Có thể hình dung gần đúng bằng mã giả sau:
// Đây là hàm Zone.js đã cài vào window.setTimeout lúc nạp polyfills.
function patchedSetTimeout(callback: () => void, delay: number) {
const zoneLúcĐăngKý = Zone.current; // Khi component chạy: Angular zone.
return nativeSetTimeout(() => {
// Lúc timer xong: phục hồi ngữ cảnh đã nhớ rồi mới gọi callback.
zoneLúcĐăngKý.run(callback);
}, delay);
}Zone.current và .run() trong ví dụ là API/ý tưởng của Zone.js. Mã thật phức tạp hơn, nhưng nguyên lý là vậy. Zone không là thread mới, không là biến global chứa state và không giữ dữ liệu component. Nó là “môi trường thực thi” để Zone.js biết callback này thuộc về ai và hook nào cần chạy trước/sau callback.
Vì sao wrapper phải nhớ zone?
Bạn hình dung đúng một phần: Zone.js bọc callback. Trong một ứng dụng đơn giản, ta có thể tưởng tượng wrapper làm việc này:
nativeSetTimeout(() => {
callbackCủaBạn();
báoAngularKiểmTraUI();
}, 1_000);Nhưng Zone.js không thể luôn gọi báoAngularKiểmTraUI() như vậy. setTimeout là API global: cùng một hàm bị dùng bởi code Angular, code trong runOutsideAngular(), thư viện bên thứ ba, thậm chí zone con khác. Wrapper cần biết callback này thuộc zone nào để chỉ gọi hook của zone đó.
Reference được lưu chỉ là reference tới object zone — không phải reference tới component, state hay DOM. Object Angular zone chứa các hook Angular dùng để theo dõi task. Khi browser gọi wrapper sau một giây, browser không tự nhớ callback này trước đây được đăng ký ở Angular zone hay ngoài Angular zone; reference đó là thông tin Zone.js cần để khôi phục đúng ngữ cảnh.
Ngay cả khi app thường chỉ có một Angular zone chính, reference vẫn cần thiết vì một callback có thể được đăng ký ngoài zone đó. Nó cũng giúp callback con được đăng ký bên trong callback cha tiếp tục kế thừa đúng ngữ cảnh.
Patch API nghĩa là gì?
Zone.js được nạp trước code ứng dụng — trong Angular truyền thống, thường qua zone.js ở polyfills. Ngay khi được nạp, nó giữ lại setTimeout gốc của trình duyệt rồi thay window.setTimeout bằng hàm bọc của nó. Tên API vẫn là setTimeout, nên component của bạn không cần gọi API đặc biệt nào.
Vì vậy Angular không cần “đoán người dùng vừa gọi setTimeout”. Khi code chạy:
setTimeout(callback, 1_000);thì lời gọi đã đi vào wrapper cài từ trước. Wrapper chụp Zone.current, thay callback bằng callback đã bọc, sau đó mới gọi setTimeout gốc của trình duyệt:
// Mô hình đơn giản hóa — không phải mã nguồn Zone.js thật.
const nativeSetTimeout = window.setTimeout;
window.setTimeout = (callback, delay) => {
const zoneLúcĐăngKý = Zone.current;
return nativeSetTimeout(() => {
zoneLúcĐăngKý.run(callback);
}, delay);
};Cùng ý tưởng này áp dụng cho API như addEventListener và Promise: Zone.js bọc lúc đăng ký callback, nên nó có quyền quan sát lúc callback chạy. Nó không đọc source code của bạn, cũng không biết mọi API trên đời; API nào không được patch hoặc callback nào được đăng ký ngoài Angular zone sẽ không tạo cùng notification.
Task, microtask và macrotask
Zone.js theo dõi các loại công việc mà event loop thực thi. Bạn chỉ cần phân biệt ba khái niệm sau để hiểu thời điểm Angular được báo:
| Loại | Ví dụ | Trực giác |
|---|---|---|
| Event task | click, input | Trình duyệt gọi khi người dùng hoặc hệ thống tạo event. |
| Macrotask | setTimeout, setInterval | Một công việc độc lập ở lượt event loop sau. |
| Microtask | Promise.then, phần tiếp theo của await | Chạy ngay sau code hiện tại, trước macrotask tiếp theo. |
Ví dụ Promise tạo microtask:
Promise.resolve('Đã tải xong').then((message) => {
this.message = message;
});Sau callback, Zone.js theo dõi việc queue task của Angular zone có trở nên ổn định hay chưa. Khi phù hợp, Angular có thể lên lịch một lượt change detection. Bạn không cần tự đoán chính xác thứ tự microtask/macrotask khi viết component thông thường; điều cần nhớ là Zone.js có thể thấy các callback đã patch chạy xong.
Angular dùng Zone.js để chạy change detection
Luồng với timer hoặc Promise
Một component dùng field thường và setTimeout:
@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);
}
}Trong ứng dụng có Zone.js, luồng khái niệm là:
Change detection mới là bước đọc {{ message }} và sửa DOM. Zone.js chỉ tạo điều kiện để bước đó được chạy. Vì vậy, nói “Zone.js cập nhật UI” là cách nói tắt, không hoàn toàn chính xác.
Một event có render lại toàn bộ trang không?
Không. Với một (click) hoặc setTimeout, Zone.js có thể khiến Angular bắt đầu một lượt change detection; nó không xóa rồi dựng lại toàn bộ HTML, không tạo lại mọi component và không tự viết lại mọi DOM node.
Ở chiến lược mặc định (Default), Angular thường đi kiểm tra rộng từ root của ứng dụng để đánh giá các binding. Điều này có nghĩa nhiều expression template có thể được chạy lại. Nhưng ở mỗi binding, Angular so sánh giá trị cũ và mới; DOM chỉ được ghi khi giá trị thật sự khác. Nếu {{ user.name }} vẫn là An, text node tương ứng không bị ghi lại.
OnPush giúp thu hẹp lượt kiểm tra hơn nữa: Angular bỏ qua các component OnPush sạch, trừ khi chúng nhận input mới, có event trong subtree, AsyncPipe phát giá trị, được gọi markForCheck() hoặc signal mà template đã đọc thay đổi.
Vì thế, nên phân biệt: “Angular kiểm tra nhiều view” không đồng nghĩa với “trang bị render lại toàn bộ.” Signals cải thiện thêm việc biết view nào phụ thuộc vào state nào.
Zone.js không biết state nào đã đổi
Zone.js nhìn vào callback, không nhìn vào dependency của template. Hai callback sau đều có thể khiến Angular được đánh thức:
setTimeout(() => {
this.message = 'Đã tải xong'; // Có thay đổi UI.
}, 1_000);
setTimeout(() => {
console.log('Chỉ ghi log'); // Không thay đổi UI.
}, 1_000);Zone.js không biết callback thứ hai không đụng đến UI. Nó chỉ biết “timer callback đã xong”. Angular dùng change detection và chiến lược như OnPush để giới hạn phần cần kiểm tra, nhưng notification ban đầu vẫn khá rộng.
Đó là một trong các động lực để Angular thêm Signals: signal cho framework biết state nào đổi và view nào đã đọc state đó. Xem Signals, Zone.js và Change Detection để học flow này từ góc nhìn state.
NgZone: ranh giới giữa Angular và code bên ngoài
NgZone là service Angular cung cấp để bạn chủ động chạy code trong hoặc ngoài Angular zone. Hầu hết component không cần dùng nó. Nó hữu ích khi bạn tích hợp thư viện có callback tần suất cao hoặc callback nằm ngoài Angular.
import { Component, NgZone, inject } from '@angular/core';
@Component({
selector: 'app-example',
template: `...`,
})
export class ExampleComponent {
private readonly zone = inject(NgZone);
}runOutsideAngular: tránh đánh thức Angular cho việc không liên quan UI
Dùng runOutsideAngular() cho công việc nhiều lần nhưng không làm thay đổi dữ liệu đang hiển thị: đo animation, xử lý dữ liệu tạm, hoặc lắng nghe event tần suất cao để tính toán nội bộ.
startAnimation(): void {
this.zone.runOutsideAngular(() => {
const frame = () => {
updateCanvasDirectly();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
});
}Vì callback được đăng ký ngoài Angular zone, Angular không bị đánh thức sau từng frame chỉ vì animation canvas chạy. Đây là tối ưu có chủ đích; đừng đưa code ra ngoài zone nếu nó phải thay đổi template ở mọi callback.
run: quay lại Angular khi field thường cần cập nhật UI
Nếu callback đang ở ngoài Angular zone và bạn đổi field thường được template đọc, quay lại Angular bằng run():
status = 'Đang tải';
connect(uploader: { onDone(callback: (fileName: string) => void): void }): void {
this.zone.runOutsideAngular(() => {
uploader.onDone((fileName) => {
this.zone.run(() => {
this.status = `Đã tải: ${fileName}`;
});
});
});
}run() đưa callback trở lại Angular zone. Khi callback hoàn tất, Angular có cơ hội chạy change detection để {{ status }} cập nhật.
Với state UI hiện đại, một lựa chọn thường rõ ràng hơn là để status là signal và gọi status.set(...). Signal đã tạo notification cho template consumer; bạn không cần dùng NgZone.run() chỉ để làm signal update render được.
Các tình huống thường gặp
Thư viện gọi callback ngoài Angular zone
Dấu hiệu: log cho thấy field đã đổi, nhưng template không đổi. Điều này thường xảy ra khi thư viện khởi tạo callback ngoài Angular zone hoặc ứng dụng chạy zoneless.
Chọn một trong hai cách rõ ràng:
// Cách 1: Ứng dụng còn dùng Zone.js, field thường.
this.zone.run(() => {
this.status = result;
});// Cách 2: State UI là signal mà template đã đọc.
this.status.set(result);Trong ứng dụng zoneless, ưu tiên cách thứ hai hoặc gọi ChangeDetectorRef.markForCheck() sau khi đổi field thường. Đừng kỳ vọng NgZone.run() một mình cung cấp semantics Zone.js khi Zone.js đã bị gỡ khỏi ứng dụng.
Sự kiện tần suất cao
scroll, mousemove, resize hoặc animation frame có thể chạy rất nhiều lần. Nếu mỗi callback đều khiến Angular kiểm tra giao diện, hiệu năng có thể giảm.
Trước khi dùng runOutsideAngular(), hãy xác định UI thật sự cần cập nhật bao nhiêu lần. Có thể throttle event, chỉ cập nhật signal khi giá trị hiển thị thực sự thay đổi, hoặc dùng API browser trực tiếp cho canvas. Tối ưu đúng là giảm notification thừa, không phải mặc định chạy mọi thứ ngoài Angular.
Signal trong ứng dụng còn dùng Zone.js
Signals không xung đột với Zone.js. Trong Angular v16–v20, nhiều ứng dụng dùng cả hai:
- Zone.js tiếp tục hỗ trợ code cũ đổi field thường trong async callback.
- Signal thông báo rõ ràng khi state được template đọc thay đổi.
Thêm Signals không tự động xóa zone.js khỏi bundle. Việc chuyển zoneless là một migration riêng, cần kiểm tra thư viện, forms, SSR và test.
Giới hạn và chi phí của Zone.js
Zone.js rất tiện cho mô hình Angular truyền thống, nhưng có các trade-off:
| Trade-off | Vì sao xảy ra | Hệ quả thực tế |
|---|---|---|
| Notification rộng | Zone.js thấy task hoàn tất, không thấy state dependency | Angular có thể bị đánh thức dù UI không đổi. |
| Patching toàn cục | Nhiều browser API bị wrapper can thiệp | Stack trace và debugging có thể khó đọc hơn. |
| Tương thích API | API mới hoặc thư viện đặc biệt không phải lúc nào cũng được patch như mong muốn | Có callback đổi state nhưng không tự cập nhật UI. |
| Kích thước/khởi động | Zone.js là thêm một dependency runtime | Có thêm chi phí payload và startup. |
Các trade-off này không có nghĩa Zone.js “xấu” hoặc dự án đang dùng nó là lỗi thời. Nó giải quyết tốt bài toán notification tổng quát. Angular hiện đại chuyển dần sang notification tường minh để việc cập nhật dễ dự đoán hơn.
Zone.js và zoneless
Zone.js chưa bị bỏ
Zoneless là mặc định cho ứng dụng mới từ Angular v21 trở lên, nhưng Zone.js không bị loại khỏi Angular. Dự án cũ đang nạp zone.js vẫn có thể tiếp tục hoạt động theo cơ chế zone-based; bạn không buộc phải migration chỉ vì nâng version Angular.
Nếu ứng dụng Angular v21+ cần chủ động dùng lại zone-based change detection, đăng ký provider sau và bảo đảm zone.js vẫn được nạp từ polyfills/cấu hình build:
import { provideZoneChangeDetection } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZoneChangeDetection()],
});Vì vậy, “zoneless là mặc định” chỉ có nghĩa Angular chọn notification tường minh cho app mới. Nó không có nghĩa NgZone, Zone.js hay các ứng dụng dựa trên Zone.js bị bỏ ngay lập tức.
Zoneless là chế độ Angular không nạp Zone.js để theo dõi mọi async task. Angular vẫn cần notification để chạy change detection, nhưng notification đến từ các API Angular rõ ràng hơn:
- cập nhật signal mà template đã đọc;
- event listener được bind trong template;
AsyncPipenhận giá trị mới;ChangeDetectorRef.markForCheck();- Angular gán input qua framework API.
Với Angular v20, có thể bật zoneless bằng provideZonelessChangeDetection(). Với dự án cũ, không tự gỡ zone.js khỏi polyfills mà chưa kiểm thử toàn bộ luồng async. Đọc hướng dẫn chính thức về zoneless trước khi migration.
NgZone không phải lý do để giữ Zone.js
NgZone.run() và NgZone.runOutsideAngular() vẫn có thể tồn tại trong code tương thích zoneless. Nhưng các observable ổn định như onStable, onUnstable hoặc onMicrotaskEmpty không còn là tín hiệu phù hợp để điều khiển render ở ứng dụng zoneless.
Checklist chẩn đoán
Khi field đã đổi nhưng UI không đổi trong dự án dùng Zone.js, kiểm tra theo thứ tự:
- Template có đọc đúng field/signal không? Đặt log hoặc breakpoint tại callback và kiểm tra tên binding.
- Callback có chạy trong Angular zone không? Tìm
runOutsideAngular(), Web Worker và thư viện bên thứ ba. - Component có
OnPushkhông? Xác định notification hợp lệ: input mới, event,AsyncPipe, signal hoặcmarkForCheck(). - Object có bị mutate tại chỗ không? Với
OnPushvà signal, dùng reference mới khi cập nhật dữ liệu phức tạp. - Ứng dụng có phải zoneless không? Nếu có, không dựa vào timer/Promise để tự đánh thức Angular; dùng notification tường minh.
- Có exception trước khi cập nhật không? Một lỗi runtime có thể khiến dòng gán hoặc
set()không chạy.
Tóm tắt
- Zone.js bọc nhiều API async để theo dõi callback và ngữ cảnh chạy của chúng.
- Angular truyền thống dùng thông tin đó để biết khi nào có thể chạy change detection.
- Zone.js không biết field, signal, component hoặc DOM node cụ thể nào đã đổi.
NgZone.runOutsideAngular()giảm notification thừa;NgZone.run()đưa callback về Angular zone khi field thường cần làm mới UI.- Zone.js vẫn được Angular hỗ trợ; zoneless là mặc định cho ứng dụng mới từ Angular v21, không phải việc loại bỏ Zone.js khỏi dự án cũ.
- Signals cung cấp dependency theo state rõ ràng hơn; zoneless thay dần việc dựa vào patching toàn cục bằng notification tường minh.
Quay lại Signals, Zone.js và Change Detection để đặt Zone.js vào bức tranh đầy đủ từ state đến DOM.