Vibe Code
Giáo trình/Phần 4 — Nâng cao

Tốc độ web — đo trước, sửa sau

Chạy Lighthouse tại máy, đọc đúng hai mỏ vàng trong kết quả, trọng số điểm để biết hy sinh cái gì, và các bẫy đã mất cả buổi mới tìm ra.

Cấp 445 phút làm

Luật đầu tiên: cấm đoán

Cả một buổi sáng từng mất vì đoán mò — đổ cho phông chữ, cho mã theo dõi, cho ảnh — trong khi công cụ đo chỉ thẳng ra chỗ khác. Đo trước, sửa sau. Không có ngoại lệ.

Chạy Lighthouse ngay trên máy:

export CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
npx lighthouse@12 https://web-cua-ban.com \
  --only-categories=performance --form-factor=mobile --screenEmulation.mobile \
  --output=json --output-path=./do-1.json \
  --chrome-flags="--headless=new --no-sandbox --disable-gpu" --quiet
Báo lỗi `EPERM ... lighthouse.xxxx` lúc kết thúc thì kệ nó

Đó là lỗi dọn thư mục tạm trên Windows. File JSON vẫn ghi xong đầy đủ. Đừng chạy lại.

Hai mỏ vàng trong file kết quả

Đừng chỉ nhìn con số điểm. Hai mục này nói cho bạn biết phải sửa gì:

1. audits['largest-contentful-paint-element'] — chỉ đúng phần tử làm chậm, kèm 4 chặng:

Chặng Nghĩa Nếu chiếm phần lớn thì sửa
TTFB Máy chủ trả byte đầu Tối ưu phía máy chủ, bộ đệm
Load Delay Chờ mới bắt đầu tải Tải sớm hơn, bỏ chặn
Load Time Tải bản thân nó Nén ảnh, giảm cỡ
Render Delay Chờ trình duyệt vẽ ra JS dựng quá chậm — nén ảnh vô ích

Chi tiết quan trọng nhất của bảng này: nếu Render Delay chiếm 75% thì mọi công sức nén ảnh đều không chạm tới vấn đề. Bạn đang chờ JavaScript dựng xong.

2. audits['screenshot-thumbnails'] — ảnh từng khung hình dạng base64. Giải mã ra file rồi XEM. Đây là cách tìm nguyên nhân nhanh nhất, hơn mọi suy luận:

// node xem-khung.js do-1.json
const fs = require("fs");
const j = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
j.audits["screenshot-thumbnails"].details.items.forEach((it, i) => {
  const b64 = it.data.split(",")[1];
  fs.writeFileSync(`khung-${String(i).padStart(2, "0")}-${it.timing}ms.jpg`, Buffer.from(b64, "base64"));
});
console.log("Xong — mo cac file khung-*.jpg ra xem");

Hai lần trong một buổi, cách này lật ngược kết luận: một lần thấy giây 1,35 chỉ có mỗi một thẻ nhỏ hiện ra còn lại trắng trơn; một lần thấy giây 5,2 app vẫn chưa dựng — lộ ra một lỗi do chính mình gây ra.

Trọng số điểm — biết để đánh đổi

Chỉ số Trọng số Là gì
TBT 30% Tổng thời gian trang "đơ", không bấm được
LCP 25% Lúc phần nội dung lớn nhất hiện ra
CLS 25% Mức bố cục nhảy giật
FCP 10% Lúc thấy thứ đầu tiên
SI 10% Trang đứng yên hoàn chỉnh lúc nào

Suy ra hai quyết định thực dụng:

  • Hy sinh SI để giữ hiệu ứng đẹp là rẻ — mỗi giây SI xấu đi chỉ mất 1–2 điểm.
  • Đừng bao giờ đánh đổi TBT và CLS — chúng chiếm 55% điểm, và chúng cũng chính là hai thứ người dùng cảm nhận rõ nhất (đơ và nhảy chữ).

Với web dựng bằng JavaScript

Đây là phần nhiều người sửa sai hướng nhất.

  • Phần tử chậm nhất thường là CHỮ, không phải ảnh. Kiểm nodeLabel trước khi đi nén ảnh.
  • Trình duyệt không tính chữ đang trong suốt là đã vẽ. Hiệu ứng mờ dần (opacity: 0 → 1) áp lên khối ở màn hình đầu là trực tiếp đẩy lùi LCP. Cho trượt (transform: translateY) thì vô hại — trượt không hoãn phép đo, cũng không làm nhảy bố cục. Mờ dần để dành cho mục dưới màn hình.
  • Khung tĩnh dựng sẵn: chép phần tử lớn nhất (thường là tiêu đề H1 và thẻ phía trên nó) vào thẳng index.html, dùng đúng class CSS của trang, bọc trong <div id="son-lot" aria-hidden="true"> đặt position:fixed;inset:0, và gỡ ngay sau khi app dựng xong. Để lại là nó nằm dưới mọi mục, mục nào nền không kín sẽ lộ ra. Chỉ ăn thua khi CSS đã nhúng thẳng vào HTML.
Hoãn dựng một khung hình — phải có đường lui, không thì tự bắn vào chân

Mẹo thường gặp: hoãn việc dựng app một khung hình để trình duyệt kịp vẽ khung tĩnh.

requestAnimationFrame(() => setTimeout(mount, 0));

(Phải lồng setTimeout vì rAF chạy trước lúc vẽ.)

Bắt buộc kèm đường lui và cờ chạy-một-lần:

let daChay = false;
const chay = () => { if (!daChay) { daChay = true; mount(); } };
requestAnimationFrame(() => setTimeout(chay, 0));
setTimeout(chay, 60);                 // đường lui

Lý do: requestAnimationFrame KHÔNG chạy khi tab bị ẩn hoặc máy quá tải. Đã đo được lượt app mãi giây thứ 7 mới dựng, SI vọt lên 8,6 giây. Đây là lỗi tự gây ra, và mất một vòng deploy mới bắt được.

Hoạt cảnh ở màn hình đầu — quyết định của chủ web

Speed Index đo "trang đứng yên hoàn chỉnh lúc nào". Một vùng đổi pixel liên tục thì nó không bao giờ tính là xong. Thủ phạm hay gặp: chữ gõ lặp vô tận, con trỏ nhấp nháy, số đếm tăng dần, video nền, chữ chạy ngang.

Nhưng đây là quyết định của chủ web, không phải của người làm kỹ thuật. Cách đúng: nêu cái giá bằng con số rồi để chủ chọn — "giữ vòng lặp thì SI 4,0 giây thay vì 2,4 giây, mất 2–3 điểm". Đừng tự ý bỏ hiệu ứng để lấy điểm.

Nếu giữ, hãy làm cho nó rẻ:

  • Vòng lặp cập nhật trạng thái: chỉ cập nhật khi giá trị THẬT SỰ đổi. Số đếm chạy 1,1 giây là 66 khung hình nhưng chỉ có 16 con số khác nhau → 50 lần dựng lại là phí, và ăn thẳng vào TBT.
  • Phần đuôi (dấu "+", đơn vị) phải hiện ngay từ đầu, đừng đợi đếm xong mới thêm — nó nới ô ra làm cả hàng xê dịch đúng lúc kết thúc, ăn vào CLS.

content-visibility — khoản lợi lớn nhất cho trang dài

.muc-duoi-man-hinh {
  content-visibility: auto;
  contain-intrinsic-size: auto 760px;
}
`contain-intrinsic-size` là chiều cao phần RUỘT, chưa tính lề

Đặt bằng đúng chiều cao cả mục (gồm lề trên dưới) là trang phình thêm ~80px mỗi mục, thanh cuộn sai, và mối nhảy mục (#id) đáp lệch chỗ.

Cách đo đúng: bật lên, so document.body.scrollHeight trước và sau, trừ đi phần chênh.

Sau khi bật phải kiểm ba việc: chiều cao trang không đổi; cuộn xuống rồi lên chiều cao vẫn thế; mọi liên kết nhảy mục vẫn đáp đúng vị trí.

Ảnh và phông chữ

Ảnh:

  • Cỡ đúng = 2× cỡ hiển thị lớn nhất, đo bằng getBoundingClientRect() trên cả điện thoại lẫn màn 1440px.
  • Đừng hạ xuống đúng con số Lighthouse gợi ý. Nó đo trên máy giả lập mật độ 1,75×; hạ theo là màn 2×–3× (gần hết điện thoại thật) nhìn nhoè.
  • Ảnh người, ảnh chân dung: đừng đụng vào. Đó là thứ người ta soi kỹ nhất.
  • Header bộ đệm để immutable → đổi ảnh là phải đổi tên file, sửa hết tham chiếu rồi xóa bản cũ.

Phông chữ:

  • Chỉ preload phông của chữ trong màn hình đầu. Phông chỉ dùng giữa trang mà preload là tự nhét nó vào chuỗi tải tới hạn.
  • Muốn phông không vào chuỗi tới hạn: đừng gán font-family ở lớp luôn tồn tại, gán ở lớp chỉ bật khi cuộn tới.
  • Làm vậy rồi thì phải await document.fonts.load('28px "Tên Phông"') (kèm hạn ~700ms) trước khi chạy hiệu ứng — không thì chữ hiện bằng phông thường rồi nhảy sang phông thật giữa chừng.
scroll-snap bắt buộc lúc tải trang làm Chrome ngừng đo LCP

Một bẫy hiếm nhưng khó chịu: scroll-snap-type: y mandatory áp lên vùng cuộn ngay lúc tải trang có thể khiến Chrome không ghi được LCP — PageSpeed trả về NO_LCP kèm chữ Error, không có điểm nào cả.

Cách chữa: bật scroll-snap sau tương tác đầu tiên của người dùng (thêm một class vào <html> khi có sự kiện cuộn/chạm đầu tiên), giữ nguyên trải nghiệm mà vẫn đo được.

Hai cảnh báo khi đo

1. Máy bạn nhiễu. Nếu máy đang chạy nhiều tiến trình nền, SI có thể dao động 2,2–8,6 giây giữa các lần đo giống hệt nhau. FCP/LCP/CLS thì ổn định hơn. → Lấy trung vị 3 lần, và chốt bằng PageSpeed Insights (chạy trên máy chủ Google, sạch). Đừng báo cáo số đo tại máy như số chính thức.

2. Mã theo dõi (beacon) không ảnh hưởng điểm. Nó là script defer, không đụng FCP/LCP/TBT. Nó chỉ làm dài nhánh trong sơ đồ chẩn đoán — mục không tính điểm. Đừng khuyên tắt nó để lấy điểm; lời khuyên đó sai.

Điểm số không phải mục tiêu — nhưng nó là ngôn ngữ chung với kháchchuyên gia insight & case thực tế

Tôi không tin vào việc đuổi theo 100 điểm. Nhưng tôi vẫn đo, vì ba lý do thực dụng:

  1. Khách hiểu con số. "Web của anh 98 điểm, web đối thủ 46 điểm" là câu nói có sức nặng hơn mọi giải thích kỹ thuật.
  2. Nó bắt được lỗi tự gây ra. Bẫy requestAnimationFrame ở trên không lộ ra bằng mắt thường — chỉ lộ khi nhìn ảnh từng khung.
  3. Nó buộc mình đo thay vì đoán, và đó là thói quen có giá trị vượt ra ngoài chuyện tốc độ.

Cái tôi không làm: bỏ tính năng người dùng thích để lấy thêm 3 điểm. Điểm là công cụ, không phải sản phẩm.

Tôi tưởng web chậm là do ảnhngười dùng bình thường góp ý

Lúc nào cũng vậy: thấy chậm là đi nén ảnh. Lần đó tôi nén từ 2 MB xuống 300 KB, điểm không nhúc nhích.

Chạy đúng quy trình trong bài này mới thấy: phần tử chậm nhất là cái tiêu đề chữ, và 75% thời gian là ngồi chờ JavaScript dựng. Ảnh của tôi nằm dưới màn hình, trình duyệt còn chưa thèm tải.

Bài học: mở file kết quả ra xem nó chỉ vào đâu. Mất 2 phút, tiết kiệm cả buổi đi sai hướng.

Quy trình tối ưu một trang, theo đúng thứ tựchuyên gia thực thi
1. Đo 3 lần, lấy trung vị. Lưu file JSON.
2. Mở audits['largest-contentful-paint-element']:
   phần tử nào? chặng nào chiếm % lớn nhất?
3. Xuất ảnh từng khung ra xem — trang trông thế nào ở giây 1, 2, 3?
4. Sửa ĐÚNG MỘT thứ.
5. Đo lại, so với trung vị cũ.
6. Lặp lại. Chốt cuối cùng bằng PageSpeed Insights.

Bước 4 là bước người ta hay bỏ: sửa 5 thứ một lúc rồi không biết thứ nào có tác dụng — và nếu điểm tụt, cũng không biết lùi cái nào.

Chốt review bài nàychuyên gia review
  • Đủ: quy trình đo, hai mỏ vàng trong kết quả, trọng số điểm, các chiêu cho web dựng bằng JS, content-visibility, ảnh, phông, hai cảnh báo khi đo.
  • Đã trả giá thật: bẫy rAF không chạy khi tab ẩn, bẫy contain-intrinsic-size tính nhầm lề, bẫy scroll-snap làm mất LCP, và chuyện máy đo nhiễu.
  • Rủi ro còn lại: điểm Lighthouse không đo được chất lượng sản phẩm. Một trang 100 điểm mà không ai muốn dùng vẫn là trang hỏng. Đo tốc độ sau khi đã làm đúng UX, không phải thay cho nó.
Bài tập chốt
  1. Đo web của bạn 3 lần, lấy trung vị, ghi lại cả 5 chỉ số.
  2. Xuất ảnh từng khung, nhìn thật — trang bạn trông thế nào ở giây thứ 1?
  3. Tìm phần tử chậm nhất và chặng chiếm phần trăm lớn nhất. Viết ra giấy: "Tôi phải sửa X vì chặng Y chiếm Z%."
  4. Sửa đúng một thứ, đo lại, chép bảng trước/sau vào README.

Nếu bước 3 của bạn ra kết quả "Render Delay 70%" mà trước đó bạn định đi nén ảnh — bạn vừa tiết kiệm được một buổi.