OnPush Change Detection trong Angular
Hiểu khi nào Angular kiểm tra component OnPush, cách phối hợp input, Signals, AsyncPipe và ChangeDetectorRef để UI luôn cập nhật đúng.
Phạm vi và kiến thức đầu vào
Bài này giải thích ChangeDetectionStrategy.OnPush từ cơ chế đến thực hành. Bạn nên biết component, input và event binding. Ví dụ dùng standalone component và API có trong Angular 18; phần ghi chú phiên bản giúp áp dụng vào Angular mới hơn.
Mục lục
- OnPush là gì
- Cấu hình và khác biệt phiên bản
- Khi nào component OnPush được kiểm tra
- OnPush ảnh hưởng cây component thế nào
- Ví dụ hoàn chỉnh về input và mutation
- Case thực tế: Save không cập nhật MessagesList
- OnPush với danh sách và dữ liệu dẫn xuất
- Chọn API của ChangeDetectorRef
- OnPush không phải zoneless
- Những lỗi dễ gặp
- Kiểm thử hành vi OnPush
- Quy trình debug và áp dụng
- Checklist và tóm tắt
- Tài liệu tham khảo và đi tiếp
OnPush là gì
OnPush là chiến lược cho phép Angular bỏ qua việc kiểm tra template của component khi component không có lý do cần cập nhật. Nó không có nghĩa “chỉ cập nhật khi input đổi”.
Ví dụ, một bộ đếm không có input vẫn hoạt động với OnPush:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>{{ count }}</p>
<button type="button" (click)="increase()">+1</button>
`,
})
export class CounterComponent {
count = 0;
increase(): void {
this.count += 1;
}
}Angular biết (click) đã chạy trong view này. Nó đánh dấu component cần kiểm tra, nên count mới xuất hiện trên màn hình dù đây chỉ là một thuộc tính thường của class, hay field.
Ba khái niệm cần tách biệt
Change detection là quá trình Angular đọc expression trong template, so sánh kết quả với lần trước và đồng bộ phần khác biệt vào DOM.
Với {{ count }}, Angular đọc count. Nếu giá trị vẫn là 1, nó không cần ghi lại text node.
| Khái niệm | Ý nghĩa | Ví dụ |
|---|---|---|
| View | Phần giao diện Angular quản lý, như template của một component | Template chứa {{ count }} |
| Dirty hoặc cần refresh | View đã có dấu hiệu cần được kiểm tra lại | Event chạy, signal được template đọc thay đổi |
| DOM update | Angular ghi khác biệt thật vào DOM | Text đổi từ 1 thành 2 |
Dirty không đồng nghĩa DOM chắc chắn đổi. Nó chỉ cho Angular một lý do để kiểm tra. OnPush chủ yếu giảm số lượt kiểm tra không cần thiết; component instance không bị tạo lại sau mỗi lần state đổi.
Mô hình từ state đến DOM
Hãy hình dung hai cánh cửa: Angular phải biết cần đồng bộ ứng dụng, rồi phải chọn được view cần kiểm tra. Notification là tín hiệu báo cho Angular rằng có công việc đồng bộ cần xử lý.
Dòng this.count = 10 chỉ đổi giá trị trong bộ nhớ. Nó không tự sửa text trong DOM, cũng không tự đánh dấu component OnPush cần kiểm tra. Cùng một dòng gán có thể cho kết quả UI khác nhau tùy nơi nó chạy.
Ví dụ cụ thể: gán trong click và gán trong timer. Component dưới đây bắt đầu với count = 0:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-notification-demo',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count trên UI: {{ count }}</p>
<button type="button" (click)="setNow()">Gán 10 ngay</button>
<button type="button" (click)="setLater()">Gán 10 sau 2 giây</button>
`,
})
export class NotificationDemoComponent {
count = 0;
setNow(): void {
this.count = 10;
}
setLater(): void {
setTimeout(() => {
this.count = 10;
console.log('Count trong bộ nhớ:', this.count);
}, 2_000);
}
}Trường hợp 1 — bấm “Gán 10 ngay”. Angular quản lý listener (click) này, nên nó đánh dấu view cần kiểm tra. Handler gán count = 10 ngay trước lượt kiểm tra đó. Template đọc 10 và Angular đổi text trên màn hình từ 0 thành 10.
UI cập nhật nhờ notification từ event, không phải vì phép gán field tự có khả năng thông báo.
Trường hợp 2 — tải lại trang, rồi bấm “Gán 10 sau 2 giây”. Đừng thử ngay sau trường hợp 1 mà không reset, vì UI lúc đó đã là 10. Để quan sát lỗi, chờ timer mà không bấm nút khác hoặc tạo notification khác cho view.
| Thời điểm | Việc xảy ra | count trong bộ nhớ | Text trên UI |
|---|---|---|---|
| Vừa render | Angular đọc giá trị ban đầu | 0 | 0 |
| Bấm nút đặt trễ | setLater() chỉ đăng ký timer; chưa chạy dòng gán | 0 | 0 |
| Lượt kiểm tra sau click | Angular đọc count, rồi view trở về trạng thái sạch | 0 | 0 |
| Sau 2 giây | Callback gán count = 10, console in 10 | 10 | Vẫn 0 nếu không có lý do khác để kiểm tra view |
Điểm dễ nhầm là: click và callback timer là hai lần thực thi khác nhau. Click đánh dấu view cho lượt kiểm tra lúc đó. Nó không đánh dấu view mãi mãi cho mọi callback được đăng ký trong handler.
Sau khi console đã in 10, bấm “Gán 10 ngay”. UI sẽ hiện 10 vì click mới lại đánh dấu view. Lúc này field trong bộ nhớ vốn đã là 10; event mới chỉ giúp Angular đọc lại giá trị đó.
Ngay cả khi timer chạy trong Zone.js
Zone.js có thể làm Angular chạy một lượt change detection khi timer hoàn tất. Nhưng component OnPush đã sạch vẫn có thể bị bỏ qua. Trong zoneless, phép gán field thường trong timer cũng không tự lên lịch kiểm tra. Vì vậy, điều cần bổ sung là notification cho view này, không chỉ mong timer hoặc một tick toàn cục làm thay việc đó.
Cách sửa khi giữ field thường: thêm ChangeDetectorRef và inject vào import từ @angular/core, rồi thay phần xử lý timer trong class bằng:
private readonly cdr = inject(ChangeDetectorRef);
setLater(): void {
setTimeout(() => {
this.count = 10;
this.cdr.markForCheck(); // Báo rõ: view này cần được kiểm tra lại.
}, 2_000);
}markForCheck() đánh dấu view để Angular đồng bộ. Nó không ghi DOM ngay tại dòng gọi; UI cập nhật trong lượt kiểm tra được lên lịch sau đó.
Cách sửa bằng signal: import signal từ @angular/core, đổi field và method như sau:
readonly count = signal(0);
setNow(): void {
this.count.set(10);
}
setLater(): void {
setTimeout(() => {
this.count.set(10);
}, 2_000);
}Template phải đổi từ {{ count }} sang {{ count() }}. Angular ghi nhận template đọc signal này, nên khi giá trị đổi từ 0 sang 10, signal update thông báo cho view cần cập nhật. Không cần gọi thêm markForCheck() trong callback.
Timer trong ví dụ chỉ dùng để minh họa cơ chế. Với component thực tế, hủy timer khi component bị hủy nếu công việc không còn cần thiết.
Câu hỏi quan trọng: sau thao tác ghi này, cơ chế nào bảo đảm view được kiểm tra? Trong ba cách trên, câu trả lời lần lượt là event Angular quản lý, markForCheck() và signal được template đọc.
Vì sao có change detection mà UI vẫn không đổi
Có một lượt change detection chạy không có nghĩa mọi template đều được đọc lại. Trong ví dụ timer, Angular có thể bắt đầu lượt đồng bộ nhưng bỏ qua template OnPush đang sạch.
Sau callback, hai giá trị vẫn có thể khác nhau:
Field count trong bộ nhớ: 10
Text đang nằm trong DOM: "0"DOM không tự theo dõi field JavaScript. Nếu Angular chưa đọc lại {{ count }}, text cũ vẫn nằm trong DOM.
Có thể hình dung quyết định kiểm tra view bằng giả mã sau. isOnPush và needsCheck chỉ là tên minh họa, không phải property công khai của Angular:
if (view.isOnPush && !view.needsCheck) {
return; // Bỏ qua template: không đọc count, không sửa DOM.
}
readTemplateAndUpdateDOM();“View sạch” không có nghĩa dữ liệu thật sự chưa đổi. Nó chỉ có nghĩa view chưa có dấu hiệu cần refresh. Angular không gắn cơ chế thông báo tự động vào mọi field thường, nên this.count = 10 không tự đổi trạng thái đó.
Với timer chạy trong Angular zone của app dùng Zone.js, lượt change detection có thể bắt đầu. Nhưng nếu view OnPush vẫn sạch, Angular bỏ qua template trước khi đọc được giá trị 10. Đây là lý do có change detection mà UI vẫn hiện 0.
Khi thêm markForCheck() hoặc dùng signal được template đọc, view có lý do cần refresh. Angular đọc 10, so với binding cũ là 0, rồi sửa DOM thành 10.
Với Default, khi lượt duyệt đi tới view, template thường được đọc lại mà không cần điều kiện đánh dấu riêng của OnPush. Trong zoneless, chỉ gán field trong timer lại không tự lên lịch đồng bộ, kể cả với Default.
Kết luận: hãy kiểm tra cả hai điều kiện: có lượt đồng bộ chạy, và view này có được chọn để kiểm tra hay không.
Cấu hình và khác biệt phiên bản
Cấu hình nằm trong metadata của @Component:
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ name }}</p>`,
})
export class UserCardComponent {
name = 'An';
}OnPush không chỉ dành cho standalone component. Component khai báo trong NgModule cũng dùng cùng thuộc tính changeDetection.
Default và OnPush khác nhau ở đâu
Trong một lượt duyệt thông thường, chiến lược CheckAlways kiểm tra view khi Angular đi tới nó. OnPush cho phép bỏ qua template nếu view không có input mới hoặc notification liên quan.
| Khía cạnh | CheckAlways | OnPush |
|---|---|---|
| Tên API | Default trong Angular 18–21; Eager trong Angular v22 | OnPush |
| Khi traversal đi tới view | Thường kiểm tra template | Chỉ kiểm tra khi có điều kiện phù hợp |
| Input object bị mutate tại chỗ | Template có thể đọc được mutation ở lượt kiểm tra kế tiếp | Không tạo lý do kiểm tra chỉ bằng input đó |
| Field thường đổi trong timer | Trong app dùng Zone.js, tick có thể làm view cập nhật | Tick không đủ nếu view vẫn sạch |
| Chi phí chính | Có thể kiểm tra nhiều view không liên quan | Bỏ qua được nhiều view không cần cập nhật |
Traversal là quá trình Angular đi qua cây view. Đi qua một nhánh để tới view con không nhất thiết có nghĩa template của mọi ancestor đều được đánh giá lại.
CheckAlways cũng không có nghĩa “theo dõi mọi dòng gán JavaScript”. Đặc biệt trong zoneless, ứng dụng vẫn cần notification để lên lịch đồng bộ.
Đọc tài liệu theo đúng phiên bản
Tài liệu cũ thường viết “Default là mặc định, OnPush phải bật thủ công”. Điều đó đúng với Angular 18 mà nhiều khóa học đang dùng, nhưng không còn đúng cho mọi phiên bản.
| Phiên bản | Chiến lược khi không khai báo changeDetection | Ghi chú |
|---|---|---|
| Angular 18–21 | ChangeDetectionStrategy.Default | Khai báo OnPush rõ ràng để dùng chiến lược này |
| Angular v22 | ChangeDetectionStrategy.OnPush | Eager là tên cho chiến lược kiểm tra chủ động; Default là alias đã deprecated |
Các ví dụ trong bài vẫn khai báo OnPush tường minh để thể hiện ý định và dùng được trên các phiên bản khác nhau.
Đừng trộn strategy với Zone.js
Angular v21+ dùng zoneless mặc định, trừ khi ứng dụng cấu hình lại để dùng Zone.js. Đây là một thay đổi khác với việc OnPush trở thành mặc định ở v22. Strategy quyết định view nào cần kiểm tra; Zone.js hoặc scheduler zoneless — bộ lập lịch đồng bộ — liên quan đến lúc Angular được đánh thức để đồng bộ.
Khi nào component OnPush được kiểm tra
Component OnPush được khởi tạo và render lần đầu như bình thường. Sau đó, các cơ chế phổ biến sau giúp view được kiểm tra lại:
| Cơ chế | Ví dụ | Điều cần nhớ |
|---|---|---|
| Input mới qua binding | <app-card [user]="user" /> nhận object mới | Binding của cha phải được đánh giá để Angular biết input đổi |
| Event Angular xử lý | (click), output binding, @HostListener | Event trong component hoặc các view con liên quan có thể đánh dấu đường ancestor |
| Signal được template đọc thay đổi | count.update(...) với {{ count() }} | Dependency phải được template đọc; đọc trong một method bất kỳ không đủ |
| AsyncPipe nhận emission | {{ user$ | async }} | Pipe tự gọi markForCheck() cho view chứa nó |
| Input mới cho component tạo động | componentRef.setInput('count', 10) | Dùng API Angular thay vì gán property trên instance |
| Đánh dấu thủ công | cdr.markForCheck() | Không cập nhật DOM đồng bộ ngay tại dòng gọi |
Emission là một giá trị mà Observable phát ra. Observable là nguồn dữ liệu theo thời gian của RxJS, ví dụ chuỗi trạng thái tải hoặc chuỗi HTTP response.
Ngoài các cơ chế trên, detectChanges() có thể kiểm tra view trực tiếp. Đây là API điều khiển cục bộ, không phải cách mặc định để thiết kế data flow.
Input nhận giá trị mới
Với primitive như number, string hoặc boolean, đổi giá trị có thể tạo input mới:
this.quantity = 2; // Trước đó là 1.Với object và array, điều cần quan tâm là reference — danh tính của object mà biến đang trỏ tới:
const oldUser = this.user;
this.user = { ...this.user, name: 'Bình' };
console.log(oldUser === this.user); // false: object mới.Angular không quét sâu từng property của input để quyết định nó đã đổi. Trong implementation binding của Angular 18, phép so sánh dùng Object.is. Cả @Input() và input() đều không biến mutation sâu của object thành một input update tự động.
// Không tạo input object mới.
this.user.name = 'Bình';
// Tạo input object mới.
this.user = { ...this.user, name: 'Bình' };Tuy nhiên, reference mới chưa đủ nếu cha chưa được kiểm tra. Khi field thường của cha đổi trong callback async, cha vẫn cần signal hoặc markForCheck() để binding [user]="user" được đánh giá.
Quy tắc thực hành: dùng cập nhật bất biến (immutable update) — tạo dữ liệu mới thay vì sửa object cũ — tại ranh giới state. Công bố thay đổi bằng notification rõ ràng, rồi truyền giá trị mới qua binding.
Event trong component và component con
Ví dụ bộ đếm đầu bài cho thấy field thường có thể cập nhật nhờ event. Notification đến từ (click), không đến từ phép gán count.
Một event trong component con cũng có thể khiến ancestor OnPush được kiểm tra:
Đường liên quan từ SaveButton lên EditorComponent và PageComponent được đánh dấu. Các component OnPush ở nhánh sibling sạch vẫn có thể bị bỏ qua.
Điều này áp dụng cho listener Angular quản lý. Đừng xem callback của SDK bên thứ ba hoặc element.addEventListener() thủ công là tương đương hoàn toàn với (click) trong template.
Signal được đọc trong template thay đổi
Signal là một hộp giá trị có khả năng thông báo consumer — nơi sử dụng dữ liệu — đã đọc nó. Trong ví dụ sau, template đọc count(), nên Angular biết view này phụ thuộc count:
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-signal-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ count() }}</p>`,
})
export class SignalCounterComponent {
readonly count = signal(0);
increase(): void {
this.count.update((value) => value + 1);
}
}Nếu một callback gọi increase(), signal update là cơ chế thông báo. Component không cần nhận input mới hay chờ một click khác để hiện số mới.
Có hai điều kiện quan trọng:
- Template phải có dependency vào signal, trực tiếp hoặc qua một
computedmà nó đọc. - Giá trị ghi phải được signal xem là thay đổi theo equality function. Mặc định, signal cũng dùng
Object.is.
// Sai: mutate object nhưng không ghi vào signal.
this.user().name = 'Bình';
// Đúng: ghi một object mới.
this.user.update((user) => ({ ...user, name: 'Bình' }));Signal update có thể cho Angular đi qua ancestor để đến view consumer mà không phải chạy lại template của mọi ancestor sạch. Vì vậy, signal ở con đổi không có nghĩa toàn bộ cây cha phải được kiểm tra như khi có một event.
AsyncPipe nhận giá trị mới
AsyncPipe quản lý subscription của template. Khi nhận emission mới sau khi đã subscribe, pipe tự gọi markForCheck() cho view chứa nó. Nó cũng unsubscribe khi view bị hủy.
Bạn không cần tự gọi cdr.markForCheck() trong một subscription khác chỉ để template này nhận giá trị. AsyncPipe đã làm bước thông báo đó giúp bạn.
Ví dụ một service sở hữu state người dùng:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly userSubject = new BehaviorSubject<User>({
id: 1,
name: 'An',
});
readonly user$ = this.userSubject.asObservable();
rename(name: string): void {
this.userSubject.next({ ...this.userSubject.value, name });
}
}BehaviorSubject là một Observable có giữ giá trị hiện tại và cấp giá trị đó cho subscriber mới. Component dùng AsyncPipe để đọc stream:
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { UserStore } from './user-store.service';
@Component({
selector: 'app-user-panel',
standalone: true,
imports: [AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user$ | async; as user) {
<p>{{ user.name }}</p>
}
`,
})
export class UserPanelComponent {
readonly user$ = inject(UserStore).user$;
}Khi một nơi khác gọi UserStore.rename('Bình'), UserPanelComponent vẫn cập nhật dù không có input và không xử lý event tại chính nó.
Nếu cha truyền một Observable cho con qua input, reference của Observable có thể giữ nguyên suốt đời component. Con vẫn cập nhật theo emission khi template của con dùng AsyncPipe. Reference stream không cần đổi để pipe thông báo.
Emission không chữa mọi mutation
AsyncPipe đánh dấu view chứa pipe, nhưng không tạo reference mới cho object được phát ra. Nếu view đó truyền cùng object đã mutate xuống một child OnPush khác, child vẫn có thể bị bỏ qua. Hãy phát dữ liệu bất biến ở ranh giới state.
Component tạo động nhận input qua setInput
Với component được tạo bằng ViewContainerRef.createComponent(), Angular trả về một ComponentRef. Đối tượng này cho phép quản lý component instance và view vừa tạo. Dùng setInput() để cập nhật input qua cơ chế Angular:
// Fragment tại nơi tạo component động.
// viewContainerRef là một instance ViewContainerRef đã được inject.
// UserCardComponent được định nghĩa trong ví dụ hoàn chỉnh ở dưới.
const componentRef = viewContainerRef.createComponent(UserCardComponent);
componentRef.setInput('user', { id: 1, name: 'Bình' });Khi giá trị input thay đổi, setInput() đánh dấu component OnPush cần kiểm tra. API này cũng bảo đảm hook ngOnChanges được xử lý khi component được kiểm tra. Không cần gọi thêm markForCheck() chỉ để cập nhật input đó.
Ngược lại, componentRef.instance.user = nextUser chỉ là phép gán property JavaScript. Nó không tương đương setInput().
Đánh dấu bằng markForCheck
Khi buộc phải dùng field thường với callback async, ChangeDetectorRef.markForCheck() là cách báo rõ ràng rằng view cần kiểm tra.
Ví dụ sau có một timer RxJS. Subscription được dọn khi component bị hủy:
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
OnInit,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-manual-tick',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ ticks }}</p>`,
})
export class ManualTickComponent implements OnInit {
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
ticks = 0;
ngOnInit(): void {
interval(1_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.ticks += 1;
this.cdr.markForCheck();
});
}
}Nếu bỏ markForCheck(), việc timer chạy trong Zone.js cũng không bảo đảm template OnPush này được kiểm tra lại. Một tick toàn cục có thể xảy ra, nhưng view sạch vẫn bị bỏ qua.
Trong code mới, state signal hoặc AsyncPipe thường giúp tránh bước đánh dấu thủ công. Dùng markForCheck() khi cần một cầu nối có chủ đích với field thường hoặc API cũ.
OnPush ảnh hưởng cây component thế nào
Giả sử ứng dụng đã render lần đầu và không có view nào đang cần cập nhật:
CheckAlways trong sơ đồ tương ứng với Default ở Angular 18 hoặc Eager ở v22. Bảng dưới giả định không có notification nào khác xảy ra cùng lúc:
| Tình huống | Kết quả chính |
|---|---|
Click trong HeaderComponent, không đổi input của Main | Header được kiểm tra; Main và UserCard có thể bị bỏ qua |
| Click trong Main, input của UserCard giữ nguyên | Main được kiểm tra; UserCard sạch vẫn có thể bị bỏ qua |
| Click trong UserCard | UserCard và ancestor Main được đánh dấu để kiểm tra |
| App truyền input mới cho Main | Main được kiểm tra; UserCard không tự động phải kiểm tra nếu input của nó giữ nguyên |
| Signal chỉ được template UserCard đọc thay đổi | UserCard cần refresh; Angular đi qua đường ancestor cần thiết, không mặc định refresh template Main |
Không nên suy luận “cha đã chạy thì mọi con đều chạy”. Mỗi ranh giới OnPush vẫn có điều kiện của riêng nó.
Chỉ một component OnPush và phần còn lại CheckAlways
Giả sử chỉ CounterComponent dùng OnPush. Các component còn lại đều dùng CheckAlways, không có view nào bị detach():
Trường hợp 1 — click trong Counter, app dùng Zone.js. Counter có field thường count = 0 và template:
<p>{{ count }}</p>
<button type="button" (click)="count = count + 1">+1</button>Khi người dùng click, Angular đánh dấu Counter và đường ancestor liên quan cần kiểm tra. Event trong Angular zone của app dùng Zone.js dẫn tới một lượt change detection toàn cục. Trong cây này, tất cả các view CheckAlways đều được duyệt và kiểm tra; Counter cũng được kiểm tra vì có event.
Giả sử binding ở các component khác không đổi, kết quả là:
| Component | Template được kiểm tra? | State có tự đổi theo Counter không? | DOM có đổi không? |
|---|---|---|---|
| Counter | Có | count đổi từ 0 thành 1 do handler | Text số đếm đổi thành 1 |
| App và Main | Có | Không | Không, nếu binding giữ nguyên |
| Header và Footer | Có | Không | Không, nếu binding giữ nguyên |
Vì vậy, nói “các component trong cây trên đều được kiểm tra” là đúng với lượt cập nhật này. Nhưng nói “state con đổi làm state cha đổi, rồi kéo state của mọi component đổi” là sai. State của Counter chỉ nằm trong Counter; không có input, output hay service nào chuyển giá trị mới sang các component khác trong ví dụ này.
Cha được kiểm tra không có nghĩa state cha thay đổi
Angular có thể đọc lại template của App, Main, Header và Footer dù dữ liệu của chúng vẫn như cũ. Việc kiểm tra này không tạo lại component instance, không chạy lại constructor hay ngOnInit, và không mặc định viết lại DOM.
Chỉ bật OnPush cho Counter không tự khiến Header và Footer CheckAlways được bỏ qua trong lượt change detection toàn cục. OnPush là ranh giới kiểm tra của component đó, không phải công tắc giới hạn mọi update của app vào một component duy nhất.
Trường hợp 2 — signal chỉ được Counter đọc, app dùng zoneless. Giữ cấu trúc cây trên, nhưng đổi count thành signal(0) và template của Counter đọc {{ count() }}. Xét riêng một callback async gọi this.count.set(1) sau lần render đầu. Không có event Angular hoặc notification khác xảy ra cùng lúc.
Ở lượt cập nhật này, signal thông báo cho view consumer là Counter. Angular có thể dùng lượt kiểm tra hướng đích (Targeted) — tập trung vào view cần refresh:
- Counter được kiểm tra và text số đếm đổi thành
1. - Angular đi qua đường App → Main để tới Counter, nhưng không cần refresh template của các ancestor sạch chỉ để đi tiếp.
- Header và Footer sạch có thể được bỏ qua, dù strategy của chúng là CheckAlways.
CheckAlways không có nghĩa template phải được đánh giá sau mọi signal update. Phạm vi còn phụ thuộc loại lượt đồng bộ và notification nào đã xảy ra.
Không nên mở rộng kết quả của trường hợp signal này thành “zoneless luôn chỉ kiểm tra đúng một component”. Event Angular hoặc markForCheck() có thể khiến các ancestor được refresh và phạm vi kiểm tra rộng hơn.
Kết luận: với click trong app dùng Zone.js và cây ở trên, các component đều được kiểm tra. Với các nguồn cập nhật khác, hãy xác định notification và chế độ đồng bộ trước khi kết luận phạm vi kiểm tra. Trong mọi trường hợp, kiểm tra template không đồng nghĩa state hoặc DOM của mọi component đều thay đổi.
Component con dùng Default có vượt qua cha OnPush không
Đổi UserCard sang CheckAlways không làm nó tự chạy xuyên qua một cha OnPush sạch:
Trong một lượt duyệt thông thường, nếu nhánh Main bị bỏ qua và không có descendant notification cần xử lý, Angular không đi tiếp vào UserCard. Chiến lược của con không tự vô hiệu hóa ranh giới của cha.
Nếu view con có notification hợp lệ, Angular có thể được chỉ dẫn đi vào nhánh đó. Đừng dùng Default ở con như cách chữa một luồng dữ liệu thiếu notification.
Ví dụ hoàn chỉnh về input và mutation
Ví dụ này dùng field thường để tách rõ vai trò của input và event. Không có signal hay AsyncPipe vô tình che mất hành vi cần quan sát.
Component con nhận object
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
export interface User {
id: number;
name: string;
}
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p data-testid="user-name">{{ user.name }}</p>
<button type="button" (click)="probeClicks = probeClicks + 1">
Click trong component con
</button>
<small>Số click ở con: {{ probeClicks }}</small>
`,
})
export class UserCardComponent {
@Input({ required: true }) user!: User;
probeClicks = 0;
}Input thuộc về cha. Nút ở con chỉ đổi state cục bộ probeClicks, không sửa object của cha.
Component cha cập nhật state
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { User, UserCardComponent } from './user-card.component';
@Component({
selector: 'app-onpush-demo',
standalone: true,
imports: [UserCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button type="button" (click)="mutate()" data-testid="mutate">
Mutate object cũ
</button>
<button type="button" (click)="replace()" data-testid="replace">
Tạo object mới
</button>
<p data-testid="parent-name">Cha đang giữ: {{ user.name }}</p>
<app-user-card [user]="user" />
`,
})
export class OnPushDemoComponent {
user: User = { id: 1, name: 'An' };
mutate(): void {
this.user.name = 'Bình';
}
replace(): void {
this.user = { ...this.user, name: 'Chi' };
}
}Thử từng thao tác và giải thích kết quả
Render màn hình rồi thử theo thứ tự:
| Thao tác | Text ở cha | Text ở con | Vì sao |
|---|---|---|---|
| Render lần đầu | An | An | View được khởi tạo |
| Bấm Mutate object cũ | Bình | Vẫn An | Event đánh dấu cha; input reference của con không đổi |
| Bấm Click trong component con | Bình | Bình | Event ở con khiến template đọc lại object đã mutate |
| Bấm Tạo object mới | Chi | Chi | Cha truyền reference input mới cho con |
Đây là nguyên nhân bug “click một cái thì dữ liệu mới hiện”. Mutation đã đổi dữ liệu trong bộ nhớ, nhưng không tạo notification qua input. Một event đến sau vô tình làm template đọc lại dữ liệu đó.
Đừng kết luận mutation an toàn vì một lần click
Giao diện cập nhật nhờ một event khác không chứng minh mutation là reactive update. Hãy sửa điểm ghi state bằng immutable update, thay vì yêu cầu người dùng thao tác thêm hoặc rải detectChanges() để che lỗi.
Nếu thay @Input() bằng input.required<User>(), template đổi sang user().name. Cơ chế reference vẫn như cũ; Signal Input không tự phát hiện user.name bị sửa sâu ở cha.
Case thực tế: Save không cập nhật MessagesList
Case này dựa trên nhóm src/app/messages/ của project học 01-starting-project, dùng Angular 18 và Zone.js. Các đoạn code dưới đây rút gọn phần debug và style để tập trung vào luồng dữ liệu.
Cây component và service dùng chung
Người dùng nhập tin nhắn trong NewMessage rồi bấm Save. MessagesList cần hiển thị tin nhắn mới. Nhưng cả hai component đều dùng OnPush và là sibling — hai component cùng cha, không phải quan hệ cha con:
Phần ghép UI trong messages.component.html chỉ có:
<app-new-message />
<app-messages-list />Không có input binding truyền danh sách từ cha xuống MessagesList. NewMessage và MessagesList cùng inject MessagesService có providedIn: 'root', nên trong cấu hình này chúng đọc cùng một instance service.
Mảng thường và getter trong MessagesList
Service ban đầu giữ dữ liệu bằng một mảng JavaScript thường:
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class MessagesService {
private messages: string[] = [];
get allMessages(): string[] {
return [...this.messages];
}
addMessage(message: string): void {
this.messages = [...this.messages, message];
}
}MessagesList có các member đọc state như sau:
// Bên trong MessagesListComponent, đang dùng OnPush.
private messageService = inject(MessagesService);
get messages(): string[] {
return this.messageService.allMessages;
}Template dùng getter đó để render danh sách:
<ul>
@for (message of messages; track message) {
<li>{{ message }}</li>
}
</ul>Trong NewMessage, form dùng (ngSubmit)="onSubmit()". Handler Save hiện tại là:
// Bên trong NewMessageComponent.
// mesageService là property inject MessagesService trong project.
onSubmit(): void {
this.mesageService.addMessage(this.enteredText);
this.enteredText = '';
}Sau Save, service đã có mảng mới trong bộ nhớ. Việc xóa nội dung ô nhập cũng có thể xuất hiện trên UI của NewMessage, nhưng MessagesList vẫn hiển thị danh sách cũ.
Vì sao Save không đánh dấu sibling
Luồng xảy ra sau lần render đầu là:
- Angular xử lý
(ngSubmit)trong NewMessage và đánh dấu view đó cùng đường ancestor cần kiểm tra. addMessage()tạo mảng mới trong service, nhưng đây vẫn là phép gán field thường.- Messages được kiểm tra, nhưng template của nó không cấp input mới cho MessagesList.
- MessagesList không xử lý event Save, không đọc signal và không được đánh dấu bởi cơ chế khác.
- Angular bỏ qua template OnPush sạch của MessagesList. Getter
messagesvà vòng@forkhông được đánh giá lại ở view đó.
Giả sử không có notification khác đồng thời đánh dấu MessagesList:
| View | Được kiểm tra sau Save? | Lý do |
|---|---|---|
| App | Có | CheckAlways trong lượt change detection của app dùng Zone.js |
| Messages | Có | Ancestor trên đường event |
| NewMessage | Có | View xử lý event Save |
| MessagesList | Không | Sibling OnPush sạch, không có input mới hoặc notification liên quan |
Getter không phải một subscription — đăng ký nhận thông báo khi dữ liệu đổi. Angular chỉ gọi getter khi đánh giá expression cần nó. Getter có trả mảng mới mỗi lần gọi cũng không giúp được nếu template đang bị bỏ qua.
Mảng mới trong service không phải input mới của component
Service đã cập nhật bất biến bằng [...this.messages, message]; lỗi ở đây không phải mutate array tại chỗ. Mảng mới chỉ nằm trong field của service, không đi qua input binding tới MessagesList. Cùng inject một service cũng không tự đăng ký notification cho mọi component đọc field thường của nó.
Nói chính xác là Angular không kiểm tra lại template của MessagesList, chứ không phải service chưa lưu dữ liệu. Điều còn thiếu là notification hợp lệ cho view hiển thị danh sách.
Sửa bằng signal mà giữ nguyên getter
Có thể chỉ đổi phần state trong service sang signal. Getter vẫn trả về array như trước:
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class MessagesService {
private readonly messages = signal<string[]>([]);
get allMessages(): string[] {
return [...this.messages()];
}
addMessage(message: string): void {
this.messages.update((messages) => [...messages, message]);
}
}Giữ nguyên getter messages của MessagesList và template @for (message of messages; track message). Không đổi thành messages() trong cách sửa này, vì messages của component vẫn là một getter trả array, không phải signal.
Ở lần render đầu, template đọc getter và lần đọc đó đi tới signal trong service:
Angular theo dõi signal được đọc trong lúc đánh giá template. Lần đọc đi qua các getter đồng bộ vẫn được ghi nhận, nên MessagesList trở thành consumer của signal trong service.
Sau Save, messages.update() công bố array mới. Signal thông báo cho MessagesList cần refresh, dù event Save nằm ở sibling NewMessage. Template của MessagesList được kiểm tra và @for nhận danh sách mới.
Điểm tạo notification là đọc và ghi signal, không phải việc getter tạo một bản sao array. Chỉ import signal mà vẫn khai báo private messages: string[] = [] thì chưa thay đổi cơ chế cập nhật.
Các cách khác và kiểm chứng
Signal không phải lựa chọn bắt buộc. Nếu giữ state không phải signal, vẫn cần một đường cập nhật view rõ ràng:
| Cách | Điều gì khiến MessagesList được kiểm tra? |
|---|---|
| Service phát Observable, MessagesList dùng AsyncPipe | Emission mới khiến pipe đánh dấu view chứa nó |
| Cha đọc dữ liệu và truyền input xuống List | Khi cha được kiểm tra, binding cấp array reference mới cho input của List |
List nhận thông báo thay đổi và gọi markForCheck() | Đánh dấu đúng view consumer; cần quản lý subscription nếu có |
Gọi markForCheck() trên ChangeDetectorRef của NewMessage không đánh dấu sibling MessagesList. Đánh dấu thêm cha Messages cũng không tự làm mọi child OnPush sạch được kiểm tra. Nếu dùng cách thủ công, phải thông báo cho view hiển thị dữ liệu, không chỉ view ghi dữ liệu.
Để đối chiếu trong project Angular 18 này, có thể thử đổi riêng MessagesList sang ChangeDetectionStrategy.Default. Sau Save, cha Messages đã được đánh dấu bởi event, nên Angular đi tới List và đọc lại getter. Danh sách sẽ cập nhật trong luồng này dù service vẫn dùng mảng thường.
Cách thử đó chứng minh dữ liệu đã được lưu, nhưng không biến field thường thành reactive state. Nếu service đổi từ callback khác mà cha OnPush vẫn sạch, chỉ đổi List sang Default chưa bảo đảm UI được cập nhật.
Kiểm chứng tối thiểu với trạng thái mới sau khi tải lại trang:
- Bản mảng thường, List OnPush: nhập một tin nhắn khác rỗng rồi Save. Service giữ dữ liệu mới, ô nhập được xóa, nhưng danh sách vẫn cũ nếu List không có notification khác.
- Bản signal, List vẫn OnPush: tải lại trang, nhập tin nhắn rồi Save. Tin nhắn xuất hiện trong danh sách mà không cần thêm event ở List hay gọi
detectChanges()thủ công.
Kết luận: trong case này, Save đã thông báo cho NewMessage và các ancestor, nhưng chưa thông báo cho MessagesList. Muốn giữ List OnPush, hãy nối nguồn state với view consumer bằng signal, AsyncPipe, input binding hoặc đánh dấu đúng view.
OnPush với danh sách và dữ liệu dẫn xuất
Mảng mới nhưng item cũ vẫn có thể gây UI cũ
Một array mới giúp component nhận array nhận ra input đổi. Nhưng mỗi row có thể là một component OnPush nhận item, không nhận toàn bộ array:
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
}Đoạn dưới đổi array reference nhưng giữ lại item đã mutate:
// Sai: user có id = 1 vẫn là object cũ.
this.users.update((users) => {
const user = users.find((item) => item.id === 1);
if (user) user.name = 'Bình';
return [...users];
});Template danh sách được kiểm tra, nhưng row cho user id = 1 có thể tiếp tục bị bỏ qua vì input vẫn là object cũ.
// Đúng: array mới và item đã thay đổi cũng mới.
this.users.update((users) =>
users.map((user) =>
user.id === 1 ? { ...user, name: 'Bình' } : user,
),
);Các item không đổi giữ nguyên reference. Angular có thể tái sử dụng row theo track user.id và bỏ qua các row sạch.
track giữ identity của row để tái sử dụng DOM. Nó không thay thế input notification, cũng không buộc child OnPush kiểm tra khi item reference giữ nguyên.
Không tạo input mới ở mọi lượt kiểm tra
OnPush mất nhiều lợi ích nếu một method của cha liên tục tạo object hoặc array mới dù dữ liệu nghiệp vụ không đổi:
// Fragment trong component cha.
getUser(): User {
return { id: this.userId, name: this.userName };
}<!-- Method trả object mới mỗi lần được gọi khi template cha được kiểm tra. -->
<app-user-card [user]="getUser()" />Một getter trả về .map() mới mỗi lần cũng có thể gây tình trạng tương tự. Các reference mới khiến child liên tục nhận input mới.
Object literal trong template là trường hợp khác
Angular compiler có thể cache object/array literal viết trực tiếp trong template theo các giá trị binding của chúng. Vì vậy, không thể kết luận [user]="{ id: userId, name: userName }" luôn tạo reference mới ở mọi lượt kiểm tra. Vấn đề của ví dụ trên là method tự tạo object mới mỗi lần được gọi.
Với state signal, dùng computed để giữ kết quả dẫn xuất ổn định cho đến khi dependency đổi:
readonly userId = signal(1);
readonly userName = signal('An');
readonly user = computed(() => ({
id: this.userId(),
name: this.userName(),
}));<app-user-card [user]="user()" />Đoạn TypeScript trên là phần khai báo trong component; cần import signal và computed từ @angular/core.
computed cache kết quả đã tính cho đến khi dependency đổi. Một lượt kiểm tra không liên quan vì thế không tự tạo input object mới.
Chọn API của ChangeDetectorRef
Các cách cập nhật view mà không tự gọi markForCheck
Bạn không nhất thiết phải rải cdr.markForCheck() ở mọi nơi. Chọn cơ chế thông báo phù hợp với nguồn dữ liệu:
| Tình huống | Cách ưu tiên | Ví dụ |
|---|---|---|
| State cục bộ đổi từ timer hoặc callback async | Signal được template đọc | count.set(10) với {{ count() }} |
| Dữ liệu là Observable | AsyncPipe | {{ count$ | async }}; pipe tự đánh dấu view khi nhận emission mới |
| Dữ liệu thuộc component cha | Input qua binding, cập nhật bất biến | Cha được kiểm tra và truyền object mới qua [user]="user" |
| State đổi đồng bộ do người dùng tương tác | Event Angular quản lý | (click)="count = 10" trong template của component |
| Cấp dữ liệu cho component tạo động | ComponentRef.setInput() | componentRef.setInput('count', 10) |
Với timer hai giây ở đầu bài, signal là lựa chọn đơn giản: callback gọi count.set(10), còn template đọc count(). Nếu dữ liệu vốn đã là Observable, dùng AsyncPipe để quản lý subscription và notification cùng một chỗ.
Một event chỉ đánh dấu view cho lượt kiểm tra liên quan đến event đó. Nếu handler đăng ký timer rồi timer gán field thường về sau, callback vẫn cần một cơ chế thông báo riêng. Không nên thêm một click vô nghĩa chỉ để UI tình cờ cập nhật.
Quy tắc chọn: để signal, AsyncPipe hoặc input API thực hiện thông báo; dùng markForCheck() tại ranh giới tích hợp khi vẫn phải giữ field thường.
markForCheck và detectChanges không giống nhau
| API | Hành vi | Trường hợp phù hợp |
|---|---|---|
markForCheck() | Đánh dấu view và đường ancestor liên quan cần kiểm tra; không ghi DOM ngay | Field thường đổi từ subscription hoặc callback tích hợp |
detectChanges() | Thực hiện kiểm tra cục bộ ngay cho view và các view con đủ điều kiện | Luồng kiểm soát local change detection có chủ đích |
detach() | Loại view khỏi các lượt kiểm tra tự động | UI dữ liệu dày cần tự quyết định nhịp refresh |
reattach() | Đưa view trở lại cây kiểm tra tự động | Kết thúc chế độ kiểm soát thủ công |
this.status = 'Đã tải xong';
this.cdr.markForCheck();
// DOM không được bảo đảm đã đổi ngay ở dòng này.Ngược lại, detectChanges() kiểm tra view trực tiếp ngay tại điểm gọi, thay vì chỉ đánh dấu rồi chờ lượt kiểm tra được lên lịch:
this.count = 10;
this.cdr.detectChanges(); // Kiểm tra view này ngay.Đây là cách chủ động kiểm tra, không phải một cơ chế đánh dấu khác. Tuy nhiên, đừng hiểu nó là “ép tất cả child OnPush sạch chạy bất kể điều kiện”. Các view con vẫn có quy tắc kiểm tra của chúng.
Lựa chọn mặc định: signal hoặc AsyncPipe cho state reactive; markForCheck() khi cần nối field thường vào Angular. Chỉ dùng detectChanges() khi thật sự cần kiểm tra cục bộ ngay và hiểu lifecycle tại điểm gọi.
View đã detach không được chữa bằng markForCheck
Một view detached không tham gia lượt kiểm tra tự động, kể cả khi được đánh dấu dirty:
this.cdr.detach();
this.status = 'Dữ liệu mới';
this.cdr.markForCheck(); // View vẫn chưa được kiểm tra tự động.Nếu tự quản lý nhịp refresh, dùng detectChanges() để kiểm tra view tại thời điểm đã chọn. Nếu muốn trở về data flow thông thường, gọi reattach() và bảo đảm có notification phù hợp.
Không dùng detach() chỉ để “OnPush mạnh hơn”. Nó tạo trách nhiệm mới: lịch refresh, teardown, trạng thái hidden/visible và khả năng UI hiển thị dữ liệu cũ đều do bạn kiểm soát.
OnPush không phải zoneless
Zone.js tạo tick nhưng không vô hiệu hóa OnPush
Trong ứng dụng dùng Zone.js, timer hoặc HTTP callback có thể làm Angular chạy một lượt đồng bộ. Nhưng một component OnPush sạch vẫn có thể bị bỏ qua.
// Fragment trong component OnPush: notification còn thiếu.
this.http.get<User>('/api/user').subscribe((user) => {
this.user = user;
});HttpClient trả dữ liệu không đồng nghĩa mọi component đang giữ kết quả đều tự dirty. Subscription thủ công chỉ chạy callback mà bạn viết.
Có ba hướng rõ ràng:
- Đưa Observable vào template qua AsyncPipe.
- Ghi kết quả vào signal được template đọc, ví dụ
this.user.set(user). - Nếu giữ field thường, gọi
markForCheck()sau khi gán và quản lý vòng đời subscription.
takeUntilDestroyed() chỉ giải quyết cleanup của subscription. Nó không tự đánh dấu view. NgZone.run() cũng không thay thế việc đánh dấu một view OnPush sạch.
Zoneless cần notification rõ ràng
Zoneless bỏ sự phụ thuộc vào Zone.js để suy đoán lúc state có thể đổi. Angular dựa vào các notification như signal update, listener Angular, markForCheck() hoặc ComponentRef.setInput().
Một callback timer chỉ gán field thường không phải là notification:
setTimeout(() => {
this.message = 'Xong'; // Chưa nói cho Angular view nào cần kiểm tra.
}, 1_000);Dùng signal được template đọc:
readonly message = signal('Đang chờ');
// Trong method hoặc callback setup phù hợp:
setTimeout(() => {
this.message.set('Xong');
}, 1_000);Template tương ứng là {{ message() }}. Trong code thực tế, hủy timer hoặc subscription khi component bị hủy nếu công việc không còn cần thiết.
OnPush giúp kiểm tra xem component có data flow với notification rõ ràng hay không. Tuy vậy, OnPush không bắt buộc để dùng zoneless, và chỉ bật OnPush cũng không tự tắt Zone.js.
Những lỗi dễ gặp
Gán input trực tiếp qua ViewChild
Gán property trên instance không tương đương với một input binding Angular xử lý:
// Fragment: child là instance lấy được qua @ViewChild.
this.child.user = { id: 1, name: 'Bình' };Cách này không tự thực hiện đầy đủ cơ chế input update, như đánh dấu OnPush và tạo ngOnChanges. Ưu tiên để cha sở hữu dữ liệu và truyền qua template:
<app-user-card [user]="user" />Với component tạo động, dùng ComponentRef.setInput('user', nextUser) thay vì gán componentRef.instance.user trực tiếp. API này đi qua cơ chế input của Angular.
Nếu một public method của child là cần thiết, method đó nên tự quản lý state bằng signal hoặc đánh dấu view của chính child. Đừng chỉ đánh dấu cha rồi giả định con cũng sẽ được kiểm tra.
Đánh dấu view nhưng dữ liệu dẫn xuất vẫn cũ
OnPush, computed và pure pipe có các ranh giới cập nhật khác nhau. Pure pipe là pipe chỉ tính lại khi input của pipe thay đổi theo cơ chế so sánh của nó.
Giả sử label là computed từ signal user:
readonly user = signal({ name: 'An' });
readonly label = computed(() => `Xin chào ${this.user().name}`);
// Sai: cache của computed không được invalidated bởi mutation này.
this.user().name = 'Bình';
this.cdr.markForCheck();Template có thể đọc lại user().name, nhưng label() vẫn có thể giữ Xin chào An vì signal chưa công bố giá trị mới. Một pure pipe nhận cùng object reference cũng có thể giữ kết quả cũ.
// Đúng: cập nhật nguồn reactive, thay vì ép view để che mutation.
this.user.update((user) => ({ ...user, name: 'Bình' }));markForCheck() giải quyết view cần kiểm tra, không giải quyết mọi cache dữ liệu. Nếu state dẫn xuất bị cũ, sửa nguồn và dependency trước.
Dùng lifecycle hook như bộ đếm render tuyệt đối
Đếm ngDoCheck() hoặc đặt console.log() trong getter không phải phép đo chính xác “DOM render bao nhiêu lần”. Hook có scope riêng, getter có thể chạy nhiều lần và development mode thêm các bước kiểm tra.
Nếu mục tiêu là biết OnPush bỏ qua view nào, dùng profiler phù hợp. Nếu mục tiêu là biết UI chậm ở đâu, đọc thời gian JavaScript, layout và paint trong trace.
Kết luận: không dùng số log để khẳng định một component đã ghi lại DOM hoặc để chốt lợi ích performance.
Kiểm thử hành vi OnPush
Test input bằng ComponentRef
Trong component test, đặt input bằng API Angular thay vì gán property trực tiếp:
fixture.componentRef.setInput('user', { id: 1, name: 'Bình' });
fixture.detectChanges();
const name = fixture.nativeElement.querySelector('[data-testid="user-name"]');
expect(name.textContent?.trim()).toBe('Bình');Đoạn trên giả định fixture của UserCardComponent đã được tạo. setInput() đánh dấu component phù hợp và bảo toàn semantics của input, bao gồm với Signal Input.
Test qua host để thấy input reference thật
Test trực tiếp child rồi gọi detectChanges() có thể không phản ánh việc child bị bỏ qua trong cây thật. Với bug mutation ở cha, dùng host có binding [user]="user".
Hai component của ví dụ hoàn chỉnh ở trên đã tạo đúng ranh giới này:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnPushDemoComponent } from './onpush-demo.component';
// Ví dụ dùng assertion Jasmine; có thể điều chỉnh cho runner của dự án.
describe('OnPush input boundary', () => {
let fixture: ComponentFixture<OnPushDemoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OnPushDemoComponent],
}).compileComponents();
fixture = TestBed.createComponent(OnPushDemoComponent);
fixture.detectChanges();
});
function click(testId: string): void {
const button = fixture.nativeElement.querySelector(
`[data-testid="${testId}"]`,
) as HTMLButtonElement;
button.click();
fixture.detectChanges();
}
function childName(): string {
return fixture.nativeElement
.querySelector('app-user-card [data-testid="user-name"]')
.textContent.trim();
}
it('keeps a clean child unchanged after an in-place mutation', () => {
click('mutate');
const parent = fixture.nativeElement.querySelector(
'[data-testid="parent-name"]',
);
expect(parent.textContent).toContain('Bình');
expect(childName()).toBe('An');
});
it('updates the child after receiving a new object reference', () => {
click('replace');
expect(childName()).toBe('Chi');
});
});Test mutation là test minh họa cơ chế, không phải hành vi nghiệp vụ nên giữ trong app thật. Trong ứng dụng thật, nút đổi tên cần dùng immutable update và test phải khẳng định cả cha lẫn con cập nhật.
Với app zoneless, thêm test để notification thật tự lên lịch: cấu hình TestBed tương ứng, kích hoạt event hoặc signal update, rồi await fixture.whenStable() trước assertion. Cách đó giúp phát hiện trường hợp chỉ chạy đúng khi test ép detectChanges().
Quy trình debug và áp dụng
Xác định view và nguồn state
Tìm component đang hiển thị sai. State là field thường, input, signal, Observable hay computed? Ai là owner có quyền ghi state đó?
Kiểm tra reference và notification
Với object/array, so sánh reference trước và sau update. Với callback async, kiểm tra có signal update, AsyncPipe hoặc markForCheck() không. Nếu cha đổi field, nhớ kiểm tra cha có được đánh dấu để đánh giá binding hay không.
Kiểm tra ranh giới con và cache
Một array mới có thể vẫn chứa item cũ. Một view đã được đánh dấu có thể vẫn đọc computed hoặc pure pipe cache cũ. Kiểm tra thêm view có bị detach() hay không.
Xác minh bằng tương tác và đo lại
Viết test cho đúng luồng đã lỗi: input từ cha, async response hoặc callback thư viện. Sau khi UI đúng, dùng profiler để xem các view không liên quan có được bỏ qua và interaction có nhanh hơn không.
Khi nào nên áp dụng OnPush
OnPush phù hợp cho component có contract rõ ràng: input bất biến, output thể hiện ý định người dùng, state signal hoặc Observable được template tiêu thụ đúng cách.
Với Angular 18, hãy chuyển từng subtree, bắt đầu từ component lá ít dependency. Kiểm tra mutation, subscription thủ công và callback SDK trước khi mở rộng. Với Angular v22, cơ chế này đã là mặc định; giữ data flow đúng vẫn quan trọng hơn việc có khai báo metadata hay không.
OnPush không làm một phép tính nặng chạy nhanh hơn khi view thật sự phải kiểm tra. Nó cũng không giảm số DOM node của bảng 10.000 hàng. Nếu nút thắt là tính toán, dùng thuật toán phù hợp hoặc cache dữ liệu dẫn xuất; nếu là DOM quá lớn, cân nhắc phân trang hoặc virtual scrolling.
Quyết định thực hành: dùng OnPush như ranh giới dữ liệu có notification rõ ràng, rồi đo để biết nó giảm được chi phí nào trong ứng dụng của bạn.
Checklist và tóm tắt
- Tôi biết strategy mặc định của phiên bản Angular đang dùng.
- Component nhận object/array input bằng reference mới khi dữ liệu thay đổi.
- Mỗi item đã đổi trong list cũng có reference mới nếu truyền vào child OnPush.
- Callback async cập nhật signal, được AsyncPipe tiêu thụ hoặc gọi
markForCheck(). - Binding của cha có cơ hội được đánh giá để truyền input mới xuống con.
- Không mutate signal rồi dựa vào click khác để UI tình cờ cập nhật.
- Không tạo input object/array mới vô điều kiện trong template hoặc getter.
- Không dùng
detectChanges()để che lỗi dependency hoặc mutation. - Test kiểm tra hành vi qua binding, event và async flow giống ứng dụng thật.
Tóm lại, OnPush bỏ qua view không có lý do cần kiểm tra; nó không giới hạn cập nhật chỉ ở input. Input mới, event, signal dependency, AsyncPipe, ComponentRef.setInput() và markForCheck() đều là những cơ chế quan trọng.
Có change detection chạy không bảo đảm mọi view được kiểm tra. Một view sạch vẫn có thể giữ DOM cũ dù field trong bộ nhớ đã đổi. Ưu tiên các cơ chế thông báo tự động, thay vì gọi detectChanges() để che một điểm ghi state thiếu notification.
Mô hình dễ nhớ là: state mới → notification đúng → view được kiểm tra → DOM chỉ đổi ở binding khác biệt.
Tài liệu tham khảo và đi tiếp
Các điểm về phiên bản và API được đối chiếu với tài liệu Angular chính thức:
- Skipping component subtrees — các tình huống input, event và ranh giới OnPush.
- ChangeDetectionStrategy — strategy mặc định và tên API hiện hành.
- Signals trong component OnPush — dependency giữa signal và template.
- AsyncPipe — emission, đánh dấu view và tự unsubscribe.
- ChangeDetectorRef — đánh dấu, kiểm tra, detach và reattach.
- ComponentRef.setInput — cập nhật input và đánh dấu component OnPush được tạo động.
- Zoneless — notification và lưu ý kiểm thử không dùng Zone.js.
- Binding implementation trong Angular 18.2.14 — phép so sánh binding bằng
Object.is. - Pure literal structures trong Angular 18.2.14 — cách compiler biến literal trong template thành biểu thức có thể cache.
Signals, Zone.js và Change Detection
Phân biệt notification, scheduler và quá trình đồng bộ view.
Mutation object và UI không cập nhật
Đi sâu vào reference, pure pipe và cache dữ liệu dẫn xuất.
Signals
Thiết kế state reactive để component OnPush cập nhật đúng.
Profiling hiệu năng
Đo lợi ích của OnPush trên một interaction có thể tái lập.
Lifecycle hooks trong Angular
Hiểu thứ tự lifecycle hook, render callbacks, cleanup với DestroyRef và cách chọn đúng thời điểm để chạy logic trong component Angular.
Content projection
Thiết kế component linh hoạt với ng-content, slot, fallback content và ranh giới rõ ràng giữa projection, input và template outlet.