Next.jsLighthouseCore Web Vitals

Tăng Tốc Website Next.js: Từ 58 lên 71 Điểm Lighthouse Mobile, với Số Đo Thật

Không phải danh sách mẹo chung: đây là những gì mình làm trên chính vinclair.cloud, kèm số đo trước và sau, cả thay đổi có ích lẫn thay đổi làm trang tệ đi.

Vinclair StudioCập nhật Đăng 6 nguồn được dẫn

Nhân vật Vinclair cầm đồng hồ bấm giờ ở vạch xuất phát khi một trang web hình tên lửa lao qua, cạnh logo Next.js và Lighthouse

Mình không muốn viết thêm một danh sách mẹo chung. Bài này ghi lại những gì mình đã làm trên chính vinclair.cloud, một site Next.js xuất tĩnh (static export) chạy sau Caddy và Cloudflare, kèm số Lighthouse trước và sau. Có thay đổi làm điểm tăng, cũng có thay đổi làm trang tệ đi, và mình ghi lại cả hai.

Lập trình viên cầm đồng hồ bấm giờ bên chiếc điện thoại có logo Next.js đang tải trang
Trên mobile, mỗi giây chờ đều lộ rõ. Bài này đi qua từng thay đổi mình đã đo.
Kết quả

Số đo trước và sau

Mình bắt đầu bằng kết quả để bạn thấy toàn cảnh. Đây là số Lighthouse 12.6 trên bản production ngày 14/10/2026, trước và sau một đợt tối ưu:

Performance58 · trước71 · sauLCP8,3 s · trước5,4 s · sauFCP6,0 s · trước3,1 s · sauDung lượng trang1.129 KiB · trước604 KiB · sauPerformance58 · trước71 · sauLCP8,3 s · trước5,4 s · sauFCP6,0 s · trước3,1 s · sauDung lượng trang1.129 KiB · trước604 KiB · sau
Hình 1. Bốn chỉ số mobile trước (xám) và sau (màu). Performance càng cao càng tốt; ba chỉ số còn lại càng thấp càng tốt.
Chỉ sốTrướcSau
Performance mobile5871
LCP mobile8,3 s5,4 s
FCP mobile6,0 s3,1 s
Dung lượng trang1.129 KiB604 KiB
Accessibility96100
SEO66100
Performance desktop8495
LCP desktop2,3 s1,4 s

Đợt này gồm: font tự host, bỏ CSS bên thứ ba, hiệu ứng đầu trang không che ảnh LCP, intro chỉ chạy trên desktop, và các sửa lỗi CLS.

Dù vậy, 71 điểm vẫn chưa đẹp, và LCP 5,4 s còn xa mốc 2,5 s mà web.dev coi là tốt (tính ở 75% lượt xem). Các phần dưới, mình đi qua từng việc đã làm và chỉ ra việc nào vẫn chưa đủ.

Thay đổi 1

1. Bỏ CSS chặn render từ bên ngoài

Việc đầu tiên mình xử lý là CSS. Trước đợt này, trang tải CSS từ domain khác (font và một file CSS icon). CSS chặn render: trình duyệt chờ các file đó trước khi vẽ chữ đầu tiên.

Mình chuyển font sang next/font/google: font được tải về lúc build và phục vụ cùng site, không còn request sang domain khác. Icon chuyển thành SVG nội tuyến (bộ Phosphor). FCP mobile giảm từ 6,0 s xuống 3,1 s trong cùng đợt.

Thay đổi 2

2. Ảnh LCP phải hiện từ khung đầu tiên

Sau CSS, mình quay sang ảnh. Trên trang chủ, ảnh lớn ở đầu trang là phần tử LCP. Nếu hiệu ứng mở đầu che hay cắt ảnh này, LCP chỉ được tính khi ảnh lộ ra. Vì thế mình đặt một luật: hiệu ứng được di chuyển hoặc phóng ảnh LCP, nhưng không được cắt hay ẩn nó.

Đi kèm luật đó là ba quy tắc về ảnh; có cái mình làm trước, có cái làm sau đợt đo ở trên:

  • Ảnh LCP không bao giờ loading="lazy". Ở bài nhật ký, ảnh báo cáo từng bị lazy-load; mình bỏ lazy và thêm fetchPriority="high" trong lần deploy sau đợt đo.
  • Mọi ảnh có srcset và sizes, với các bản WebP -800 và -560, để điện thoại không tải ảnh 1.200 px.
  • Chỉ ảnh dưới màn hình đầu tiên mới dùng loading="lazy".

Vì là static export, next.config.js đặt images: { unoptimized: true }: không có bước tối ưu ảnh tự động của Next.js. Các bản WebP nhỏ được tạo sẵn và khai báo tay trong srcset. Ảnh nhật ký chuyển từ PNG sang WebP giảm tổng từ 2,2 MB còn 0,33 MB (việc này làm trong lần deploy trước đợt đo).

Thay đổi 3

3. Chỉ chạy hiệu ứng ở nơi đáng chạy

Hiệu ứng là chỗ dễ làm chậm trang nhất, nên mình giới hạn nó lại. Intro chỉ chạy trên màn hình từ 981 px trở lên, một lần mỗi phiên, và bị bỏ qua khi người dùng bật giảm chuyển động (Reduced Motion). Trên điện thoại, nơi Lighthouse mobile đo, trang hiện ngay.

GSAP được cài từ npm và đóng gói cùng site thay vì tải từ CDN. Trong bản đang làm (chưa lên production), thẻ Google Analytics tải với strategy="lazyOnload", tức là sau khi trang tải xong.

Thay đổi 4

4. Mỗi component client chỉ nhập icon nó dùng

Lỗi tiếp theo khó thấy hơn nhiều. Thanh menu là component client. Theo ghi chú trong repo, khi nó nhập cả bộ đăng ký icon, mọi trang mang thêm khoảng 40 kB JavaScript. Bây giờ component client chỉ nhập đúng vài icon nó vẽ từ @phosphor-icons/react/ssr, còn bộ icon đầy đủ ở lại phía server.

Thay đổi 5

5. Đổi font mà không làm xô trang (CLS)

Đến đây tốc độ đã khá hơn, nhưng trang vẫn còn bị xô. CLS đo mức bố cục bị xô khi trang đang tải, và mình gặp hai nguồn xô:

  1. Font dự phòng khác cỡ font thật. Tiêu đề lớn đầu trang được chia sẵn thành ba dòng cố định, không cho xuống dòng tự do, nên khi font thật thay font dự phòng, số dòng không đổi và trang không bị đẩy xuống. Các nhãn nhỏ cũng không được xuống dòng.
  2. Một tùy chọn build tưởng là tối ưu. Ngày 01/11/2026 mình thử bật experimental.inlineCss của Next.js để nhúng CSS thẳng vào HTML, bỏ một request. Tài liệu gọi đây là tính năng thử nghiệm, và ghi rằng nó có lợi nhất cho CSS dạng atomic như Tailwind. Trên bản build local, trang nhật ký mobile bị CLS 0,203 ở 6 trên 6 lần tải có giả lập mạng chậm; tắt tùy chọn thì 0 trên 6 lần. Mình tắt nó và ghi lý do ngay trong next.config.js để người sau không bật lại.
Cách đo

Đo nhiều lần, vì một lần chạy dễ cho số may mắn

Bài học ở phần trên dẫn mình tới thói quen đo nhiều lần. Trang hướng dẫn đầu tiên của mình được đo trên bản build local ngày 01/11/2026, Lighthouse 12.6.1 mobile, ba lần: performance 91, 90, 91; LCP 3,2 s; CLS gần 0; accessibility, best practices và SEO đều 100. Cùng ngày, sau khi chỉ sửa thẻ tiêu đề, mô tả và robots, ba lần đo lại cho 88, 87, 87 và LCP 3,7 s. Không có CSS hay JavaScript nào đổi giữa hai đợt.

Ảnh chụp thậtBáo cáo Lighthouse 12 mobile cho bản build local của bài này: Performance 87, Accessibility 100, Best Practices 100, SEO 100
Báo cáo Lighthouse 12 mobile mình chạy ngày 01/11/2026 cho chính trang này trên bản build local (npx serve out), không phải bản production ngày 14/10. Chạy lại vài lần, điểm Performance sẽ dao động.

Mình chưa tìm ra nguyên nhân, và cách đọc hợp lý nhất là độ dao động của phép đo trên cùng một máy. Đó cũng là lý do không tin một lần chạy. Số local thường đẹp hơn production vì không có độ trễ mạng thật. Mình sẽ đo lại sau khi trang lên site.

Xử Lý Lỗi

Khi số đo không như mong đợi

Trong lúc tối ưu, mình vấp vào ba tình huống mà bạn cũng có thể gặp với một site Next.js static export.

Điểm Lighthouse nhảy lên xuống dù không sửa gì

Đây là chuyện bình thường. Tài liệu Lighthouse về độ dao động liệt kê nhiều nguồn: mạng, phần cứng máy đo, tiến trình khác đang chạy, và cả chính trình duyệt. Mình chạy ít nhất ba lần, tắt extension (dùng cửa sổ ẩn danh), so trung vị chứ không so một lần chạy đẹp nhất.

next build báo lỗi khi bật output: 'export'

Static export không có server Node, nên một số tính năng không dùng được: rewrites, redirects, headers trong next.config.js, Server Actions, ISR, route động thiếu generateStaticParams(), và tối ưu ảnh bằng loader mặc định của next/image. Với ảnh, mình tự xuất WebP nhiều kích thước rồi dùng thẻ img với srcset; header cache thì đặt ở Caddy.

CLS tăng sau khi bật một tùy chọn "tăng tốc"

Mình gặp đúng chuyện này với experimental.inlineCss: CLS lên 0,203 ở cả sáu lần đo, như mình kể ở phần trên. Bài học là đo lại cả bốn chỉ số sau mỗi thay đổi, không chỉ nhìn Performance.

Tiếp theo

Việc còn dang dở

Dù đã làm khá nhiều, LCP mobile trên trang chủ vẫn trên 2,5 s. web.dev chia LCP thành bốn phần:

◷PHẦN 1TTFBchờ byte đầu tiên…PHẦN 2Trễ trước tảiảnh chưa được gọi↓PHẦN 3Tải ảnhcỡ file, mạng✓PHẦN 4Trễ vẽhiện lên màn hình◷PHẦN 1TTFBchờ byte đầu tiên…PHẦN 2Trễ trước tảiảnh chưa được gọi↓PHẦN 3Tải ảnhcỡ file, mạng✓PHẦN 4Trễ vẽhiện lên màn hình
Hình 2. Bốn phần của LCP theo web.dev. Đo từng phần trước khi chọn cách sửa.

Bước tiếp theo của mình là đo từng phần trên production thay vì đoán, rồi mới quyết định giảm cỡ ảnh hay đổi cách cache. Mình cũng làm các sản phẩm của studio theo cùng cách: Glubbo, Roastior, Night Paws và Scovidex.

Nhớ 4 điều

Điểm mấu chốt

Chia sẻ lên Threads
  1. Dung lượng trang mobile giảm gần một nửa: 1.129 KiB còn 604 KiB.
  2. LCP mobile giảm 2,9 giây, từ 8,3 s xuống 5,4 s, vẫn chưa đạt mốc 2,5 s.
  3. experimental.inlineCss làm CLS 0,203 ở 6 trên 6 lần đo, nên mình đã tắt nó.
  4. Ảnh LCP không bao giờ lazy-load, và hiệu ứng không được che nó.

Đọc tiếp

Tất cả hướng dẫn →