ngFor, trackBy, IterableDiffer và @for: Angular Thực Sự Làm Gì?

Trong một thời gian dài, mình nghĩ là mình đã hiểu *ngFor. Cho đến khi gặp vài bug rất khó giải thích: dữ liệu trong TypeScript hoàn toàn đúng, nhưng màn hình lại sai; ô input người dùng đang gõ dở tự nhiên mất trắng sau một lần refresh; danh sách sort lại thì thẻ mở rộng nhảy sang đúng người khác.
Mình đoán là không ít anh/em cũng từng ở trạng thái giống mình: dùng được, nhưng nếu bị hỏi "vì sao lại thế" thì trả lời không trôi. Bài viết này là phần ghi chép lại quá trình mình đi tìm câu trả lời.
Bài khá dài. Đây là phần phụ lục để anh/em dễ chọn chỗ mình cần:
Phần 1 Bài toán gốc: đồng bộ một mảng với một danh sách DOM
Phần 2 Khuôn đúc, điểm neo và các view
Phần 3 Angular biết mảng đã đổi bằng cách nào?
Phần 4 IterableDiffer và bài toán "danh tính"
Phần 5 Hai câu hỏi mình từng mắc kẹt
5.1 users = [...users] mà sao không thấy gì đổi?
5.2 Đổi sang track $index thì chuyện gì xảy ra?
Phần 6 trackBy: cú pháp và năm cái bẫy
Phần 7 @for của Angular 17 -> 20
Phần 8 @for + Signals trong app zoneless
Phần 1 — Bài toán gốc: đồng bộ một mảng với một danh sách DOM
Giả sử bạn có một mảng users: User[] và cần render mỗi user thành một hàng trong danh sách. Nếu dữ liệu nằm im mãi thì không có gì để bàn. Nhưng thực tế thì dữ liệu luôn thay đổi: thêm một user mới, xoá một user, đổi tên một user, sắp xếp lại, lọc theo ô search, load thêm trang tiếp theo từ API.
Nếu phải tự viết bằng tay, thường ta sẽ nghĩ ra hai hướng.
Cách 1 — Xoá sạch rồi dựng lại
container.innerHTML = '';
for (const u of users) {
container.appendChild(renderRow(u));
}
Đơn giản, và luôn cho ra kết quả đúng. Nhưng cứ mỗi lần dữ liệu "nhúc nhích" một chút là bạn phá huỷ toàn bộ DOM cũ rồi dựng lại từ đầu. Hệ quả không chỉ là chậm — cái mất lớn hơn là toàn bộ trạng thái hiện tại trong DOM: vị trí scroll, ô input người dùng đang gõ dở, phần tử đang focus, animation đang chạy, video đang phát, vùng text đang bôi đen.
Cách 2 — Tự tính phần khác biệt
Bạn tự viết logic: "user nào mới thì tạo node, user nào biến mất thì xoá node, user nào đổi vị trí thì di chuyển node". Về lý thuyết đây là cách tối ưu. Nhưng viết cho đúng trong mọi tình huống thì phức tạp hơn nhiều so với tưởng tượng.
Và đây chính là phần lõi mà framework sinh ra để gánh cho chúng ta. Với Angular, bạn chỉ cần khai báo hai thứ — "một item trông như thế nào" và "danh sách nguồn là gì" — phần còn lại là việc của framework. Cơ chế đó là structural directive *ngFor, và từ v17 trở đi là block @for.
<div *ngFor="let user of users">{{ user.name }}</div>
Cái tên gây hiểu lầm
Điều cơ bản nhất mà mình từng hiểu sai — và mình nghĩ nó là nguyên nhân gốc của gần như mọi ngộ nhận về trackBy — là: *ngFor không phải là một vòng lặp for.
Một vòng lặp for chạy một lượt từ đầu đến cuối rồi kết thúc. *ngFor thì sống suốt vòng đời của component. Mỗi lần change detection chạy, nó phải trả lời một câu hỏi hoàn toàn khác:
Mảng bây giờ so với mảng ở lần kiểm tra trước khác nhau ở chỗ nào — và tôi cần sửa DOM tối thiểu bao nhiêu để khớp lại?
Nói cách khác, *ngFor là một bộ đồng bộ hoá hai chiều giữa cấu trúc dữ liệu JS và cấu trúc DOM, chứ không phải một bộ sinh HTML.
Toàn bộ những khái niệm phía sau — IterableDiffer, trackBy, track — chỉ là các câu trả lời khác nhau cho đúng một vế của câu hỏi trên: khác nhau ở chỗ nào.
Phần 2 — Khuôn đúc, điểm neo và các view
Ở phần trên mình đã nói *ngFor cần tạo, xoá và — quan trọng nhất — di chuyển các node DOM. Nhưng để làm được việc đó, framework phải giải hai vấn đề trước đã.
Vấn đề 1 — Cần một template HTML "chưa được sinh ra"
Cái <div>{{ user.name }}</div> bên trong *ngFor không được phép hiển thị như một div bình thường. Nó không phải là một phần tử, nó là bản thiết kế.
Vấn đề 2 — Cần một điểm neo cố định trong DOM
Giả sử danh sách đang rỗng, không có node nào. Lát nữa user đầu tiên xuất hiện, Angular phải chèn nó vào đúng một vị trí nhất định. Nhưng ở đúng chỗ đó lại chẳng có dấu mốc nào cả. Vậy nên cần một cột mốc vô hình nằm lại trong DOM để biết vị trí.
Để giải hai vấn đề này, Angular sinh ra bốn thứ cho mọi structural directive, chứ không riêng gì *ngFor:
| Khái niệm | Là gì | Ví von |
|---|---|---|
ng-template |
Mẫu HTML ở trạng thái chưa render | cái khuôn |
TemplateRef |
Tay cầm để thao tác khuôn từ TypeScript | chìa khoá |
ViewContainerRef |
Điểm neo trong DOM + API quản lý view | cái khay |
EmbeddedViewRef |
Một bản đúc cụ thể đã có trong DOM | sản phẩm |
Bước 1 — Trình biên dịch "mở gói" dấu sao
Cái bạn viết:
<div *ngFor="let user of users; index as i">
{{ i }} - {{ user.name }}
</div>
Cái Angular thực sự biên dịch ra:
<ng-template ngFor let-user [ngForOf]="users" let-i="index">
<div>{{ i }} - {{ user.name }}</div>
</ng-template>
Ba điều đáng để ý ở dạng đã mở gói:
- Thẻ
divbị bọc vào trongng-template. Nó không còn là phần tử trực tiếp của template cha nữa. let usertrở thành một biến cục bộ của template.of userstrở thành một@InputtênngForOf. Quy tắc đặt tên là: tiền tố directive (ngFor) + từ khoá viết hoa chữ đầu (Of). Đây cũng chính là lý dotrackBysẽ trở thành inputngForTrackByở phần sau.
Bước 2 — Directive nhận vào hai thứ và giữ lấy
constructor(
private _viewContainer: ViewContainerRef, // cái khay
private _template: TemplateRef<NgForOfContext<T>>, // cái khuôn
private _differs: IterableDiffers,
) {}
ViewContainerRef ở đây trỏ tới vị trí của chính ng-template đó trong DOM. Angular đánh dấu vị trí này bằng một comment node rỗng. Nếu bạn mở DevTools xem một danh sách Angular, bạn sẽ thấy một dòng <!--container--> hoặc <!--ng-container--> nằm ngay dưới các item. Đó chính là cột mốc vô hình, và nó không bao giờ bị xoá — kể cả khi danh sách rỗng.
Bước 3 — Đúc ra các embedded view
this._viewContainer.createEmbeddedView(
this._template, // khuôn
// dữ liệu riêng của bản đúc này
new NgForOfContext(user, users, index, count),
index // chèn vào vị trí thứ mấy trên khay
);
Cái context truyền vào chính là nguồn gốc của mọi biến bạn dùng được trong template:
class NgForOfContext<T> {
$implicit: T; // chính là 'user' — biến mặc định khi viết 'let user'
ngForOf: T[]; // cả mảng gốc
index: number;
count: number;
get first() { return this.index === 0; }
get last() { return this.index === this.count - 1; }
get even() { return this.index % 2 === 0; }
get odd() { return !this.even; }
}
Bước 4 — Điểm mấu chốt: một view là một khối có thể di chuyển
Đây là chỗ mình cho là quan trọng nhất của cả phần này, vì nó là bản lề của trackBy.
Một EmbeddedViewRef không chỉ là "vài node DOM". Nó là một đơn vị Angular sở hữu trọn vẹn, và bên trong nó đóng gói:
- Các node DOM đã tạo
- Context object gắn với nó
- Các binding và listener đã đăng ký
- Các component con đã khởi tạo — cùng toàn bộ state nội bộ, subscription, form control của chúng
- Các lifecycle hook đang chạy
Và ViewContainerRef cung cấp API để thao tác trên những đơn vị đó mà không phá huỷ chúng:
viewContainer.createEmbeddedView(tpl, ctx, i); // tạo mới -> tốn kém
viewContainer.move(viewRef, newIndex); // di chuyển -> rất rẻ
viewContainer.remove(i); // huỷ hẳn
viewContainer.detach(i); // gỡ ra nhưng giữ lại để tái dùng
viewContainer.insert(viewRef, i); // cắm cái đã detach vào lại
Nghĩa là Angular hoàn toàn có khả năng lấy nguyên một hàng đang tồn tại — với toàn bộ ô input người dùng đang gõ dở, checkbox đang tick, component con đang giữ state — rồi dời sang vị trí khác trong danh sách mà không tạo lại gì cả.
Vấn đề còn lại chỉ là: Angular có đủ thông tin để biết hàng ở vị trí mới có đúng là hàng cũ hay không, để nó chọn move() thay vì remove() + createEmbeddedView(). Đây là chỗ mà bạn — người viết code — phải cung cấp thông tin.
Một lưu ý nhỏ về @for: cú pháp mới bỏ hẳn ng-template khỏi cách bạn viết, nhưng bên dưới nó vẫn làm việc đúng với mô hình khuôn / khay / view ở trên. Cái thay đổi là thuật toán quyết định khi nào move — và mình sẽ nói ở Phần 7.
Phần 3 — Angular biết mảng đã đổi bằng cách nào?
Khi bạn viết:
this.users.push(newUser);
Một nhịp sau, hàng mới xuất hiện trên màn hình. Cảm giác như Angular "đang theo dõi" mảng users.
Thực ra nó không theo dõi gì cả.
Một mảng JavaScript thuần không có khả năng thông báo. Khi bạn gọi push, không có sự kiện nào được phát ra, không ai được đánh thức. Đối tượng vừa bị thay đổi vẫn nằm im lìm trong bộ nhớ.
Đây là bài toán nền tảng mà mọi framework UI đều phải giải: làm sao biết được "đã có gì đó thay đổi" trong khi ngôn ngữ không hề báo cho bạn?
Angular thay đổi câu hỏi
Thay vì hỏi "cái gì vừa đổi?", Angular hỏi: "Khi nào thì có khả năng có gì đó vừa đổi?"
Và câu trả lời của nó rất thô sơ nhưng hiệu quả: state của một ứng dụng web chỉ có thể thay đổi sau một tác vụ bất đồng bộ. Người dùng click, timer chạy xong, HTTP trả về, Promise resolve. Không có nguyên nhân nào khác. Code đồng bộ thì chạy xong là xong, và Angular kiểm tra ngay sau khi nó chạy xong.
Từ đó sinh ra Zone.js: một thư viện vá đè (monkey-patch) lên các API bất đồng bộ của trình duyệt.
// Đại ý những gì Zone.js làm khi app khởi động
const originalSetTimeout = window.setTimeout;
window.setTimeout = function (cb, delay) {
return originalSetTimeout(() => {
cb();
notifyAngular(); // <- thứ được thêm vào
}, delay);
};
Zone.js vá setTimeout, setInterval, addEventListener, Promise, XMLHttpRequest, fetch, requestAnimationFrame… Kết quả: mọi tác vụ bất đồng bộ trong app đều tự động báo cho Angular biết khi nó vừa xong.
Ba tầng câu hỏi độc lập
Chỗ này rất nhiều người (kể cả mình lúc đầu) gộp làm một. Mình tách hẳn thành ba tầng, vì phân biệt được ba tầng này là đã hiểu được phần lớn change detection của Angular:
| Tầng | Câu hỏi | Ai trả lời |
|---|---|---|
| 1 | Cái gì đánh thức Angular? | Zone.js / signal / markForCheck() |
| 2 | Angular sẽ ghé thăm component nào? | Chiến lược CD: Default hay OnPush |
| 3 | Khi ghé tới, NgForOf làm gì? |
ngDoCheck() -> differ.diff() |
Tầng 1 — Đánh thức. Lệnh users.push(newUser) của bạn nằm bên trong một event handler click, hoặc trong callback của subscribe(). Chính cái bọc ngoài đó (click / HTTP) mới là thứ Zone.js bắt được, chứ không phải lệnh push. Zone.js đợi hàng đợi microtask trống, phát tín hiệu onMicrotaskEmpty, và ApplicationRef.tick() được gọi.
Nếu bạn push bên trong một API mà Zone.js không vá (code chạy ngoài zone, hoặc một thư viện bên thứ ba đặc biệt), màn hình sẽ không cập nhật, dù dữ liệu đã đổi hoàn toàn đúng.
Tầng 2 — Duyệt cây. tick() duyệt cây component từ gốc xuống lá. Với mỗi component nó ghé qua, Angular chạy hàm cập nhật template đã biên dịch sẵn, tính lại tất cả biểu thức binding, và gọi các lifecycle hook.
Nếu component dùng ChangeDetectionStrategy.OnPush, Angular sẽ bỏ qua nguyên cả view đó (kể cả cây con), trừ khi có một trong các điều kiện: một @Input đổi tham chiếu, một sự kiện phát ra từ bên trong nó, một signal nó đọc bị thay đổi, hoặc markForCheck() được gọi tay.
Tầng 3 — NgForOf được hỏi. Đây là chỗ then chốt. NgForOf không dùng ngOnChanges. Nó dùng ngDoCheck:
@Input()
set ngForOf(value: U | undefined | null) {
this._ngForOf = value;
this._ngForOfDirty = true; // chỉ chạy khi THAM CHIẾU đổi
}
ngDoCheck(): void { // chạy MỌI chu kỳ CD
if (this._ngForOfDirty) {
this._ngForOfDirty = false;
if (!this._differ && this._ngForOf) {
this._differ = this._differs.find(this._ngForOf)
.create(this.ngForTrackBy);
}
}
if (this._differ) {
// <- so sánh thật sự
const changes = this._differ.diff(this._ngForOf);
if (changes) this._applyChanges(changes);
}
}
Hãy đọc kỹ sự khác biệt:
- Setter
ngForOfchỉ được gọi khi tham chiếu mảng đổi (this.users = [...]). Angular so sánh input bằngObject.is, nênpushvào mảng cũ không kích hoạt setter. ngDoCheck()được gọi ở mọi chu kỳ change detection, bất kể tham chiếu có đổi hay không.
Và chính vì ngDoCheck chạy vô điều kiện, nên differ.diff() cũng chạy vô điều kiện. Đó là câu trả lời cho câu hỏi ban đầu:
pushhoạt động không phải vì Angular phát hiện rapush, mà vì đằng nào Angular cũng sẽ duyệt lại cả mảng vài mili-giây sau đó.
Ba hệ quả thực tế cần nhớ
Thứ nhất — cái bẫy OnPush + mutation.
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class UserListComponent {
@Input() users: User[] = [];
}
// Component cha:
this.users.push(newUser); // (X) Màn hình KHÔNG cập nhật
this.users = [...this.users, u]; // (V) Cập nhật
Lý do bây giờ đã rõ ràng theo đúng ba tầng: mutation không đổi tham chiếu → tầng 2 bỏ qua cả view → ngDoCheck của NgForOf bên trong không bao giờ chạy → differ.diff() không bao giờ được gọi. Dữ liệu đúng, DOM sai.
Đây chính là gốc rễ của quy tắc "hãy dùng immutable data" mà chúng ta nghe suốt.
Thứ hai — chi phí ngầm. differ.diff() chạy ở mọi chu kỳ CD, và một chu kỳ có thể xảy ra hàng chục lần mỗi giây (mỗi cú mousemove, mỗi phím gõ, mỗi timer). Nếu mảng có 1000 phần tử, Angular duyệt qua 1000 phần tử đó liên tục. không cva— nhanh hay chậm, thông minh hay ngờ nghệch — hoàn toàn phụ thuộc vào cái mà bạn dạy nó dùng để nhận diện một item. Và đó chính xác là trackBy.
Thứ ba — Angular 20 và zoneless. Đến v20, Angular cho phép chạy hoàn toàn không cần Zone.js bằng provideZonelessChangeDetection(). Khi đó tầng 1 thay đổi bản chất: không còn ai vá setTimeout nữa, mà chính signal chủ động thông báo khi giá trị của nó đổi. Tầng 2 và tầng 3 thì gần như giữ nguyên. Mình sẽ quay lại chuyện này ở Phần 8, sau khi đã nắm @for.
Phần 4 — IterableDiffer và bài toán "danh tính"
Ở Phần 3 ta chốt được rằng mỗi chu kỳ CD, NgForOf gọi differ.diff(this._ngForOf). Bây giờ mở cái hộp đen đó ra.
Bài toán
Hãy đặt mình vào vị trí của thuật toán. Bạn có hai thứ:
- Ảnh chụp cũ: mảng ở lần kiểm tra trước (differ đã lưu lại nội bộ).
- Mảng mới: mảng hiện tại.
Nhiệm vụ: xuất ra một danh sách thao tác trên DOM. Ví dụ cụ thể:
Cũ: [A, B, C, D]
Mới: [A, C, D, E]
Câu trả lời đúng và tối ưu là: xoá B, thêm E vào cuối. Hai thao tác, ba view A/C/D giữ nguyên hoàn toàn.
Nhưng để ra được kết luận đó, thuật toán phải trả lời được một câu hỏi mà nó không tự trả lời nổi:
Cái C ở vị trí 1 trong mảng mới — nó là cái C ở vị trí 2 trong mảng cũ (chỉ dịch chỗ), hay là một cái C hoàn toàn khác tình cờ trông giống?
Đây là bài toán danh tính (identity problem). Nó không phải bài toán kỹ thuật, nó là bài toán ngữ nghĩa. Chỉ có bạn — lập trình viên — mới biết cái gì làm nên "danh tính" của một user: là id, là email, hay là chính object đó trong bộ nhớ. Thuật toán chỉ nhìn thấy các ô nhớ.
Angular buộc phải chọn một câu trả lời mặc định. Và lựa chọn mặc định đó chính là nguồn gốc của mọi rắc rối.
4A — Nhìn qua mã nguồn
Angular đưa ra một lớp trừu tượng: IterableDiffer — bộ so sánh có trạng thái (stateful), sống cùng directive và nhớ được lần kiểm tra trước.
this._differ = this._differs.find(this._ngForOf).create(this.ngForTrackBy);
Trong đó, ngForTrackBy là chỗ bạn gắn hàm "định nghĩa danh tính". Cài đặt mặc định là DefaultIterableDiffer. Và mặc định của mặc định là:
const trackByIdentity = (index: number, item: any) => item;
Đọc kỹ dòng này. Khi bạn không truyền trackBy, Angular dùng chính bản thân object làm khoá định danh. Với object, so sánh bằng Object.is nghĩa là so sánh tham chiếu bộ nhớ. Nói cách khác, mặc định của Angular là:
"Hai item là cùng một item khi và chỉ khi chúng là cùng một object trong RAM."
Cấu trúc dữ liệu. Differ không lưu mảng cũ. Nó lưu một danh sách liên kết các bản ghi:
class IterableChangeRecord_<V> {
item: V; // object hiện đang gắn với view này
trackById: any; // KHOÁ định danh — kết quả của trackByFn
currentIndex: number | null; // vị trí mới
previousIndex: number | null; // vị trí cũ
_next, _prev, ... // con trỏ danh sách liên kết
}
Kèm hai bảng tra cứu phụ: _linkedRecords (các item đang có mặt) và _unlinkedRecords (các item vừa bị gỡ ra, biết đâu lát nữa lại xuất hiện — trường hợp sắp xếp lại).
Vòng lặp so sánh (rút gọn từ default_iterable_differ.ts):
let record = this._itHead; // con trỏ chạy trên danh sách CŨ
for (let index = 0; index < collection.length; index++) {
const item = collection[index];
const itemTrackBy = this._trackByFn(index, item);// <- tính khoá
if (record === null || !Object.is(record.trackById, itemTrackBy)){
// Khoá KHÔNG khớp -> phải đi tìm
record = this._mismatch(record, item, itemTrackBy, index);
} else {
// Khoá KHỚP -> cùng một item
if (!Object.is(record.item, item)) { // <- ghi nhớ chi tiết này
this._addIdentityChange(record, item);
}
}
record = record._next;
}
this._truncate(record); // phần thừa của danh sách cũ -> REMOVE
_mismatch xử lý ba tình huống theo đúng thứ tự sau:
- Tìm trong
_unlinkedRecords(đã bị gỡ trước đó trong lượt này) → tái chèn, tức là MOVE. - Tìm trong
_linkedRecords(còn nằm đâu đó phía sau) → gỡ ra và chèn vào đây → MOVE. - Không thấy ở đâu cả → ADD, tạo bản ghi mới.
Kết quả trả về là một object IterableChanges, và NgForOf._applyChanges dịch nó thành thao tác ViewContainerRef:
changes.forEachOperation((record, adjustedPreviousIndex, currentIndex) => {
if (record.previousIndex == null) {
// ADD — đắt
viewContainer.createEmbeddedView(template, ctx, currentIndex);
} else if (currentIndex == null) {
viewContainer.remove(adjustedPreviousIndex);// REMOVE — huỷ view
} else {
const view = viewContainer.get(adjustedPreviousIndex);
viewContainer.move(view, currentIndex); // MOVE — rẻ
}
});
// Sau đó cập nhật lại index/count/first/last/even/odd cho MỌI view
// Rồi:
changes.forEachIdentityChange((record) => {
viewContainer.get(record.currentIndex).context.$implicit = record.item;
});
Hai điều then chốt ở đoạn code trên, vì mọi thứ phía sau xây trực tiếp lên nó:
- Thuật toán đưa ra quyết định ADD / MOVE / REMOVE hoàn toàn dựa trên
trackById, chứ không nhìn vào nội dung item. forEachIdentityChangelà cơ chế xử lý tình huống: khoá giống nhau nhưng object đã bị thay thế. Khi đó Angular giữ nguyên view cũ và chỉ đổi$implicitsang object mới. View sống sót, nội dung được làm mới.
Thảm hoạ của cách mặc định
Bây giờ ráp lại. Kịch bản kinh điển nhất trong mọi app:
refresh() {
this.http.get<User[]>('/api/users').subscribe(users => {
this.users = users; // JSON.parse tạo ra object HOÀN TOÀN MỚI
});
}
Server trả về đúng 100 user y hệt, không đổi một ký tự. Nhưng JSON.parse sinh ra 100 object mới với 100 địa chỉ bộ nhớ mới. Differ chạy với trackByIdentity:
| Item | trackById cũ | trackById mới | Object.is? |
Kết luận |
|---|---|---|---|---|
| User #1 | {ref: 0x1A} |
{ref: 0x9F} |
KHÔNG | ADD mới |
| User #2 | {ref: 0x2B} |
{ref: 0xA3} |
KHÔNG | ADD mới |
| ... | ... | ... | KHÔNG | ADD mới |
| User #100 | {ref: 0x7C} |
{ref: 0xF1} |
KHÔNG | ADD mới |
Không một khoá nào khớp. _mismatch không tìm thấy gì trong _linkedRecords. _truncate quét sạch toàn bộ danh sách cũ.
Kết quả: 100 remove() + 100 createEmbeddedView(). Toàn bộ danh sách bị san phẳng và dựng lại từ đầu, dù dữ liệu giống hệt.
Và như đã phân tích ở Phần 2, cái mất không chỉ là DOM node:
<div *ngFor="let user of users">
<input [(ngModel)]="user.note"> <!-- ô đang gõ dở -> mất trắng -->
<!-- component con -> huỷ, ngOnDestroy, dựng lại -->
<app-user-card [user]="user"/>
</div>
Người dùng đang gõ ghi chú, auto-refresh chạy, mọi thứ biến mất. Scroll nhảy về đầu. Element đang focus mất focus. Animation giật. Subscription trong component con bị huỷ rồi tạo lại 100 lần.
Đây không phải vấn đề hiệu năng đơn thuần. Đây là bug chức năng.
Vậy gốc rễ là gì?
Không phải Angular chậm. Không phải thuật toán tồi. Gốc rễ là:
Angular đang dùng sai định nghĩa về "danh tính". Nó nghĩ danh tính của user là địa chỉ ô nhớ. Còn bạn biết rằng danh tính của user là
user.id.
Chỉ cần dạy cho differ biết điều đó, mọi thứ đảo chiều: 100 khoá khớp hoàn hảo → 0 thao tác DOM → chỉ có forEachIdentityChange âm thầm thay $implicit, và các binding tự cập nhật nội dung.
Cái "công cụ để dạy" đó chính là trackBy.
Điều mình muốn nhấn mạnh: trackBy không phải là một tuỳ chọn tối ưu hoá. Nó là tham số định nghĩa danh tính của thuật toán diff — và Angular đã chọn sẵn một giá trị mặc định gần như luôn sai với dữ liệu đến từ server.
4B — Nhìn lại bằng ngôn ngữ thường
Đoạn mã nguồn ở trên khá khô. Phần này mình kể lại đúng cùng một thuật toán bằng một hình ảnh mà mình thấy dễ nhớ hơn nhiều, rồi chạy tay một ví dụ.
Mảnh 1 — Differ giữ cái gì trong đầu?
Differ cần so sánh mảng lần trước với mảng lần này, nhưng nó không lưu mảng cũ. Nó lưu một cuốn sổ. Mỗi dòng sổ tương ứng với một view đang tồn tại trên màn hình, và mỗi dòng có bốn thông tin:
| Trường | Ý nghĩa |
|---|---|
trackById |
Biển số của item này — thứ để nhận diện |
item |
Object thực tế đang gắn với view đó |
previousIndex |
Trước đây nó nằm ở vị trí thứ mấy |
currentIndex |
Bây giờ nó nằm ở vị trí thứ mấy |
Hai cột đầu là chỗ dễ nhầm nhất, nên phải tách bạch ngay:
trackByIdlà biển số xe.itemlà chiếc xe. Hai thứ này có thể lệch nhau: cùng một biển số, nhưng chiếc xe bên trong đã bị thay bằng chiếc khác.
Chuyện này xảy ra suốt, và Mảnh 6 sẽ nói riêng về nó.
Các dòng trong sổ được nối với nhau bằng danh sách liên kết (mỗi dòng có con trỏ tới dòng kế tiếp), chứ không phải mảng. Vì công việc chính ở đây là chèn vào giữa và rút ra khỏi giữa — mà mảng thì làm hai việc đó rất tệ, còn danh sách liên kết chỉ cần nối lại vài con trỏ.
Mảnh 2 — Hai con trỏ và hai nhánh
Khi diff() chạy, có hai thứ chạy song song:
- Một con trỏ chạy trên cuốn sổ cũ (dòng 1 → dòng 2 → dòng 3…).
- Một con trỏ chạy trên mảng mới (index 0 → 1 → 2…).
Ở mỗi bước, differ đặt đúng một câu hỏi: "Biển số ở dòng sổ hiện tại có trùng với biển số của item ở vị trí này trong mảng mới không?" Chỉ có hai nhánh:
Nhánh A — Trùng. Không phải làm gì với DOM cả. Item này vẫn ở đúng chỗ cũ. Differ chỉ liếc thêm một cái: "chiếc xe bên trong có còn là chiếc cũ không?" Nếu bị thay rồi thì ghi chú lại (đây là identity change, Mảnh 6). Rồi cả hai con trỏ cùng bước tiếp.
Nhánh B — Không trùng. Có chuyện. Nhưng "có chuyện" ở đây chưa rõ là chuyện gì, vì có ba khả năng hoàn toàn khác nhau:
- Item cũ ở dòng này đã bị xoá khỏi mảng.
- Item cũ vẫn còn, chỉ là nó dời xuống phía dưới.
- Item mới này là item hoàn toàn mới vừa được thêm vào.
Differ chưa thể phân biệt ba khả năng đó ngay tại thời điểm này, nên nó chuyển sang một thủ tục riêng để điều tra.
Mảnh 3 — Ngăn kéo tạm: chỗ then chốt
Đây là ý tưởng mình thấy thông minh nhất của thuật toán, và cũng là chỗ hay bị bỏ qua.
Khi gặp Nhánh B, việc đầu tiên differ làm là: rút dòng sổ hiện tại ra khỏi danh sách chính, bỏ vào một ngăn kéo tạm gọi là "hàng chờ xoá".
Chú ý chữ tạm. Nó chưa xoá gì trên DOM cả. Nó chỉ nói: "Cái này tôi chưa biết dùng vào đâu, để tạm sang bên."
Vì sao phải làm vậy? Vì trong một lần sắp xếp lại danh sách, một item bị "đá ra" ở bước này rất có thể sẽ được cần đến ở vài bước sau. Nếu xoá thẳng tay, ta mất luôn cái view đắt tiền của nó.
| Chỗ tra | Chứa gì | Tìm thấy nghĩa là gì |
|---|---|---|
Hàng chờ xoá (_unlinkedRecords) |
Những dòng vừa bị đá ra trong chính lượt này | Item quay lại rồi -> kéo về, MOVE |
Danh sách đang sống (_linkedRecords) |
Những dòng vẫn còn trong sổ, ở phía sau | Item nằm dưới, cần kéo lên -> MOVE |
Và thủ tục điều tra chạy theo đúng thứ tự này:
- Ngó vào hàng chờ xoá trước. Có biển số này không? Có → lấy nó ra, cắm vào vị trí hiện tại. MOVE. View sống sót.
- Không có thì ngó vào danh sách đang sống. Có biển số này ở đâu đó phía dưới không? Có → rút nó lên đây. MOVE. View sống sót.
- Cả hai đều không có. Chưa từng thấy biển số này bao giờ → ADD. Tạo dòng sổ mới, và lát nữa sẽ tạo view mới.
Toàn bộ giá trị của trackBy nằm gọn trong ba dòng này. Bước 1 và 2 là hai cơ hội để cứu một view. Cả hai đều tra bằng biển số. Nếu biển số của bạn là địa chỉ ô nhớ (mặc định) và server vừa trả về object mới, thì mọi lần tra đều trượt, và mọi item đều rơi xuống bước 3.
Mảnh 4 — Chạy tay một ví dụ
Lý thuyết đủ rồi, giờ chạy thật. Giả sử track trả về chữ cái làm biển số.
Sổ cũ (DOM hiện tại): A B C
Mảng mới: C A D
Bằng mắt thường ta biết đáp án đúng: B bị xoá, D được thêm, A và C chỉ đổi chỗ. Xem differ có ra được như vậy không.
Bước 0 — trạng thái ban đầu
Sổ: [A] -> [B] -> [C]
Hàng chờ xoá: (rỗng)
Con trỏ sổ: A
Bước 1 — mảng mới vị trí 0, item là C. Con trỏ sổ đang ở A. Biển số A ≠ C → Nhánh B.
- Rút A ra khỏi sổ, ném vào hàng chờ xoá.
- Tra hàng chờ xoá tìm C: trong đó chỉ có A. Không thấy.
- Tra danh sách đang sống tìm C: thấy! C đang nằm cuối sổ.
- → Kéo C lên đầu. Đây là MOVE.
Sổ: [C] -> [B]
Hàng chờ xoá: {A}
Dòng C: previousIndex = 2, currentIndex = 0
Con trỏ sổ bước sang dòng kế tiếp của C, tức là B.
Bước 2 — mảng mới vị trí 1, item là A. Con trỏ sổ đang ở B. Biển số B ≠ A → Nhánh B.
- Rút B ra khỏi sổ, ném vào hàng chờ xoá.
- Tra hàng chờ xoá tìm A: thấy! Chính là cái vừa bị đá ra ở Bước 1.
- → Kéo A về, cắm ngay sau C. MOVE.
Sổ: [C] -> [A]
Hàng chờ xoá: {B}
Dòng A: previousIndex = 0, currentIndex = 1
Đây chính là lý do phải có hàng chờ tạm. Nếu ở Bước 1 differ xoá thẳng A, thì tới Bước 2 nó sẽ phải tạo lại view cho A từ đầu. Nhờ ngăn kéo tạm, view của A sống sót nguyên vẹn, chỉ đổi vị trí.
Con trỏ sổ bước tiếp. Sau A không còn gì → null.
Bước 3 — mảng mới vị trí 2, item là D. Con trỏ sổ là null, tức là sổ cũ đã hết → Nhánh B.
- Không có dòng nào để rút ra.
- Tra hàng chờ xoá tìm D: trong đó chỉ có B. Không thấy.
- Tra danh sách đang sống tìm D: sổ chỉ còn C, A. Không thấy.
- → Chưa từng thấy biển số này. ADD.
Sổ: [C] -> [A] -> [D]
Hàng chờ xoá: {B}
Dòng D: previousIndex = null <- dấu hiệu "hàng mới toanh"
currentIndex = 2
Bước 4 — dọn dẹp cuối (_truncate). Vòng lặp đã đi hết mảng mới. Bây giờ differ kiểm tra: con trỏ sổ đã đi tới cuối chưa? Nếu chưa, nghĩa là sổ cũ còn thừa dòng mà mảng mới không dùng đến — toàn bộ phần thừa đó bị quét vào hàng chờ xoá. Ở ví dụ này con trỏ đã tới null rồi nên không có gì để quét thêm.
Rồi differ chốt sổ: những gì còn nằm trong hàng chờ xoá đến giờ phút này là xoá thật. Nó đánh dấu currentIndex = null cho chúng.
Hàng chờ xoá cuối cùng: {B} -> B.currentIndex = null
Bản kê khai cuối cùng:
| Item | previousIndex | currentIndex | Kết luận |
|---|---|---|---|
| C | 2 | 0 | MOVE |
| A | 0 | 1 | MOVE |
| D | null | 2 | ADD |
| B | 1 | null | REMOVE |
Đúng bằng đáp án ta đoán bằng mắt. Chỉ có 1 view bị huỷ, 1 view được tạo. Hai view kia chỉ dịch chỗ.
Để ý cách hai cột index mã hoá loại thao tác, rất gọn:
previousIndex == null -> chưa từng tồn tại -> ADD
currentIndex == null -> không còn tồn tại -> REMOVE
cả hai đều có giá trị -> tồn tại ở cả hai thời điểm -> MOVE
Mảnh 5 — Từ bản kê khai sang thao tác DOM
Đến đây differ đã xong việc. Nó trả bản kê khai về cho NgForOf, và NgForOf ra lệnh cho ViewContainerRef (cái "khay" ở Phần 2):
- Dòng ghi ADD →
createEmbeddedView(...). Đúc một view mới từ khuôn. Đắt. - Dòng ghi REMOVE →
remove(...). Huỷ view, kèm theo mọi component con, subscription, DOM state bên trong. - Dòng ghi MOVE →
move(...). Dời nguyên khối view sang chỗ khác. Rẻ, và giữ nguyên mọi thứ bên trong.
Có một chi tiết kỹ thuật đáng biết dù không cần nhớ kỹ: NgForOf không dùng thẳng con số previousIndex trong sổ, mà dùng một con số đã được hiệu chỉnh. Lý do: các thao tác được thi hành lần lượt, nên mỗi lần dời hay xoá một view thì vị trí của những view còn lại đã bị xê dịch. Angular tự tính bù phần xê dịch đó. Bạn chỉ cần nhớ: đừng tự suy luận vị trí DOM từ previousIndex, vì con số trong sổ là vị trí ở trạng thái ban đầu, không phải vị trí tại thời điểm thao tác chạy.
Sau khi xong ba loại thao tác trên, NgForOf còn làm một việc bắt buộc nữa: cập nhật lại index, count, first, last, even, odd cho toàn bộ view. Vì C giờ ở vị trí 0 nên nó phải trở thành first, A không còn là first nữa, v.v.
Mảnh 6 — Identity change: cùng biển số, khác xe
Đây là mảnh mình thấy quan trọng nhất, và cũng là mảnh trả lời được câu hỏi: "nếu view không bị dựng lại thì làm sao nội dung mới hiện ra được?"
Tình huống. Bạn đã dùng trackBy theo user.id. Rồi bạn gọi API refresh:
this.users = await fetchUsers(); // JSON.parse -> 100 object mới toanh
Server trả về user id: 7 với tên đã đổi từ "An" thành "Bình". Bây giờ differ chạy. Ở vị trí của user 7:
- Biển số: cũ là 7, mới cũng là 7 → Trùng → Nhánh A → không có thao tác DOM nào cả.
- Nhưng differ liếc thêm: chiếc xe trong sổ là object cũ (
{id:7, name:'An'}ở địa chỉ0x1A), còn item trong mảng mới là object khác hẳn ({id:7, name:'Bình'}ở địa chỉ0x9F). - Hai thứ này lệch nhau. Differ ghi vào một danh sách riêng: "dòng số 7: chiếc xe đã bị thay." Đó là identity change.
Tại sao chuyện này quan trọng đến vậy? Nhớ lại Phần 2: mỗi view có một object context đi kèm, và trong context có trường $implicit. Khi bạn viết let user of users, cái tên user trong template chính là trỏ tới $implicit của view đó.
Bây giờ hình dung nếu Angular không làm gì thêm:
- View của user 7 vẫn tồn tại, không bị đụng tới. Tốt.
- Nhưng
$implicitcủa nó vẫn đang trỏ vào object cũ ở địa chỉ0x1A, cái object cóname: 'An'. - Binding
{{ user.name }}sẽ đọc từ object cũ. - → Màn hình vẫn hiện "An". Dữ liệu mới không bao giờ xuất hiện.
Đây sẽ là một bug cực kỳ khó chịu: dữ liệu đúng trong TypeScript, sai trên màn hình, và không có lỗi nào được báo.
Nên Angular phải làm bước cuối cùng: duyệt qua danh sách identity change, và với mỗi dòng, trỏ lại $implicit của view sang object mới.
View của user 7:
$implicit: 0x1A ----------> $implicit: 0x9F
(object cũ, "An") (object mới, "Bình")
DOM node: KHÔNG ĐỔI
Component con: KHÔNG ĐỔI
Input đang gõ dở: KHÔNG ĐỔI
Sau đó, ngay trong cùng chu kỳ change detection, các binding của view được chạy lại, đọc từ object mới, và cập nhật text node từ "An" thành "Bình".
Đây chính là điều mình thấy hay nhất mà trackBy mang lại. Ráp lại toàn bộ câu chuyện của một lần refresh với 100 user không đổi thứ tự:
CÓ trackBy theo id |
KHÔNG có trackBy |
|
|---|---|---|
| Khoá khớp | 100/100 -> 0 thao tác DOM | 0/100 -> 100 REMOVE + 100 ADD |
| Identity change | 100 -> chỉ đổi con trỏ $implicit |
Không cần, vì view mới toanh |
| Binding | Chạy lại, chỉ ghi vào DOM text node thực sự đổi | Toàn bộ DOM dựng lại |
| Ô input đang gõ | Còn nguyên | Mất trắng |
| Component con | Sống, giữ state | ngOnDestroy -> ngOnInit x100 |
| Scroll / focus / animation | Nguyên vẹn | Reset hết |
Và lưu ý cột bên trái: nội dung vẫn được cập nhật đầy đủ. Không có chuyện "dùng trackBy thì màn hình bị đứng dữ liệu cũ". Cơ chế identity change đảm bảo điều đó.
Ba câu đọng lại của Phần 4
- Differ giữ một cuốn sổ, mỗi dòng là một view đang sống, và nó nhận diện các dòng bằng biển số chứ không phải bằng nội dung.
- Khi biển số lệch, nó không xoá ngay mà đưa vào ngăn kéo tạm, để còn cơ hội cứu view khi item chỉ đổi chỗ. Hết vòng lặp mà vẫn nằm trong ngăn kéo thì mới xoá thật.
- Khi biển số khớp nhưng object bị thay, nó giữ view và đổi con trỏ dữ liệu thay vì dựng lại. Đây là lý do
trackByvừa nhanh vừa vẫn hiển thị đúng dữ liệu mới.
Phần 5 — Hai câu hỏi mình từng mắc kẹt
Hai chỗ dưới đây là hai lần mình tưởng đã hiểu, rồi phát hiện là hiểu lệch. Mình giữ lại nguyên cả suy luận sai ban đầu, vì mình nghĩ chỗ sai thường dạy được nhiều hơn chỗ đúng.
5.1 — "Mình gán lại users = [...users] mà sao không thấy gì đổi?"
Suy luận ban đầu của mình là: "reference của array đã thay đổi, nhưng ngFor lại dùng reference của từng item cho hàm trackBy, nên nó không nhận ra."
Nửa sau của câu đó đúng, nhưng chuỗi nhân quả thì lệch. Và chỉnh chỗ này rất đáng, vì nó là ranh giới giữa hai cơ chế mà mình từng gộp làm một.
Phần đúng
"ngFor dùng reference của từng item cho hàm trackBy" — chính xác. Mặc định trackByIdentity = (index, item) => item, và với object thì so sánh bằng Object.is, tức là so địa chỉ ô nhớ.
Chỗ cần chỉnh
Vấn đề nằm ở chữ shallow. Hãy thử:
const a = [{ id: 1, name: 'An' }];
const b = [...a];
a === b // false <- reference của MẢNG đổi
a[0] === b[0] // true <- reference của ITEM không đổi
Spread chỉ sao chép lớp vỏ ngoài. Các item bên trong vẫn là đúng những object cũ, đúng những địa chỉ cũ.
Nên khi bạn users = [...users], differ chạy và kết luận:
- Biển số dòng 1: khớp. Biển số dòng 2: khớp. … Tất cả đều khớp.
- Thêm nữa:
Object.is(record.item, item)cũngtrue, vì đúng là object cũ. - → Không có ADD, không MOVE, không REMOVE, và không cả identity change.
diff()trả vềnull,_applyChangesthậm chí không được gọi.
Vậy nên không phải "ngFor bị nhầm nên không cập nhật". Mà là ngFor kết luận hoàn toàn chính xác rằng cấu trúc danh sách không hề thay đổi — và đúng là nó không thay đổi thật.
Nhưng đây mới là điểm mấu chốt
Việc differ nói "không có gì đổi" không có nghĩa là màn hình đứng yên. Đây là hai cơ chế tách rời hoàn toàn:
| Cơ chế | Phụ trách | Chạy khi nào |
|---|---|---|
| Differ | Cấu trúc: có bao nhiêu view, view nào ở vị trí nào | Mỗi ngDoCheck |
| Binding | Nội dung: {{ user.name }} hiện chữ gì |
Mỗi chu kỳ CD, cho từng view |
Differ im lặng chỉ có nghĩa là "không tạo/xoá/dời view nào". Nhưng các view đang tồn tại vẫn được chạy lại binding như thường. Nếu user.name đã đổi giá trị, text node vẫn được cập nhật bình thường.
Nói cách khác: với ChangeDetectionStrategy.Default, {{ user.name }} sẽ hiển thị đúng dữ liệu mới kể cả khi bạn không spread gì cả, chỉ cần sửa users[0].name = 'Bình'.
Vậy tại sao mình lại thấy nó không cập nhật?
Nếu text bên trong *ngFor thật sự không đổi sau khi spread, thì nguyên nhân nằm ở chỗ khác. Xếp theo xác suất:
1. Component con dùng OnPush — thủ phạm phổ biến nhất.
<div *ngFor="let user of users">
<app-user-card [user]="user"/> <!-- OnPush -->
</div>
[...users] không đổi reference của user, nên @Input của app-user-card không đổi → Angular bỏ qua nguyên cái card. Bạn spread mảng ngoài, nhưng cái cần đổi là object bên trong.
Cách sửa là thay item thật sự, chứ không phải thay vỏ:
// (X) đổi vỏ, ruột giữ nguyên
this.users = [...this.users];
// (V) thay đúng item cần đổi
this.users = this.users.map(u =>
u.id === id ? { ...u, name: 'Bình' } : u
);
2. Dữ liệu thật ra chưa đổi. Bạn spread nhưng chưa sửa gì bên trong. Khi đó không cập nhật là đúng.
3. Chu kỳ CD không chạy tới. Component chứa danh sách là OnPush và phép gán xảy ra ngoài event handler (callback thư viện ngoài, setTimeout chạy ngoài zone, hoặc app zoneless mà users là field thường chứ không phải signal). Đây là tầng 1 và tầng 2 ở Phần 3.
Ráp lại theo ba tầng
Đây là chỗ mình thấy đẹp nhất của câu hỏi này — nó cho thấy [...users] giải quyết đúng một tầng, và không phải tầng mình từng tưởng:
[...users] có tác dụng gì? |
|
|---|---|
Tầng 2 — OnPush của component nhận users làm @Input |
CÓ. Đây chính là mục đích thật của nó: đổi reference mảng để đánh thức OnPush. |
Tầng 3 — differ của ngFor |
KHÔNG. Item không đổi -> differ thấy y hệt -> không làm gì. Mà điều này là TỐT: view được giữ nguyên. |
| Item con dùng OnPush | KHÔNG. Cần thay chính object item. |
Và lật ngược lại để thấy toàn cảnh, so với trường hợp ở Phần 4:
| Bạn làm gì | Array ref | Item ref | Differ (không trackBy) |
Differ (có trackBy id) |
|---|---|---|---|---|
users.push(u) |
giữ | giữ | 1 ADD | 1 ADD |
users = [...users] |
đổi | giữ | 0 thao tác | 0 thao tác |
users = await fetch() |
đổi | đổi hết | xoá hết + dựng lại (!) | 0 thao tác + N identity change |
Hàng cuối mới là hàng gây đau. [...users] thì vô hại — nó chỉ đơn giản là không làm gì ở tầng differ.
5.2 — "Nếu mình đổi sang track $index thì chuyện gì xảy ra?"
Câu trả lời ngắn: track $index biến biển số thành vị trí. Bạn đang nói với Angular:
"Item thứ 0 luôn luôn là cùng một item, bất kể nó chứa dữ liệu gì."
Hệ quả kéo theo là toàn bộ thuật toán diff sụp xuống thành một phép so sánh độ dài mảng. Nội dung của mảng trở nên hoàn toàn vô nghĩa với việc quyết định ADD/MOVE/REMOVE.
Vì sao diff sụp xuống chỉ còn length
Nhớ lại cuốn sổ ở Mảnh 1: mỗi dòng sổ có một biển số, và biển số được gán một lần lúc tạo dòng, rồi không bao giờ đổi. Với track $index, ta có một bất biến rất mạnh:
Cuốn sổ luôn luôn là dãy biển số 0, 1, 2, …, n-1 theo đúng thứ tự đó.
Chứng minh nhanh bằng quy nạp: dòng nào cũng được tạo với biển số bằng vị trí lúc tạo; dòng không bao giờ bị MOVE (ta sẽ thấy ngay); dòng chỉ bị xoá ở đuôi. Nên dãy biển số luôn liên tục từ 0.
Bây giờ chạy vòng lặp so sánh. Ở vị trí i bất kỳ: biển số của dòng sổ tại đó là i, biển số của item mới tại đó cũng là i → luôn khớp.
Nhánh B (mismatch) chỉ được kích hoạt khi con trỏ sổ chạm null, tức là khi mảng mới dài hơn sổ cũ. Còn _truncate chỉ chạy khi mảng mới ngắn hơn. Kết luận rất gọn:
| Tình huống | Thao tác DOM |
|---|---|
| Mảng mới dài hơn | ADD, luôn ở CUỐI |
| Mảng mới ngắn hơn | REMOVE, luôn ở CUỐI |
| Cùng độ dài | Không có thao tác nào |
| Mọi item còn lại | identity change -> đổi con trỏ $implicit |
Và điều quan trọng nhất: MOVE không bao giờ xảy ra. Không một lần nào. Ngăn kéo tạm ở Mảnh 3 trở nên vô dụng, vì chẳng bao giờ có mismatch ở giữa danh sách để cần cứu view.
Chạy tay: chèn một item vào đầu danh sách
Sổ cũ: view0=[A] view1=[B] view2=[C] (biển số 0,1,2)
Mảng mới: [X, A, B, C] (biển số 0,1,2,3)
| i | Biển số sổ | Biển số mới | Khớp? | Xe cũ | Xe mới | Hành động |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | CÓ | A | X | identity change |
| 1 | 1 | 1 | CÓ | B | A | identity change |
| 2 | 2 | 2 | CÓ | C | B | identity change |
| 3 | — | 3 | KHÔNG | — | C | ADD |
Kết quả: 0 MOVE, 0 REMOVE, 1 ADD ở cuối, 3 identity change.
Màn hình hiển thị X A B C. Hoàn toàn đúng. Đây là lý do bug này rất khó phát hiện: nhìn bằng mắt thì không có gì sai.
Nhưng hãy nhìn kỹ chuyện gì vừa xảy ra ở tầng DOM:
DOM node vốn thuộc về A -> bây giờ mang dữ liệu của X
DOM node vốn thuộc về B -> bây giờ mang dữ liệu của A
DOM node vốn thuộc về C -> bây giờ mang dữ liệu của B
DOM node MỚI TINH ở cuối -> mang dữ liệu của C
Bạn chèn một item vào đầu, nhưng Angular tạo view mới ở cuối, rồi đẩy dữ liệu trượt xuống qua các view có sẵn.
So sánh với track user.id:
DOM node MỚI TINH ở đầu -> X
DOM node của A -> vẫn là A, chỉ dịch xuống
DOM node của B -> vẫn là B
DOM node của C -> vẫn là C
Đây mới là điều chúng ta thực sự muốn.
Chạy tay: xoá item ở đầu
Sổ cũ: [A] [B] [C] [D]
Mảng mới: [B, C, D]
| i | Khớp? | Xe cũ -> mới | Hành động |
|---|---|---|---|
| 0 | CÓ | A -> B | identity change |
| 1 | CÓ | B -> C | identity change |
| 2 | CÓ | C -> D | identity change |
| — | hết mảng, sổ còn dòng | _truncate -> REMOVE view của D |
Bạn bấm nút xoá trên hàng A, nhưng cái view thực sự bị huỷ là view của D.
Vậy bug cụ thể là gì?
Vì binding vẫn chạy lại đầy đủ, mọi thứ được ràng buộc với dữ liệu đều hiển thị đúng. Cái bị hỏng là mọi thứ không được ràng buộc với dữ liệu — tức là trạng thái sống nằm trong bản thân DOM hoặc trong component con.
Focus và text chưa commit.
@for (user of users; track $index) {
<input #note placeholder="Ghi chú...">
<span>{{ user.name }}</span>
}
Bạn đang gõ dở "gọi lại chiều nay" trong ô ghi chú của hàng An (vị trí 0). Một user mới được đẩy vào đầu danh sách. Bây giờ: tên hiển thị ở hàng 0 đổi thành user mới, nhưng ô input vẫn giữ nguyên chữ "gọi lại chiều nay" và con trỏ vẫn đang nhấp nháy trong đó. Ghi chú của An giờ nằm trên hàng của người khác.
State bên trong component con.
@for (user of users; track $index) {
<app-user-card [user]="user"/>
}
export class UserCardComponent {
isExpanded = false; // state nội bộ, không đến từ @Input
isEditing = false;
}
Bạn mở rộng thẻ của user ở vị trí 2. Danh sách được sort lại. View không bị move, chỉ $implicit đổi. isExpanded vẫn true ở vị trí 2, nhưng vị trí 2 giờ là người khác. Thẻ sai bị mở.
ngOnInit không chạy lại — đây là cái âm thầm nhất.
ngOnInit() {
this.chart = renderChart(this.user.stats); // (X)
this.sub = this.api.watch(this.user.id).subscribe(...); // (X)
}
View không bị huỷ và tạo lại, nên ngOnInit chỉ chạy đúng một lần. Sau khi danh sách sort lại, biểu đồ và subscription vẫn thuộc về user cũ, trong khi tên hiển thị đã là user mới.
(Cái này cũng có thể xảy ra với track user.id khi item bị MOVE, nên quy tắc chung là: setup phụ thuộc @Input phải đặt trong ngOnChanges hoặc effect, không đặt trong ngOnInit. Nhưng với $index thì nó xảy ra ở gần như mọi thay đổi, chứ không chỉ khi reorder.)
Animation chạy sai chỗ. Với :enter animation, item mới ở đầu sẽ không có hiệu ứng, mà hàng cuối cùng mới là hàng chạy animation vào. Với :leave, bạn xoá hàng đầu nhưng hàng cuối là hàng biến mất.
Các trạng thái DOM khác. Scroll bên trong hàng, <video> đang phát, vùng text đang bôi đen, instance của thư viện bên thứ ba (bản đồ, editor, chart), tooltip đang mở. Tất cả đều dính ở vị trí thay vì đi theo item.
Còn hiệu năng thì sao?
Đây là chỗ trực giác hay sai. Nhiều người nghĩ $index là "chậm". Thực ra nó nhanh về mặt cấu trúc (gần như không tạo/xoá view), nhưng có thể chậm về mặt nội dung.
Chèn 1 item vào đầu danh sách 1000 hàng:
track user.id |
track $index |
|
|---|---|---|
| Thao tác cấu trúc | 1 ADD + các move | 1 ADD |
Số view có $implicit đổi |
1 | 1000 |
| Số view phải chạy lại binding | ~1 | 1000 |
| Component con OnPush bị đánh dấu dirty | ~1 | 1000 (vì @Input đổi ref) |
Nếu mỗi hàng có pipe nặng, component con phức tạp, hoặc format ngày tháng, thì $index làm bạn re-render cả danh sách chỉ vì một phép chèn.
Nói gọn: $index không phải vấn đề hiệu năng, nó là vấn đề đúng/sai. Nhưng trong một số kịch bản nó còn kéo theo cả vấn đề hiệu năng.
Vậy khi nào $index là lựa chọn đúng?
Nó không phải là thứ luôn sai. Nó đúng khi vị trí thực sự chính là danh tính:
- Danh sách chỉ đọc, không có state tương tác trong mỗi hàng (bảng hiển thị thuần).
- Danh sách chỉ bao giờ thêm/bớt ở cuối (log stream, chat append-only, kết quả phân trang nối đuôi).
- Item là primitive và có thể trùng nhau, ví dụ
['error', 'warn', 'error']. Ở đâytrack itemsẽ báo lỗi vì trùng khoá, còn$indexlà câu trả lời đúng về mặt ngữ nghĩa. - Bạn đang render một dãy cố định như
@for (i of [1,2,3,4,5]; track $index).
Cái bẫy đặc thù của @for ở v17–v20
Angular 17 trở đi bắt buộc phải khai báo track, và nó ném lỗi runtime NG0955 khi phát hiện khoá trùng nhau:
NG0955: The provided track expression resulted in duplicated keys
for a given collection.
Rất nhiều người gặp lỗi này và "sửa" bằng cách đổi sang track $index, vì lỗi biến mất ngay lập tức. Theo mình đây là cái bẫy nguy hiểm nhất trong cả bài.
Lý do: track $index không bao giờ sinh khoá trùng, nên nó luôn dập tắt được lỗi. Nhưng NG0955 đang cố báo cho bạn một bug thật ở tầng dữ liệu: hoặc là API trả về bản ghi lặp, hoặc là bạn nối hai mảng có id chồng nhau, hoặc là id bị undefined ở vài phần tử (và undefined === undefined nên tất cả bị coi là cùng một item).
Đổi sang $index là giấu triệu chứng và đổi lấy một lớp bug mới ở tầng UI. Việc cần làm là đi tìm vì sao id bị trùng.
Thứ tự ưu tiên khi chọn track
1. track user.id <- có id nghiệp vụ ổn định. LUÔN ưu tiên.
2. track user <- object bất biến, không bao giờ bị thay bằng bản sao.
3. track name <- primitive, chắc chắn không trùng.
4. track $index <- chỉ khi vị trí ĐÚNG LÀ danh tính,
hoặc primitive có thể trùng.
Và một mẹo tự kiểm tra rất nhanh, dùng được cho mọi trường hợp:
Nếu tôi đảo ngược thứ tự cả danh sách, cái mà
tracktrả về cho mỗi item có đổi không? Không đổi → khoá đúng. Đổi → khoá đang gắn với vị trí, và bạn sắp có bug.
Phần 6 — trackBy: cú pháp và năm cái bẫy
Bài toán
Ta đã biết cần dạy differ định nghĩa danh tính. Nhưng dạy bằng cách nào?
Vấn đề là template không phải TypeScript. Bạn không thể viết logic ở đó. Cái duy nhất bạn có thể làm là chuyển một tham chiếu hàm từ class sang directive. Và đường ống duy nhất để chuyển là @Input.
Nghe thì đơn giản, nhưng chính vì đây là một cú "gửi hàm qua template" nên nó sinh ra một loạt vấn đề rất đặc thù về this, về tính ổn định của tham chiếu, và về thời điểm hàm được nhận.
Ba thứ Angular đưa ra
// 1. Một kiểu dữ liệu chuẩn
export interface TrackByFunction<T> {
(index: number, item: T): any;
}
// 2. Một @Input trên NgForOf
@Input() ngForTrackBy: TrackByFunction<T>;
// 3. Một từ khoá trong microsyntax
// trackBy: <expression>
Nhớ lại quy tắc đặt tên ở Phần 2 — tiền tố directive + từ khoá viết hoa chữ đầu. Nên trackBy trong microsyntax được mở gói thành input ngForTrackBy:
<!-- Bạn viết -->
<div *ngFor="let user of users; trackBy: trackById">{{ user.name }}</div>
<!-- Angular biên dịch -->
<ng-template ngFor let-user [ngForOf]="users" [ngForTrackBy]="trackById">
<div>{{ user.name }}</div>
</ng-template>
Nhìn dạng đã mở gói sẽ thấy ngay bản chất: trackById ở đây là một biểu thức được đánh giá để lấy ra một giá trị, và giá trị đó phải là một hàm. Nó không phải lời gọi hàm. Đây là nguồn gốc của bẫy số 1.
Chuỗi sự kiện
export class UserListComponent {
users: User[] = [];
trackById(index: number, user: User): any {
return user.id;
}
}
- Angular đánh giá biểu thức
trackById→ lấy được tham chiếu hàm → gán vào inputngForTrackBy. - Ở lần
ngDoCheckđầu tiên,NgForOftạo differ và truyền hàm đó vào lúc tạo:this._differ = this._differs.find(value).create(this.ngForTrackBy); - Differ cất hàm vào
_trackByFn. - Từ đó về sau, mỗi lần diff, với mỗi item, differ gọi
this._trackByFn(index, item)để lấy biển số.
Hai chi tiết ở bước 2 và 4 cần đóng đinh, vì bẫy 2 và bẫy 4 nằm đúng ở đó: differ được tạo đúng một lần, và nó giữ hàm như một property của chính nó.
Bẫy 1 — Gọi hàm thay vì truyền hàm
<!-- (X) SAI -->
<div *ngFor="let u of users; trackBy: trackById(u)">
<div *ngFor="let u of users; trackBy: trackById()">
Cả hai đều truyền vào kết quả của lời gọi (một số, hoặc undefined), chứ không phải hàm. Differ sau đó cố gọi một con số như hàm.
May là Angular có cảnh báo ở dev mode: NG02200: trackBy must be a function, but received "undefined". Nhưng cảnh báo này nằm lẫn trong console và cực dễ bị bỏ qua.
Quy tắc: sau trackBy: không bao giờ có dấu ngoặc đơn.
<!-- (V) ĐÚNG -->
<div *ngFor="let u of users; trackBy: trackById">
Bẫy 2 — this bên trong trackBy không phải là component
Đây là cái bẫy tinh vi nhất, và cũng là cái mà phần giải thích ở Phần 4 cho phép ta tự suy ra.
Nhớ lại: differ cất hàm vào this._trackByFn rồi gọi bằng this._trackByFn(index, item). Trong JavaScript, gọi theo cú pháp obj.method() thì this bên trong sẽ là obj. Mà ở đây obj là differ, không phải component của bạn.
export class UserListComponent {
trackKey = 'id';
// (X) this ở đây là DefaultIterableDiffer
trackById(index: number, user: User) {
return user[this.trackKey]; // this.trackKey là undefined
}
}
Cách sửa: dùng arrow function làm class property. Arrow function bắt this từ lúc khai báo (lexical), tức là từ instance component, và không ai đổi được nó.
export class UserListComponent {
trackKey = 'id';
// (V) this luôn là component
trackById = (index: number, user: User) => user[this.trackKey];
}
Nếu hàm của bạn không hề dùng this thì viết method thường vẫn chạy đúng. Nhưng đây là loại bug chỉ nổ ra khi ai đó thêm một dòng this.xxx vào sáu tháng sau. Nên mình nghĩ nên mặc định dùng arrow property cho mọi trackBy.
Bẫy 3 — Trả về giá trị không ổn định
Biển số được so bằng Object.is. Nên hàm phải trả về thứ so sánh được bằng tham chiếu hoặc giá trị nguyên thuỷ, và phải ổn định qua các lần gọi.
// (X) Object mới mỗi lần gọi -> không bao giờ khớp
// -> tệ hơn cả không dùng trackBy
trackBy = (i: number, u: User) => ({ id: u.id });
// (X) Mảng mới mỗi lần gọi
trackBy = (i: number, u: User) => [u.id, u.type];
// (V) Ghép thành chuỗi
trackBy = (i: number, u: User) => u.type + '-' + u.id;
// (V) Nguyên thuỷ
trackBy = (i: number, u: User) => u.id;
Trường hợp { id: u.id } đặc biệt độc: bạn tưởng đã bật tối ưu, nhưng thực tế bạn vừa tạo ra một biển số mới toanh cho mọi item ở mọi chu kỳ CD. Kết quả là danh sách bị xoá sạch và dựng lại ở mỗi lần change detection — kể cả khi không có gì thay đổi. Đây là một trong những nguyên nhân "app Angular giật lag không hiểu nổi" hay gặp nhất.
Một biến thể khác của cùng lỗi:
// (X) id có thể undefined với item chưa lưu lên server
trackBy = (i: number, u: User) => u.id;
Nếu ba item mới tạo đều có id === undefined, cả ba mang cùng một biển số → differ coi chúng là cùng một item → hai item biến mất khỏi màn hình. Đây chính là kịch bản sinh ra lỗi NG0955 ở Phần 5.2. Cách xử lý:
trackBy = (i: number, u: User) => u.id ?? u.tempId;
Bẫy 4 — Đổi hàm trackBy lúc runtime không có tác dụng
Xem lại điều kiện tạo differ:
if (!this._differ && value) {
this._differ = this._differs.find(value).create(this.ngForTrackBy);
}
Điều kiện !this._differ nghĩa là: tạo một lần, rồi thôi. Nếu sau đó bạn gán một hàm trackBy khác vào input, differ vẫn tiếp tục dùng hàm cũ mà nó đã cất từ đầu.
<!-- (X) Đổi trackFn giữa chừng gần như chắc chắn không ăn -->
<div *ngFor="let u of users; trackBy: currentTrackFn">
Điều này cũng giải thích một chuyện có thể bạn đang thắc mắc: nếu lỡ truyền vào một hàm mới ở mỗi chu kỳ CD thì sao? Câu trả lời là không sao cả — differ đã chốt hàm từ lần đầu, nó không quan tâm. Khác hẳn với React, nơi truyền hàm mới vào dependency sẽ làm hỏng memo hoá.
Nói cách khác, bẫy này không gây chậm; nó chỉ khiến một tính năng bạn tưởng có thật ra không hoạt động. Nếu thật sự cần đổi chiến lược track lúc runtime, hãy dùng @if để dựng lại hẳn khối *ngFor.
Bẫy 5 — Không viết được arrow function ngay trong template
<!-- (X) Không biên dịch được -->
<div *ngFor="let u of users; trackBy: (i, u) => u.id">
Ngôn ngữ biểu thức của template Angular không hỗ trợ khai báo hàm. Bạn buộc phải trỏ tới một thành viên của class.
Ràng buộc tưởng nhỏ này thực ra rất phiền trong thực tế: một component có năm danh sách thì phải viết năm method trackByXxx, toàn những hàm một dòng chỉ để trả về .id. Đây là loại lặp lại vô nghĩa mà framework nên tự lo.
Và đây chính là một trong những lý do trực tiếp khai sinh ra @for. Angular đã xoay chuyển vấn đề: thay vì bắt bạn cung cấp một hàm, @for nhận thẳng một biểu thức được đánh giá cho từng item.
// Cũ: phải có hàm trong file TS
trackById = (i: number, u: User) => u.id;
<div *ngFor="let u of users; trackBy: trackById"> ... </div>
<!-- Mới: chỉ cần biểu thức, không cần gì trong file TS -->
@for (u of users; track u.id) { ... }
Không còn hàm, nên không còn bẫy this, không còn bẫy gọi nhầm có ngoặc, không còn bẫy hàm được chốt một lần.
Bảng tổng kết Phần 6
| Bẫy | Triệu chứng | Cách tránh |
|---|---|---|
Có dấu ngoặc sau trackBy: |
Cảnh báo NG02200 trong console |
Chỉ truyền TÊN hàm |
Dùng this trong method thường |
undefined bất ngờ |
Khai báo arrow property |
| Trả về object/mảng mới | Danh sách dựng lại mỗi chu kỳ CD, app giật | Trả về nguyên thuỷ hoặc chuỗi ghép |
id bị undefined |
Item biến mất, hoặc NG0955 |
u.id ?? u.tempId |
| Đổi hàm track lúc chạy | Im lặng không có tác dụng | Dựng lại khối bằng @if |
| Viết arrow inline | Lỗi biên dịch template | Dùng class member, hoặc chuyển sang @for |
Và một mẫu chuẩn để copy dùng:
export class UserListComponent {
users: User[] = [];
trackById = (_: number, user: User) => user.id;
}
<div *ngFor="let user of users; trackBy: trackById">{{ user.name }}</div>
Dấu _ cho tham số index là một quy ước nhỏ nhưng có ý nghĩa: nó tuyên bố rõ rằng danh tính của item không phụ thuộc vị trí.
Phần 7 — @for của Angular 17 → 20
Điểm mình thấy hay nhất: @for không thay đổi bài toán, nó chỉ thay đổi cách giải. Nên mọi thứ ở các phần trên đều còn nguyên giá trị.
Năm vấn đề tích tụ của *ngFor
1. trackBy là tuỳ chọn, nên mặc định là sai. Như đã phân tích ở Phần 4, mặc định trackByIdentity gần như luôn sai với dữ liệu từ server. Framework để một cái bẫy ở chế độ mặc định và trông chờ lập trình viên tự biết mà tránh. Đa số không biết.
2. Ma sát khi khai báo. Muốn dùng đúng thì phải viết một method trong file TS, nhớ dùng arrow property, nhớ không thêm ngoặc. Năm danh sách là năm hàm một dòng vô nghĩa. Cái đúng lại tốn công hơn cái sai — đó là thiết kế ngược.
3. Phải import. CommonModule hoặc NgFor phải có trong imports. Quên thì lỗi khó hiểu. Với standalone component thì mỗi file lại phải khai báo lại.
4. Trạng thái rỗng phải tự lo. Không có cú pháp cho "danh sách rỗng thì hiện gì". Phải ghép thêm *ngIf, hoặc tệ hơn là *ngIf với ng-template else, khiến template rối lên.
5. Chi phí kiến trúc. *ngFor là một directive. Mỗi instance kéo theo: một class được khởi tạo, DI để lấy ViewContainerRef/TemplateRef/IterableDiffers, các @Input setter, và một hook ngDoCheck được đăng ký vào vòng đời. Rồi bản thân thuật toán diff phải cấp phát một object bản ghi cho mỗi item, duy trì năm danh sách liên kết và hai bảng tra trùng, tạo ra một object mô tả thay đổi, rồi mới duyệt lại object đó để thi hành.
Vấn đề 5 là thứ chỉ framework mới sửa được, còn 1–4 là thứ người dùng phải chịu hằng ngày.
Built-in control flow
Angular 17 giới thiệu @if, @for, @switch, @defer. Đây không phải directive. Đây là cú pháp cấp ngôn ngữ template, được trình biên dịch hiểu trực tiếp.
@for (user of users; track user.id) {
<div>{{ user.name }}</div>
} @empty {
<p>Chưa có người dùng nào.</p>
}
Ba thay đổi bản chất, không chỉ là đổi vỏ:
*ngFor |
@for |
|
|---|---|---|
| Bản chất | Directive được tra trong DI | Chỉ thị của trình biên dịch |
| Cần import | Có | Không |
track |
Tuỳ chọn | Bắt buộc |
| Thuật toán | IterableDiffer |
Thuật toán mới, không dùng differ |
track bắt buộc: sửa một lỗi thiết kế
Bỏ track đi thì không biên dịch được:
NG5002: @for loop must have a "track" expression
Đây là lỗi biên dịch, không phải cảnh báo runtime. Không thể bỏ qua, không thể ship lên production.
Nhìn bề ngoài thì đây chỉ là một ràng buộc phiền phức. Thực chất nó đảo ngược một quyết định thiết kế sai: định nghĩa danh tính là thông tin ngữ nghĩa mà chỉ lập trình viên mới có, nên framework không được phép đoán. *ngFor đã đoán, và đoán sai. @for từ chối đoán.
track là biểu thức, không phải hàm
<!-- Cũ: cần một hàm trong class -->
<div *ngFor="let u of users; trackBy: trackById">
<!-- Mới: chỉ cần biểu thức -->
@for (u of users; track u.id) {
File TypeScript của bạn không cần thêm dòng nào. Và cùng lúc, ba bẫy ở Phần 6 đều biến mất:
- Không có hàm → không có
thisbị mất → hết bẫy 2. - Không có lời gọi → không thể nhầm ngoặc → hết bẫy 1.
- Biểu thức được biên dịch cứng vào template → không thể tráo lúc runtime → bẫy 4 không còn ý nghĩa.
Biểu thức track được đánh giá với item và $index trong tầm nhìn, cộng thêm ngữ cảnh component:
@for (u of users; track u.id) { } <!-- phổ biến nhất -->
<!-- khi vị trí ĐÚNG LÀ danh tính -->
@for (u of users; track $index) { }
@for (u of users; track u.type + u.id) { } <!-- khoá ghép -->
<!-- gọi method của component -->
@for (u of users; track this.keyOf(u)) { }
Một chi tiết tối ưu đáng biết: nếu biểu thức chỉ phụ thuộc vào item và $index (không đụng tới component), trình biên dịch nâng nó thành một hàm cấp module dùng chung cho mọi instance — không cấp phát closure nào. Nếu bạn tham chiếu state của component, Angular mới tạo closure riêng. Nên track u.id rẻ hơn track this.keyOf(u) một chút, dù cả hai đều hợp lệ.
Lưu ý: bẫy 3 ở Phần 6 vẫn còn nguyên. track { id: u.id } vẫn tạo object mới mỗi lần, vẫn phá sạch danh sách mỗi chu kỳ CD. Cú pháp mới không cứu bạn khỏi khoá không ổn định.
Thuật toán diff mới — phần mình thấy đáng giá nhất
@for không dùng IterableDiffer. Angular viết lại từ đầu một thuật toán nằm trong list_reconciliation.ts, tên là reconcile().
Thay vì quét tuần tự từ đầu tới cuối như differ cũ, reconcile() dùng hai con trỏ ép từ hai đầu vào giữa.
Giai đoạn 1 — cắt phần đầu giống nhau.
Cũ: [A, B, C, D, E]
Mới: [A, B, X, D, E]
^ ^
khớp, khớp -> chỉ cập nhật giá trị, bỏ qua
Giai đoạn 2 — cắt phần đuôi giống nhau.
Cũ: [A, B, C, D, E]
Mới: [A, B, X, D, E]
^ ^
khớp, khớp -> bỏ qua
Sau hai giai đoạn này, phần cần xử lý chỉ còn [C] ↔ [X].
Giai đoạn 3 — nếu một bên đã cạn. Nếu chỉ còn item ở phía mảng mới → thêm thuần tuý. Nếu chỉ còn ở phía sổ cũ → xoá thuần tuý. Xong, không cần cấu trúc dữ liệu phụ nào.
Giai đoạn 4 — mới phải làm việc nặng. Kiểm tra trường hợp hoán vị hai đầu trước (rất hay gặp khi kéo thả). Nếu không phải, mới dựng bảng tra các item đã tháo ra và xử lý giống tinh thần của differ cũ.
Vì sao điều này quan trọng?
Hãy nhìn lại các thao tác thực tế mà app của chúng ta làm với danh sách:
| Thao tác | Sau khi cắt đầu/đuôi còn lại gì? |
|---|---|
| Nối thêm vào cuối (infinite scroll) | Chỉ phần mới -> giai đoạn 3 |
| Chèn vào đầu (feed mới) | Chỉ item mới -> giai đoạn 3 |
| Xoá một hàng ở giữa | Đúng một item -> giai đoạn 3 |
| Sửa một hàng | Rỗng -> dừng ở giai đoạn 2 |
| Refresh trả về dữ liệu y hệt | Rỗng hoàn toàn -> dừng ngay ở giai đoạn 1 |
Phần lớn thao tác đời thực kết thúc trước khi chạm tới giai đoạn 4. Trong khi đó IterableDiffer cũ luôn cấp phát bản ghi và duyệt hết mọi phần tử, không có đường tắt nào.
Ba khác biệt kiến trúc
1. Không cấp phát bản ghi trung gian. Differ cũ tạo một IterableChangeRecord_ cho mỗi item, duy trì năm danh sách liên kết (_itHead, _additionsHead, _movesHead, _removalsHead, _identityChangesHead) và hai bảng tra trùng. Với 1000 item là 1000 object sống suốt vòng đời, cộng áp lực GC mỗi chu kỳ.
2. Thi hành ngay, không qua hai pha. Quy trình cũ là: diff → dựng object mô tả thay đổi → forEachOperation duyệt lại → thi hành. reconcile() gọi thẳng attach/detach/create/destroy ngay trong lúc quét. Một lượt duy nhất.
3. Không còn tầng directive. @for biên dịch thành lệnh repeaterCreate / repeater ở tầng nội bộ. Không có class directive, không tra DI, không @Input setter, không hook ngDoCheck đăng ký vào vòng đời.
Angular công bố mức cải thiện tới khoảng 90% ở một số benchmark cộng đồng khi chuyển sang control flow mới. Con số này là điều kiện lý tưởng của benchmark, mình nghĩ không nên kỳ vọng nguyên vẹn trong app thật — nhưng hướng cải thiện thì có cơ sở kiến trúc rõ ràng như trên.
Điều không thay đổi
Rất quan trọng: bản chất ngữ nghĩa giữ nguyên hoàn toàn.
- View vẫn được move thay vì dựng lại khi khoá khớp mà vị trí đổi.
- Vẫn có cơ chế identity change: khoá khớp nhưng object bị thay → giữ view, đổi con trỏ dữ liệu.
- Mọi hệ quả của việc chọn khoá sai —
$indexgây lệch state, khoá không ổn định gây dựng lại — y hệt như với*ngFor.
Toàn bộ Phần 4 và bài học về $index chuyển thẳng sang @for không sửa một chữ. Chỉ có cú pháp và tốc độ thi hành là khác.
@empty
@for (user of users; track user.id) {
<app-user-card [user]="user"/>
} @empty {
<app-empty-state message="Chưa có người dùng nào"/>
}
Trước đây phải viết:
<app-user-card *ngFor="let u of users; trackBy: trackById" [user]="u"/>
<app-empty-state *ngIf="users.length === 0"/>
Cách cũ có một vấn đề ít ai để ý: biểu thức users.length === 0 được đánh giá lại ở mọi chu kỳ CD, tách rời khỏi vòng lặp. @empty được trình biên dịch gắn thẳng vào chính khối lặp, biết ngay số item mà không cần binding riêng.
Ràng buộc: @empty phải nằm ngay sau dấu } của @for, không được có gì chen giữa.
Biến ngầm định và cái bẫy lồng nhau
@for cung cấp sáu biến, tất cả đều có tiền tố $:
| Biến | Ý nghĩa |
|---|---|
$index |
Vị trí (bắt đầu từ 0) |
$count |
Tổng số item |
$first / $last |
Có phải phần tử đầu / cuối |
$even / $odd |
Chỉ số chẵn / lẻ |
Cái bẫy: khi lồng nhau, biến trong che biến ngoài.
<!-- (X) $index bên trong che mất $index bên ngoài -->
@for (row of rows; track row.id) {
@for (cell of row.cells; track cell.id) {
{{ $index }} <!-- chỉ là index của cell -->
}
}
Giải pháp là đặt bí danh bằng let:
<!-- (V) -->
@for (row of rows; track row.id; let rowIdx = $index, isLastRow = $last) {
@for (cell of row.cells; track cell.id) {
{{ rowIdx }} - {{ $index }}
}
}
Cú pháp let gom tất cả bí danh vào một mệnh đề duy nhất, ngăn cách bằng dấu phẩy. Viết hai mệnh đề let riêng là lỗi biên dịch.
Vài cạnh sắc còn lại
Iterable không phải mảng. @for chấp nhận Set, Map, generator. Nhưng Angular phải vật chất hoá chúng thành mảng trước khi diff. Nếu bạn dùng Set cho danh sách lớn và cập nhật thường xuyên, hãy cân nhắc giữ sẵn một mảng.
Không tạo mảng mới trong biểu thức.
<!-- (X) filter() tạo mảng MỚI ở mọi chu kỳ CD -->
@for (u of users.filter(x => x.active); track u.id) { }
Cái này không phá diff (vì track u.id vẫn khớp — đây chính là chỗ track cứu bạn), nhưng nó chạy filter hàng chục lần mỗi giây một cách vô ích. Hãy dùng computed() hoặc một field đã tính sẵn.
ng-container không cần nữa. @for không đòi hỏi một phần tử chủ, nên những chỗ trước đây phải bọc <ng-container *ngFor> giờ viết thẳng được.
Migration tự động.
ng generate @angular/core:control-flow
Nó sẽ tự chuyển *ngIf/*ngFor/*ngSwitch sang cú pháp mới. Nhưng có một điểm bạn phải tự kiểm: những chỗ trước đây không có trackBy, tool buộc phải điền một track nào đó — và lựa chọn nó điền có thể không đúng ngữ nghĩa của bạn. Hãy rà lại từng chỗ.
Về tình trạng của *ngFor ở v20: nó vẫn hoạt động bình thường, code cũ không vỡ. Angular khuyến nghị @for cho code mới. Mình không dám chắc về tình trạng deprecation chính thức ở các bản sau v20, nên nếu anh/em cần con số chính xác để lập kế hoạch nâng cấp thì nên tra lại tài liệu chính thức.
Bảng tổng kết Phần 7
*ngFor |
@for |
|
|---|---|---|
| Cần import | CommonModule / NgFor |
Không |
track |
Tuỳ chọn, mặc định sai | Bắt buộc, lỗi biên dịch nếu thiếu |
| Cách khai báo khoá | Hàm trong class | Biểu thức trong template |
Bẫy this |
Có | Không |
| Trạng thái rỗng | Tự ghép *ngIf |
@empty |
| Cơ chế | Directive + ngDoCheck + IterableDiffer |
Lệnh biên dịch + reconcile() |
| Đường tắt cho thay đổi nhỏ | Không | Cắt đầu / cắt đuôi |
| Cấp phát bản ghi mỗi item | Có | Không |
| Ngữ nghĩa move / identity change | Có | Có — giống hệt |
Kết lại: @for không dạy ta điều gì mới về danh tính — nó chỉ khiến ta không thể lờ đi điều đó, và thi hành cùng một ý tưởng bằng bộ máy gọn hơn nhiều.
Phần 8 — @for + Signals trong app zoneless
Signal không thay đổi gì về track hay reconcile, mà thay thế tầng 1 của Phần 3: cái gì đánh thức Angular.
Bốn chỗ nhược điểm của Zone.js
Zone.js giải bài toán "làm sao biết có gì đó đổi" bằng cách hỏi một câu rất thô: "vừa có tác vụ bất đồng bộ nào kết thúc không?" Câu hỏi này hoạt động, nhưng nó thô ở bốn chỗ:
1. Nó đánh thức cho những thứ không liên quan. Người dùng rê chuột qua một tooltip, một thư viện bên thứ ba chạy setInterval để đo kích thước, một <img> load xong. Mỗi lần như vậy Zone.js đều báo động, Angular đều chạy một chu kỳ CD, và NgForOf của bạn đều diff lại 1000 phần tử. Không có gì thay đổi cả, nhưng công vẫn phải làm.
2. Nó không biết cái gì đổi. Khi được đánh thức, Angular chỉ biết "có thể có gì đó đã đổi ở đâu đó". Nó buộc phải duyệt cả cây component để tìm. Đây là lý do OnPush tồn tại: nó là một cách để bạn tự tay nói với Angular "đừng vào nhánh này". Nhưng đó là vá lỗi thủ công cho một bài toán mà framework lẽ ra nên tự biết.
3. Nó là một thư viện phải tải về. Vài chục KB, chạy trước cả app, và vá đè lên hàng loạt API toàn cục của trình duyệt.
4. Nó ngày càng khó theo kịp trình duyệt. async/await native là ví dụ kinh điển: Zone.js không chặn được, nên Angular từng phải biên dịch hạ cấp async/await xuống Promise chỉ để Zone.js nhìn thấy.
Gốc rễ của cả bốn: Zone.js trả lời sai câu hỏi. Nó trả lời "khi nào có thể có thay đổi", trong khi cái ta thật sự cần là "cái gì đã đổi, và view nào phụ thuộc vào nó".
Signal: một giá trị biết ai đang đọc mình
users = signal<User[]>([]);
// đọc — nếu đang trong ngữ cảnh reactive thì ĐĂNG KÝ phụ thuộc
users();
users.set(newList); // ghi — THÔNG BÁO cho mọi bên phụ thuộc
Hai vế đó dựng nên một đồ thị phụ thuộc. Angular không còn phải đi tìm. Nó được báo, và được báo chính xác.
Từ v20, chế độ không cần Zone.js đã ổn định:
bootstrapApplication(App, {
providers: [provideZonelessChangeDetection()]
});
Kèm theo là xoá zone.js khỏi mục polyfills trong angular.json.
Nó hoạt động như thế nào
Template của một component chính là một ngữ cảnh reactive. Khi Angular chạy hàm cập nhật template và gặp users(), nó ghi nhận: view này phụ thuộc vào signal users. Sau đó:
users.set([...])
-> signal thông báo cho các bên phụ thuộc
-> view chứa @for bị đánh dấu dirty
-> các view tổ tiên bị đánh dấu "cần đi xuyên qua"
-> scheduler xếp lịch một chu kỳ CD
-> Angular duyệt cây, NHƯNG bỏ qua mọi nhánh không dirty
-> tới view của bạn -> chạy reconcile()
So ba tầng của Phần 3 ở hai thế giới:
| Tầng | Zone.js | Zoneless + Signals |
|---|---|---|
| 1. Đánh thức | Mọi tác vụ async, kể cả vô can | Chỉ khi signal thực sự đổi giá trị |
| 2. Duyệt cây | Toàn bộ, trừ nhánh OnPush bị chặn | Chỉ đi tới các view dirty |
3. @for làm gì |
reconcile MỌI chu kỳ |
reconcile chỉ khi collection đổi |
Nhớ lại Phần 3: NgForOf.ngDoCheck() chạy vô điều kiện, nên differ.diff() cũng chạy vô điều kiện. Với @for trong app zoneless, cái tính chất "vô điều kiện" đó biến mất. Không ai đọc users() mà nó không đổi thì view không dirty, view không dirty thì reconcile không chạy. Danh sách 1000 phần tử của bạn hoàn toàn không bị đụng tới khi người dùng rê chuột.
Vài lời cuối
Toàn bộ bài viết này là cách hiện tại mình hiểu về *ngFor và @for, ghép lại từ việc đọc mã nguồn Angular, đọc tài liệu, và từ những lần tự mình gỡ bug. Nó chắc chắn còn chỗ chưa đủ sâu, và có thể có chỗ mình hiểu chưa đúng — đặc biệt là những chi tiết nội bộ có thể thay đổi giữa các phiên bản.
Nếu phải gói cả bài vào ba câu, mình sẽ chọn:
*ngForvà@forkhông phải vòng lặp. Chúng là bộ đồng bộ hoá giữa mảng và DOM, và câu hỏi duy nhất chúng cần trả lời là "hai lần chụp này khác nhau ở chỗ nào".- Để trả lời được câu đó, chúng cần một định nghĩa danh tính — và chỉ có bạn mới biết định nghĩa đó. Mọi bug về danh sách nhảy loạn, state lệch hàng, input mất chữ đều quy về việc bạn đã để framework tự đoán, hoặc đoán hộ nó bằng vị trí.
@forvà signal không thay đổi bài toán. Chúng chỉ làm cho việc trả lời sai trở nên khó hơn, và việc trả lời đúng trở nên rẻ hơn.
Nếu anh/em thấy chỗ nào mình viết còn sai hoặc thiếu, rất mong được góp ý ở phần bình luận — mình sẽ cập nhật lại bài. Cảm ơn anh/em đã đọc tới đây.
Linkedin: VuTranAngular
All rights reserved