Angular Learning
Kiểm thử, Hiệu năng & Triển khai

Profiling hiệu năng

Đo tải trang, tương tác, change detection và rendering Angular để tìm nút thắt bằng bằng chứng.

Mục tiêu của bài

Profiling không phải là tìm mọi dòng code “có vẻ chậm”. Đây là quy trình nối một trải nghiệm người dùng chậm với số đo, trace và nguyên nhân có thể kiểm chứng; sau đó chỉ giữ tối ưu khi phép đo sau thay đổi tốt hơn.

Mục lục

Tư duy đo trước khi tối ưu

Người dùng không quan tâm một component dùng OnPush hay một hàm được memoize. Họ cảm nhận trang xuất hiện muộn, gõ chữ bị trễ hoặc danh sách cuộn giật. Những biểu hiện này là điểm bắt đầu đúng để profiling.

Hiện tượng, chỉ số và nguyên nhân

Hãy tách ba tầng sau để tránh sửa nhầm chỗ:

TầngCâu hỏiVí dụ
Hiện tượngNgười dùng thấy gì?Gõ vào ô tìm kiếm, ký tự xuất hiện sau một nhịp rõ rệt.
Chỉ sốCó thể đo sự chậm này bằng gì?Interaction to Next Paint (INP) cao; trace có task JavaScript dài sau input.
Nguyên nhânCông việc nào tạo chi phí?Mỗi lần gõ lọc 10.000 item, tạo mảng mới và render lại toàn bộ danh sách.

Một trace có nhiều hàm tốn thời gian chưa tự chứng minh hàm đó cần tối ưu. Nó chỉ là bằng chứng để đặt giả thuyết. Ví dụ, parse một response lớn có thể đắt nhưng xảy ra một lần và không nằm trên hành trình quan trọng; tối ưu nó có thể không thay đổi điều người dùng cảm nhận.

Đầu ra tốt của một lượt profiling

Viết được một câu có cấu trúc: “Ở kịch bản X, chỉ số Y là Z; trace cho thấy chi phí chính là A; thay đổi B giảm/tăng chỉ số thành C.” Nếu chưa viết được câu này, chưa nên kết luận tối ưu nào hiệu quả.

Quy tắc đo công bằng

So sánh trước/sau chỉ có giá trị khi điều kiện đủ giống nhau. Ghi lại ít nhất:

  • Bản build và môi trường: ưu tiên production build; development mode có kiểm tra và source map làm sai lệch chi phí. Ghi commit hoặc version, trình duyệt và cấu hình máy nếu có thể.
  • Đường đi và dữ liệu: cùng route, tài khoản, dữ liệu fixture và số lượng item. Một danh sách 20 hàng không đại diện cho lỗi ở 2.000 hàng.
  • Trạng thái cache: phân biệt cold load (cache trống) với warm load (đã có cache). Không kết luận cold load từ một lần warm load.
  • Mạng và CPU: chỉ bật throttling khi mô phỏng một điều kiện đã định. Dùng cùng cấu hình ở cả hai lượt đo.
  • Số mẫu: chạy nhiều lần. Một lần nhanh hoặc chậm bất thường có thể do GC, extension, request nền hoặc scheduler của hệ điều hành.

Không cần biến mỗi benchmark thành nghiên cứu thống kê. Với một sửa lỗi UI, ba đến năm lượt đo trên cùng kịch bản thường đủ để phát hiện kết quả đảo chiều hoặc nhiễu lớn. Nếu nhiễu lớn, điều cần sửa đầu tiên là kịch bản đo chứ không phải code ứng dụng.

Mô hình hiệu năng Angular

Angular không làm chậm hay nhanh theo một phép màu riêng. Ứng dụng vẫn dùng luồng chính (main thread) của trình duyệt để chạy JavaScript, tính style, layout, paint và xử lý event. Angular thêm các bước quản lý state, change detection và cập nhật DOM vào đường đi đó.

Đường đi của một tương tác

Ví dụ người dùng thay đổi từ khóa tìm kiếm. Một tương tác thường đi theo flow sau:

direction: right

n0: "Người dùng gõ phím" {
  width: 168
  height: 58
}

n1: "Chrome dispatch input" {
  icon: ../../images/d2/chrome.svg
  width: 200
  height: 58
}

n2: "Angular handler\nCập nhật state /\nsignal" {
  width: 152
  height: 79
}

n3: "Change detection\nKiểm tra view cần cập\nnhật" {
  width: 192
  height: 79
}

n0 -> n1
n1 -> n2
n2 -> n3
Người dùng gõ phímChrome dispatch inputAngular handlerCập nhật state /signalChange detectionKiểm tra view cần cậpnhật
Người dùng gõ phímChrome dispatch inputAngular handlerCập nhật state /signalChange detectionKiểm tra view cần cậpnhật
Đường đi từ input event đến khung hình mới (1/2)
direction: right

n3: "Change detection\nKiểm tra view cần cập\nnhật" {
  width: 192
  height: 79
}

n4: "Template tính binding\nGhi DOM khác biệt" {
  width: 192
  height: 58
}

n5: "Browser style → layout\nPaint → compositing" {
  width: 200
  height: 58
}

n6: "Khung hình mới" {
  width: 136
  height: 58
}

n3 -> n4
n4 -> n5
n5 -> n6
Change detectionKiểm tra view cần cậpnhậtTemplate tính bindingGhi DOM khác biệtBrowser style → layoutPaint → compositingKhung hình mới
Change detectionKiểm tra view cần cậpnhậtTemplate tính bindingGhi DOM khác biệtBrowser style → layoutPaint → compositingKhung hình mới
Đường đi từ input event đến khung hình mới (2/2)

Một input chậm có thể mắc ở bất kỳ mũi tên nào. debounceTime có thể giảm số lần gọi API, nhưng không chữa được layout nặng. OnPush có thể giảm số view được kiểm tra, nhưng không chữa được một hàm sắp xếp 10.000 phần tử ngay trong event handler. Trace giúp xác định mũi tên nào thực sự tốn thời gian.

Bốn nhóm chi phí

NhómDấu hiệu thường thấyCông cụ để xác nhậnHướng xử lý ban đầu
Tải tài nguyênLCP muộn, waterfall dài, bundle JS lớnNetwork, Lighthouse, CoverageLazy-load, giảm dependency, cache và tối ưu ảnh/font.
JavaScriptLong task, interaction bị chặn trước paintPerformance trace, CPU profileGiảm tính toán, chia nhỏ work, chuyển tính toán sang Worker nếu phù hợp.
Angular/renderNhiều lượt change detection hoặc component render đắtAngular DevTools Profiler, traceThu hẹp dependency, OnPush, signal, track, virtual scrolling.
Trình duyệt vẽRecalculate Style, Layout hoặc Paint lớnPerformance trace, Rendering toolsGiảm DOM, tránh forced layout, ưu tiên transform/opacity khi animation.

Một lỗi thực tế có thể thuộc nhiều nhóm. Chẳng hạn bundle lớn làm tải JavaScript lâu, sau đó component nhận dữ liệu lớn lại tạo DOM quá nhiều. Đừng cố chọn một “mẹo tối ưu” bao trùm; đo và giải từng nút thắt theo thứ tự tác động lớn nhất.

Chọn và đọc chỉ số

Chỉ số là ngôn ngữ chung giữa người phát triển, reviewer và người vận hành. Chọn chỉ số bám theo hành trình thay vì theo một con số đẹp trên dashboard.

Chỉ số trải nghiệm người dùng

Chỉ sốNó mô tảHữu ích khiKhông nên hiểu nhầm
LCP (Largest Contentful Paint)Thời điểm phần nội dung lớn nhất đang nhìn thấy được renderTrang vào đầu, trang sản phẩm, landing pageLCP tốt không đảm bảo nút bấm sau đó phản hồi nhanh.
INP (Interaction to Next Paint)Độ trễ từ một tương tác đến paint kế tiếp; phản ánh event, JavaScript và renderForm, tìm kiếm, menu, thao tác bảngMột click trong DevTools không thay thế hoàn toàn dữ liệu người dùng thật.
CLS (Cumulative Layout Shift)Mức dịch chuyển bố cục ngoài ý muốn trong vòng đời trangẢnh, font, quảng cáo, dữ liệu tải muộnAnimation do người dùng chủ động thường không phải lỗi CLS cần xử lý.
FCP (First Contentful Paint)Lúc nội dung đầu tiên xuất hiệnNhận biết trang trắng quá lâuFCP có thể tốt trong khi nội dung chính vẫn chưa xuất hiện.
TTFB (Time to First Byte)Thời gian nhận byte phản hồi đầu tiênChẩn đoán server, CDN, SSR, redirectTTFB thấp không nói gì về bundle hay client render sau đó.

Các ngưỡng Web Vitals thay đổi theo hướng dẫn của nền tảng và cần được diễn giải theo percentile của dữ liệu người dùng thật. Khi profiling một lỗi cục bộ, hãy dùng chúng để định hướng, rồi thêm chỉ số sát hành trình hơn: “thời gian từ click Lọc đến danh sách mới được paint” hoặc “thời gian route /orders interactive”.

Chỉ số chẩn đoán

Các chỉ số sau không phải đích sản phẩm nhưng giải thích vì sao trải nghiệm chậm:

  • Long task: một task giữ main thread trên 50 ms. Trong khoảng đó trình duyệt khó phản hồi input hoặc paint khung hình mới.
  • Scripting time: thời gian chạy JavaScript. Tách được chi phí handler, parse JSON, framework và thư viện.
  • Rendering time: tổng thời gian style, layout, paint và compositing. Nó thường tăng cùng kích thước/độ phức tạp DOM.
  • Số lượt change detection và thời lượng mỗi lượt: chỉ có ý nghĩa khi so với cùng thao tác. “Nhiều” không luôn là lỗi; một lượt ngắn có thể rẻ hơn một lượt ít nhưng render hàng nghìn node.
  • Số node DOM, heap và garbage collection (GC): giúp thấy danh sách quá lớn, node không được dọn, hoặc allocation lặp lại. Heap tăng liên tục sau khi rời route là tín hiệu cần điều tra leak.
  • Kích thước và phần mã không dùng của bundle: giúp tìm chi phí tải. Coverage chỉ là gợi ý: code chưa chạy trong một kịch bản vẫn có thể cần cho route khác.

Bảng baseline tối thiểu

Lưu baseline trong issue, PR hoặc ghi chú kỹ thuật. Bảng nhỏ này giúp người khác tái lập kết luận:

Kịch bảnĐiều kiệnChỉ số chínhTrướcSauKết luận
Cold load /products4× CPU, Fast 3G, cache trốngLCP3,4 s2,6 sGiảm sau khi lazy-load chart.
Gõ 10 ký tự tìm kiếm2.000 sản phẩm fixturethời gian input → paint180 ms42 msKhông còn long task.
Mở/đóng chi tiết 20 lầnChrome desktop, cache ấmheap sau GC82 MB83 MBChưa thấy tăng dần rõ rệt.

Đừng ghi “nhanh hơn” mà không chỉ rõ đơn vị, điều kiện và hướng thay đổi. Một “giảm 50%” từ 2 ms xuống 1 ms có thể kém quan trọng hơn giảm 180 ms xuống 80 ms trên thao tác người dùng lặp lại.

Chuẩn bị ca đo

Tạo kịch bản tái lập

Viết thao tác như một kịch bản ngắn, có điểm bắt đầu và điểm kết thúc quan sát được. Ví dụ:

  1. Mở /products trong cửa sổ ẩn danh để cache trống.
  2. Chờ danh sách 2.000 sản phẩm fixture tải xong.
  3. Đặt con trỏ vào ô tìm kiếm và gõ angular bằng cùng cách mỗi lần.
  4. Kết thúc khi text “7 kết quả” đã xuất hiện và browser paint khung hình chứa kết quả đó.

Tránh đo bằng cách click ngẫu nhiên trong lúc DevTools đang mở nhiều tab. Kịch bản càng mơ hồ, trace càng khó so sánh. Với luồng rất quan trọng, có thể tự động hóa thao tác bằng E2E và thu thập metric riêng; nhưng trace thủ công vẫn hữu ích để điều tra một lần hồi quy cụ thể.

Đánh dấu mốc ứng dụng

performance.mark() không làm ứng dụng nhanh hơn. Nó đặt tên cho thời điểm nghiệp vụ để bạn nối trace trình duyệt với hành vi Angular.

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

@Component({
  selector: 'app-product-search',
  template: `
    <input (input)="search($any($event.target).value)" placeholder="Tìm sản phẩm" />
    <p>{{ resultCount() }} kết quả</p>
  `,
})
export class ProductSearchComponent {
  readonly resultCount = signal(0);

  search(query: string): void {
    performance.mark('product-search:start');

    // Ví dụ: cập nhật state sau khi lọc hoặc nhận kết quả API.
    this.resultCount.set(query.length);

    requestAnimationFrame(() => {
      performance.mark('product-search:painted');
      performance.measure('product-search:input-to-paint', {
        start: 'product-search:start',
        end: 'product-search:painted',
      });
    });
  }
}

Mốc requestAnimationFrame là ước lượng cho “trước paint kế tiếp”, không phải chứng minh tuyệt đối rằng mọi pixel đã hiện. Nó vẫn rất hữu ích để so sánh cùng một flow trước/sau. Đặt mark ở ranh giới nghiệp vụ như bắt đầu tìm kiếm, nhận API, render xong skeleton; đừng rải mark vào mọi hàm nhỏ.

Không để instrumentation làm bẩn production

Mark/measure số lượng lớn, log object lớn hoặc gửi từng phép đo lên mạng có thể tự tạo chi phí. Hãy lấy mẫu, chỉ bật theo môi trường/debug flag và dọn performance.clearMarks()/clearMeasures() nếu tạo tên động trong một luồng dài.

Chrome DevTools: tìm chi phí của trình duyệt

Chrome DevTools cho thấy toàn bộ đường đi trên main thread, không chỉ phần Angular. Đây là công cụ ưu tiên khi không biết hiện tượng đến từ JavaScript, network hay rendering.

Performance trace

Quy trình điều tra một thao tác tương tác:

  1. Mở DevTools → Performance. Đóng bớt extension và tab nặng để giảm nhiễu.
  2. Bật screenshots nếu cần đối chiếu timeline với màn hình. Chỉ bật network/CPU throttling khi đó là điều kiện đã ghi trong baseline.
  3. Nhấn Record, thực hiện đúng một kịch bản, dừng record ngay sau khi màn hình ổn định.
  4. Trong overview, tìm vùng interaction chậm hoặc khoảng main thread bị kín. Zoom vào task dài nhất gần thời điểm người dùng bắt đầu thao tác.
  5. Đọc call tree hoặc bottom-up: call tree giữ thứ tự gọi; bottom-up gom thời gian theo hàm và tốt để tìm nơi tiêu tốn tổng thời gian.
  6. Kiểm tra các event product-search:* nếu đã đặt mark. Chúng neo trace vào bước nghiệp vụ, tránh suy luận từ timestamp trần.

Các giá trị sau là số liệu giả định để học cách đọc timeline, không phải trace đã đo hay benchmark Angular. Mốc 0 là lúc handler nhận input; các span con nằm trong 92 ms JavaScript, nên không cộng chúng thêm lần nữa. Sơ đồ phía trên mô tả quan hệ; waterfall này bổ sung vị trí trên trục thời gian.

Phân rã thời gian input đến paintTrace minh họa · 141 ms
Chi tiết span

Chi tiết span

Input event đến khung hình kết quả mới chrome · OK · 141 ms
ID
input
Cha
Không có (root)
Bắt đầu
0 ms
Kết thúc
141 ms

Không khai báo attributes.

JavaScript angular · OK · 92 ms
ID
js
Cha
input
Bắt đầu
0 ms
Kết thúc
92 ms

Không khai báo attributes.

filterProducts() angular · OK · 58 ms
ID
filter
Cha
js
Bắt đầu
0 ms
Kết thúc
58 ms
{
  "fixture.items": 2000,
  "measured": false
}
sortByRelevance() angular · OK · 21 ms
ID
sort
Cha
js
Bắt đầu
58 ms
Kết thúc
79 ms

Không khai báo attributes.

Template / framework angular · OK · 13 ms
ID
template
Cha
js
Bắt đầu
79 ms
Kết thúc
92 ms

Không khai báo attributes.

Recalculate Style + Layout chrome · OK · 31 ms
ID
layout
Cha
input
Bắt đầu
92 ms
Kết thúc
123 ms

Không khai báo attributes.

Paint chrome · OK · 18 ms
ID
paint
Cha
input
Bắt đầu
123 ms
Kết thúc
141 ms

Không khai báo attributes.

Bảng thời gian đầy đủ
Phân rã thời gian input đến paint · ms từ mốc 0
Span / IDChaServiceBắt đầuThời lượngKết thúcTrạng thái
Input event đến khung hình kết quả mớiinput—chrome0 ms141 ms141 msOK
JavaScriptjsinputangular0 ms92 ms92 msOK
filterProducts()filterjsangular0 ms58 ms58 msOK
sortByRelevance()sortjsangular58 ms21 ms79 msOK
Template / frameworktemplatejsangular79 ms13 ms92 msOK
Recalculate Style + Layoutlayoutinputchrome92 ms31 ms123 msOK
Paintpaintinputchrome123 ms18 ms141 msOK
Về trace

Timeline tổng hợp cho bài học; không lấy từ Chrome hoặc Angular DevTools thực tế.

Thời gian tương đối từ mốc 0; không cộng thời lượng các span chồng nhau. Quan hệ cha/con không khẳng định thứ tự nhân quả.

  • chrome
  • angular

Ở mô hình này, 92 + 31 + 18 = 141 ms. Con số input → paint 180 ms ở ca minh họa cuối bài là một kịch bản khác, không phải tổng suy ra từ waterfall này. Thứ tự giả định giúp học cách đọc; với trace thật, đối chiếu timestamp và khoảng chờ trước khi kết luận nhân quả. Giả định parent/child không tự chứng minh critical path.

Trong ví dụ, tối ưu change detection trước filterProducts() chưa phải giả thuyết mạnh nhất. Task chính là lọc/sắp xếp đồng bộ. Tuy nhiên, sau khi giảm JavaScript, chi phí layout 31 ms có thể trở thành nút thắt kế tiếp; profiling là vòng lặp, không phải một lần chạy.

Network, Coverage và Memory

Network trả lời “đợi cái gì?”: request nào chặn dữ liệu, có redirect, cache miss, payload lớn hay chuỗi request tuần tự không. Xem timing của request để tách thời gian DNS/kết nối, chờ server và tải nội dung. Đừng chỉ nhìn tổng thời gian waterfall rồi kết luận Angular chậm.

Coverage ước lượng byte JavaScript/CSS chưa được thực thi trong ca đo hiện tại. Dùng nó để đặt câu hỏi về một thư viện lớn hoặc route không cần tải ngay. Không xóa module chỉ vì Coverage tô đỏ: một code path ít dùng có thể phục vụ hành trình khác hoặc chỉ chạy sau tương tác.

Memory hữu ích khi ứng dụng càng dùng càng ì hoặc tab bị treo. Chụp heap snapshot trước và sau một chuỗi thao tác có thể tái lập, ví dụ vào/rời dialog 20 lần. Tìm object/nút DOM bị giữ lại qua reference. Một heap lớn không tự động là memory leak; tín hiệu đáng tin hơn là heap vẫn tăng qua nhiều chu kỳ sau khi GC và khi dữ liệu cũ đáng lẽ đã không còn reachable.

Dùng công cụ đúng câu hỏi

Network cho “vì sao dữ liệu đến muộn”; Performance cho “vì sao main thread bận”; Memory cho “vì sao tài nguyên không được giải phóng”. Chuyển công cụ khi câu hỏi đổi, thay vì ép một panel trả lời mọi thứ.

Angular DevTools: tìm chi phí ở component

Angular DevTools bổ sung góc nhìn framework cho trace trình duyệt. Trong ứng dụng Angular chạy ở chế độ được DevTools hỗ trợ, extension thường cung cấp cây component để xem state/input và tab Profiler để ghi các chu kỳ change detection/render.

Một quy trình thực dụng:

  1. Mở route lỗi và xác nhận Angular DevTools nhận diện ứng dụng.
  2. Vào Profiler, bắt đầu ghi, thực hiện duy nhất kịch bản đã định, rồi dừng.
  3. Xem chu kỳ nào dài bất thường và component nào đóng góp nhiều thời gian. Click component để đối chiếu input, state hoặc thao tác vừa xảy ra.
  4. Chuyển sang Chrome Performance nếu cần biết thời gian còn lại là JavaScript trong thư viện, layout hay paint.

Angular DevTools trả lời tốt câu hỏi “component/view nào được kiểm tra hoặc render đắt?”. Nó không thay Chrome DevTools cho tải ảnh, CSS layout hay một hàm thư viện không hiện rõ trong component tree. Kết luận mạnh thường cần cả hai: Angular DevTools khoanh vùng component, Chrome trace xác nhận chi phí thật trên main thread.

Đừng profile development mode để chốt số liệu

Development mode và DevTools có thể thêm overhead hoặc kiểm tra bổ sung. Dùng chúng để tìm quan hệ nhân quả; xác nhận chỉ số trải nghiệm cuối cùng bằng production build, nơi mã tối ưu và hành vi tải gần với người dùng hơn.

Đọc trace từ triệu chứng đến nguyên nhân

Long task và luồng chính

Main thread thực hiện phần lớn JavaScript và rendering. Khi một task chạy quá 50 ms, nó trở thành long task: event mới, callback hẹn giờ và cơ hội paint có thể phải đợi task kết thúc. Đây là lý do một handler 120 ms có thể làm UI “đơ” dù request API rất nhanh.

Tìm long task gần thời điểm interaction, rồi đi xuống call stack. Xem nó có phải một lần chi phí cần thiết (parse payload sau load), hay lặp lại theo từng phím gõ/cuộn/click. Một chi phí 30 ms lặp 10 lần có thể gây hại hơn một chi phí 100 ms chỉ xảy ra lúc khởi động mà người dùng không chờ.

Chia nhỏ công việc bằng setTimeout, requestIdleCallback hoặc scheduler chỉ hợp lý khi phần việc có thể trì hoãn mà không làm UX sai. Ví dụ, cập nhật biểu đồ phân tích có thể hoãn; nhưng danh sách kết quả mà người dùng đang nhìn cần phản hồi nhất quán. Với tính toán CPU nặng độc lập DOM, Web Worker là ranh giới tốt hơn vì nó giải phóng main thread, nhưng cần trả giá bằng sao chép/serialize dữ liệu và kiến trúc message.

JavaScript, style, layout, paint

Sau JavaScript, trình duyệt có thể cần:

  • Recalculate Style: xác định CSS rule nào áp dụng cho node. Selector phức tạp và thay đổi class trên DOM lớn có thể làm bước này tăng.
  • Layout: tính kích thước và vị trí. Đọc offsetHeight, getBoundingClientRect() hoặc thuộc tính layout ngay sau khi vừa ghi style có thể ép browser layout đồng bộ, gọi là forced reflow.
  • Paint: vẽ pixel cho phần thay đổi. Blur, shadow lớn, clip phức tạp hoặc vùng repaint lớn có thể đắt.
  • Compositing: ghép các layer. Animation transform và opacity thường ít buộc layout/paint hơn thay đổi top, left, width hoặc height, nhưng không phải lý do để tạo layer cho mọi node.

Ví dụ forced layout cần tránh trong vòng lặp:

// Không tốt: mỗi vòng có thể ghi rồi ép browser đọc layout mới ngay.
for (const item of items) {
  item.style.width = `${nextWidth}px`;
  console.log(item.offsetWidth);
}

// Tốt hơn: đọc trước, rồi gom các lần ghi.
const widths = items.map((item) => item.offsetWidth);
items.forEach((item, index) => {
  item.style.width = `${widths[index] + 10}px`;
});

Ví dụ này không bảo đảm luôn là hotspot. Chỉ áp dụng khi trace cho thấy layout có ý nghĩa. Thêm cache hoặc thay CSS mà không có trace có thể tăng độ phức tạp nhưng không thay đổi frame time.

Phân biệt tải chậm và tương tác chậm

Tải đầu và tương tác sau tải cần cách đo khác nhau:

Triệu chứngMốc để xemNghi ngờ trướcVí dụ cải tiến
Trang trắng/nội dung chính đến muộnTTFB, FCP, LCP, Network waterfallServer, render-blocking resource, ảnh, JS ban đầuCDN/cache, tối ưu ảnh, lazy-load route, giảm initial bundle.
Trang hiện nhưng click/gõ chậmINP, long task, event → paintHandler nặng, render nhiều DOM, layoutLọc theo debounce phù hợp, virtual scroll, chia nhỏ work, sửa layout.
Cuộn giậtFrame timeline, rendering, event scrollDOM quá lớn, handler scroll, paintVirtualize, passive listener khi phù hợp, tránh đo layout trên mọi scroll.
Dùng lâu càng chậmHeap snapshot, allocation timelineLeak subscription/listener, cache vô hạnDọn resource, giới hạn cache, xác nhận object được giải phóng.

Một thay đổi tối ưu tải đầu như lazy-load module có thể khiến lần mở tính năng đó chậm hơn vì phải tải muộn. Đó là trade-off hợp lệ nếu route ít dùng; hãy đo cả cold load route và navigation thực tế trước khi quyết định.

Change detection và rendering

Khi nào view được kiểm tra

Change detection là quá trình Angular đánh giá binding trong template và đồng bộ khác biệt vào DOM. Nó không có nghĩa Angular luôn viết lại toàn bộ DOM; DOM chỉ đổi khi giá trị binding khác. Nhưng bản thân việc duyệt view, gọi expression và tạo dữ liệu trung gian vẫn có chi phí.

Một view OnPush thường được kiểm tra khi nhận input mới, event trong subtree chạy, AsyncPipe phát giá trị, template đã đọc signal thay đổi, hoặc code gọi markForCheck(). Với chiến lược mặc định, phạm vi kiểm tra rộng hơn. Xem bài Signals và Zone.js để hiểu notification và change detection chi tiết.

Điểm quan trọng là notification không đồng nghĩa render đắt. Một signal đổi có thể đánh thức view cần thiết, nhưng template của view đó vẫn có thể gọi hàm sắp xếp mảng lớn. Ngược lại, OnPush không giúp nếu parent liên tục cấp input object/array mới mà child phải xử lý nặng theo mỗi lượt.

Giảm công việc nhưng không phá tính đúng đắn

Ưu tiên làm state và dependency rõ ràng trước khi dùng API ép render:

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

interface Product {
  id: number;
  name: string;
}

@Component({
  selector: 'app-product-filter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input (input)="query.set($any($event.target).value)" />

    @for (product of filteredProducts(); track product.id) {
      <p>{{ product.name }}</p>
    }
  `,
})
export class ProductFilterComponent {
  readonly query = signal('');
  readonly products = signal<Product[]>([]);

  readonly filteredProducts = computed(() => {
    const normalizedQuery = this.query().trim().toLowerCase();
    return this.products().filter((product) =>
      product.name.toLowerCase().includes(normalizedQuery),
    );
  });
}

computed cache kết quả cho đến khi query hoặc products đổi. Điều này phù hợp hơn gọi filterProducts() trong template, vì template có thể được đánh giá trong nhiều lượt change detection dù input không đổi. OnPush làm phạm vi kiểm tra rõ hơn, còn track product.id cho Angular một identity ổn định để tái sử dụng node DOM.

Không biến mọi field thành signal hoặc gắn OnPush toàn dự án mà không kiểm tra. State mutate tại chỗ, callback thư viện chạy ngoài Angular, hoặc component dựa vào side effect ngầm có thể làm UI không cập nhật. Migration an toàn là đo một subtree nóng, đổi nhỏ, rồi chạy test tương tác và trace lại.

Danh sách lớn và identity của item

Với danh sách, có hai chi phí khác nhau:

  1. Tính danh sách: lọc, sắp xếp, mapping hoặc tạo view model.
  2. Hiển thị danh sách: tạo/cập nhật node DOM, style, layout và paint.

track giải quyết chủ yếu vế thứ hai. Nó cho Angular biết item nào là cùng thực thể giữa hai mảng. Không có identity ổn định, khi mảng mới xuất hiện Angular có thể phải hủy/tạo lại nhiều node hơn cần thiết. track không làm phép lọc 50.000 item rẻ hơn.

<!-- Không nên track theo index khi danh sách có chèn/xóa/sắp xếp. -->
@for (product of products(); track $index) {
  <app-product-row [product]="product" />
}

<!-- Nên dùng khóa nghiệp vụ ổn định. -->
@for (product of products(); track product.id) {
  <app-product-row [product]="product" />
}

Khi số item nhìn thấy vẫn quá lớn, dùng virtual scrolling: chỉ render vùng đang nhìn thấy cùng một buffer nhỏ. Trade-off là chiều cao item động, accessibility, tìm kiếm trong trang và đo layout có thể phức tạp hơn. Chỉ đưa virtualization vào khi trace chứng minh DOM/render của danh sách là vấn đề.

Ví dụ hoàn chỉnh: danh sách sản phẩm bị lag

Giả sử route sản phẩm tải 2.000 item. Người dùng gõ vào ô tìm kiếm và cảm thấy ký tự phản hồi chậm. Các số đo trước/sau trong ca này và bảng baseline phía trên đều là minh họa, không phải kết quả benchmark đã thực thi.

Đo và lập giả thuyết

Kịch bản là gõ angular vào danh sách fixture sau khi dữ liệu tải xong. Trace ở production build cho mỗi ký tự cho thấy:

Quan sátSố đoDiễn giải ban đầu
input đến paint180 msVượt mức phản hồi mong muốn của tương tác gõ.
JavaScript trong handler92 msCó long task; cần mở stack để biết hàm nào.
filterProducts() + sort()79 msTính toán lặp trên toàn tập dữ liệu là nghi phạm chính.
Style/layout/paint49 msRender 2.000 hàng cũng đáng kể.
NetworkKhông có requestDebounce API không phải lời giải cho trace này.

Code ban đầu có hai vấn đề được trace củng cố: tính toán nằm trong template và tất cả hàng đều được render.

// Không nên: template có thể gọi lại hàm này nhiều lần.
filterProducts(): Product[] {
  return this.products
    .filter((product) => product.name.includes(this.query))
    .sort((a, b) => a.name.localeCompare(b.name));
}
@for (product of filterProducts(); track $index) {
  <app-product-row [product]="product" />
}

Sửa theo nguyên nhân

Bước đầu giữ một danh sách nguồn, đưa kết quả dẫn xuất vào computed, và track theo id. Nếu sort là yêu cầu UX, giữ nó; đừng xóa tính năng chỉ để benchmark đẹp.

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

interface Product {
  id: number;
  name: string;
}

@Component({
  selector: 'app-products-page',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      [value]="query()"
      (input)="query.set($any($event.target).value)"
      placeholder="Tìm sản phẩm"
    />

    @for (product of visibleProducts(); track product.id) {
      <app-product-row [product]="product" />
    }
  `,
})
export class ProductsPageComponent {
  readonly query = signal('');
  readonly products = signal<Product[]>([]);

  readonly matchedProducts = computed(() => {
    const normalized = this.query().trim().toLocaleLowerCase('vi-VN');

    return this.products()
      .filter((product) => product.name.toLocaleLowerCase('vi-VN').includes(normalized))
      .toSorted((a, b) => a.name.localeCompare(b.name, 'vi-VN'));
  });

  // Ví dụ đơn giản. Với danh sách thực sự lớn, thay bằng virtual scroll.
  readonly visibleProducts = computed(() => this.matchedProducts().slice(0, 100));
}

Giới hạn 100 item là một quyết định sản phẩm, không phải tối ưu trung lập: người dùng phải có phân trang, nút “xem thêm” hoặc virtual scroll để vẫn tiếp cận kết quả còn lại. Nếu yêu cầu là hiển thị mọi item cùng lúc, hãy thử virtualization thay vì cắt dữ liệu im lặng.

Đo lại và quyết định

Đo cùng kịch bản, dữ liệu và throttling. Ví dụ kết quả sau thay đổi:

Chỉ sốTrướcSauÝ nghĩa
input → paint (median 5 lượt)180 ms42 msNgười dùng thấy phản hồi gần như ngay.
JavaScript92 ms24 msKhông còn long task ở handler này.
Style/layout/paint49 ms12 msÍt node cần hiển thị hơn.
Hành vi tìm kiếmđúngđúngComponent/E2E test xác nhận vẫn truy cập được kết quả.

Kết luận không phải “Signals luôn nhanh”. Kết luận có bằng chứng hơn là: cache tính toán dẫn xuất, identity ổn định và giới hạn số DOM node đã giảm hai loại chi phí đo được trong kịch bản này. Nếu matchedProducts vẫn mất hàng trăm ms với 100.000 item, nút thắt tiếp theo là thuật toán/index tìm kiếm hoặc Worker, không phải tiếp tục giảm change detection.

Các tối ưu dễ sai và trade-off

Thay đổi hấp dẫnVì sao có thể saiCách ra quyết định tốt hơn
Bật OnPush ở mọi componentCó thể làm lộ mutation/callback ngoài Angular, UI stale và debugging khó hơnĐo subtree nóng, dùng immutable update và thêm test cho trạng thái async trước khi mở rộng.
Gọi detectChanges() để “chữa” UIÉp kiểm tra đồng bộ có thể che điểm notification sai và tăng workƯu tiên signal, AsyncPipe hoặc markForCheck() tại ranh giới callback.
Memoize mọi hàmCache có chi phí bộ nhớ, invalidation và có thể giữ dữ liệu cũCache phép tính được trace chứng minh là lặp/nặng; giữ dependency rõ ràng.
Debounce mọi inputGiảm việc nhưng thêm độ trễ cảm nhận; không chữa render/layout nặngDebounce request hoặc tính toán tốn kém theo yêu cầu UX, sau đó đo INP.
Lazy-load mọi thứGiảm initial bundle nhưng chuyển độ trễ sang lần dùng tính năngXem route nào phổ biến, prefetch có phù hợp không, và đo cold navigation.
Dùng virtual scroll cho mọi listTăng phức tạp focus, a11y, chiều cao động và testDùng khi DOM/render của list là hotspot với dữ liệu thật.
Xóa code theo CoverageCoverage chỉ phản ánh một phiên chạyKiểm tra route/feature ownership và bundle analyzer trước khi gỡ dependency.
Chỉ chạy Lighthouse một lầnĐiểm lab thay đổi theo máy/mạng và không đại diện mọi interactionLấy nhiều mẫu, đọc trace, rồi kết hợp field data nếu có.

Tối ưu không được làm mất tính đúng đắn

Nếu tối ưu làm kết quả tìm kiếm thiếu, screen reader không tới được item, loading state không hiện hoặc dữ liệu cũ bị hiển thị, đó là regression dù benchmark đẹp hơn. Luôn chạy test liên quan và kiểm tra thao tác thật sau thay đổi hiệu năng.

Quy trình profiling có thể lặp lại

Dùng vòng lặp sau cho một report hiệu năng, issue hoặc PR:

  1. Nêu triệu chứng và hành trình. Viết người dùng làm gì, dữ liệu bao nhiêu và “xong” nghĩa là gì.
  2. Chọn chỉ số chính. Ví dụ LCP cho tải trang, input-to-paint/INP cho gõ tìm kiếm, heap sau GC cho nghi memory leak.
  3. Ghi baseline. Dùng production build và điều kiện đo cố định; lưu trace hoặc ảnh chụp panel có timestamp.
  4. Đọc evidence. Khoanh một long task, request, component render hoặc layout event gần chỉ số xấu. Tách observation khỏi giả thuyết.
  5. Đổi một nguyên nhân có khả năng cao. Ví dụ thêm track product.id, tránh hàm nặng trong template, hoặc lazy-load một dependency lớn. Không gộp năm tối ưu vào một lượt.
  6. Xác minh tính đúng đắn. Chạy unit/component/E2E test phù hợp, kiểm tra keyboard và loading/error state nếu thay đổi UI.
  7. Đo lại. Cùng kịch bản, cùng điều kiện, nhiều lượt. So sánh median hoặc một summary nhất quán thay vì chọn lượt đẹp nhất.
  8. Giữ, điều chỉnh hoặc hoàn tác. Giữ khi chỉ số/trải nghiệm tốt hơn và trade-off chấp nhận được; hoàn tác khi không có lợi ích rõ ràng.

Một template ghi chú ngắn:

Kịch bản: Gõ "angular" ở /products với 2.000 fixture item.
Điều kiện: production build, Chrome 123, CPU 4×, cache ấm.
Baseline: input → paint median 180 ms; long task 92 ms trong filterProducts().
Giả thuyết: filter/sort chạy trong template và 2.000 row tạo render cost.
Thay đổi: computed + track id + virtualized/giới hạn viewport.
Kết quả: input → paint median 42 ms; không long task; test tìm kiếm xanh.
Quyết định: giữ; theo dõi cold navigation vì virtualization thêm dependency/cấu hình.

Checklist và tóm tắt

Trước khi kết thúc một lượt profiling, kiểm tra:

  • Có mô tả hành trình người dùng và dữ liệu đại diện.
  • Có baseline trước thay đổi, đơn vị đo và điều kiện đo.
  • Trace hoặc công cụ đã chỉ ra chi phí gần triệu chứng, không chỉ một linh cảm.
  • Đã phân biệt tải tài nguyên, JavaScript, Angular/render và browser rendering.
  • Mỗi thay đổi gắn với một giả thuyết; không gộp nhiều thay đổi không thể tách kết quả.
  • Đã đo lại cùng điều kiện và chạy kiểm thử hành vi liên quan.
  • Đã ghi trade-off, ví dụ độ trễ lazy-load, bộ nhớ cache hoặc accessibility của virtual list.

Tóm lại, performance Angular là chuỗi chi phí từ network đến JavaScript, change detection, DOM và paint. Chrome DevTools nhìn toàn bộ chuỗi; Angular DevTools giúp khoanh vùng component và chu kỳ framework. OnPush, Signals, computed, track và virtual scrolling là công cụ có điều kiện, không phải checklist phải bật hết. Hãy để số đo trên một hành trình thật quyết định công cụ nào đáng dùng.

Đi tiếp

On this page