Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Hiệu năng web app: lazy loading và tối ưu Core Web Vitals thực chiến

Ếch Trendy
Ếch Trendy

3 tháng trước · 9 phút đọc

Tại sao điểm PageSpeed của bạn vẫn thấp dù đã cố đủ cách

Bạn đã nén ảnh, đã dùng CDN, thậm chí đã bật gzip. Điểm PageSpeed vẫn đỏ. Vấn đề thường không nằm ở những thứ bạn đã làm - mà ở cách bạn làm.

Core Web Vitals là bộ 3 chỉ số Google dùng để đo trải nghiệm thực của người dùng. Không phải tốc độ kỹ thuật chung chung, mà là những khoảnh khắc cụ thể: khi nội dung chính hiện ra (LCP), khi layout bị giật (CLS), khi người dùng bấm mà không có phản hồi (INP).

344832 3 chỉ số, 3 vấn đề khác nhau - fix sai chỗ thì không có điểm

Từ tháng 3/2024, Google đã thay thế FID bằng INP (Interaction to Next Paint) trong bộ Core Web Vitals chính thức. Nếu checklist tối ưu của bạn vẫn nhắc đến FID, đã đến lúc cập nhật.

Ngưỡng cần đạt:

  • LCP (Largest Contentful Paint): dưới 2.5 giây
  • CLS (Cumulative Layout Shift): dưới 0.1
  • INP (Interaction to Next Paint): dưới 200ms

Bài này đi thẳng vào từng chỉ số - nguyên nhân thực sự, cách fix, và những lỗi mình hay thấy khi review code người khác.


LCP: khi ảnh hero làm chậm cả trang

LCP đo thời gian từ lúc người dùng bắt đầu tải trang đến khi phần tử lớn nhất hiển thị xong. Thường là ảnh hero, banner đầu trang, hoặc khối văn bản lớn.

Lỗi phổ biến nhất: lazy load ảnh hero.

Nghe có vẻ hợp lý - lazy loading giúp tiết kiệm băng thông. Nhưng với ảnh nằm ngay viewport đầu tiên, lazy loading lại làm trình duyệt trì hoãn việc tải ảnh quan trọng nhất. Kết quả: LCP tăng vọt.

<!-- ❌ Sai - lazy load ảnh hero -->
<img src="hero.jpg" loading="lazy" alt="Hero image">

<!-- ✅ Đúng - preload ảnh hero, lazy load ảnh bên dưới -->
<link rel="preload" as="image" href="hero.jpg">
<img src="hero.jpg" alt="Hero image">
<img src="product-card.jpg" loading="lazy" alt="Sản phẩm">

344833 Preload ảnh trên fold, lazy load ảnh dưới fold - không phải ngược lại

Checklist cải thiện LCP:

  1. Không dùng loading="lazy" cho ảnh trong viewport đầu tiên
  2. Thêm <link rel="preload"> cho ảnh LCP candidate
  3. Dùng fetchpriority="high" trên thẻ <img> của ảnh hero
  4. Serve ảnh qua CDN, format WebP hoặc AVIF
  5. Kiểm tra TTFB (Time to First Byte) - nếu server chậm, mọi tối ưu client-side đều vô nghĩa

Một điểm thường bị bỏ qua: font chữ. Nếu LCP element là text dùng web font, trình duyệt phải tải font trước khi render. Thêm font-display: swap và preload font file sẽ giúp text hiển thị sớm hơn.

@font-face {
  font-family: 'MyFont';
  src: url('font.woff2') format('woff2');
  /* Hiển thị fallback font trước, swap khi font tải xong */
  font-display: swap;
}

CLS: layout nhảy vì bạn quên khai báo kích thước

CLS đo tổng mức độ layout bị dịch chuyển trong quá trình tải trang. Điểm CLS cao thường do một nguyên nhân rất đơn giản: phần tử được chèn vào DOM mà không có kích thước cố định trước.

Tình huống hay gặp: bạn đang đọc bài viết, đột nhiên nội dung nhảy xuống vì banner quảng cáo vừa load xong phía trên. Hoặc ảnh sản phẩm hiện ra và đẩy toàn bộ text xuống. Người dùng bực bội - Google cũng trừ điểm.

3 nguyên nhân CLS cao phổ biến nhất:

1. Ảnh không có width/height

<!-- ❌ Trình duyệt không biết trước kích thước -->
<img src="product.jpg" alt="Sản phẩm">

<!-- ✅ Khai báo tỉ lệ, trình duyệt giữ chỗ -->
<img src="product.jpg" width="800" height="600" alt="Sản phẩm">

2. Quảng cáo hoặc embed không có vùng giữ chỗ (placeholder)

/* Giữ chỗ trước khi ad load */
.ad-container {
  min-height: 250px;
  background: #f0f0f0;
}

3. Font gây FOUT (Flash of Unstyled Text) làm layout shift

Khi fallback font và web font có kích thước khác nhau, text reflow làm nội dung xung quanh dịch chuyển. font-display: swap giúp hiển thị nhanh hơn nhưng vẫn gây shift. Giải pháp tốt hơn: dùng font-display: optional cho font không quan trọng, hoặc chọn fallback font có kích thước tương tự.

344834 Khai báo width/height trên ảnh - 5 giây code, tránh CLS đáng kể

Cách kiểm tra nhanh: mở Chrome DevTools > Performance tab > record lại trang đang load. Phần "Layout Shift" sẽ highlight chính xác phần tử nào gây ra CLS và thời điểm nào.

Ngoài ra, CSS aspect-ratio là cách hiện đại để giữ tỉ lệ container trước khi nội dung load:

.image-wrapper {
  /* Giữ tỉ lệ 16:9 trước khi ảnh load */
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

INP: tại sao app cảm giác lag dù tốc độ tải tốt

INP là chỉ số mới nhất và cũng khó fix nhất. Nó đo độ trễ giữa lúc người dùng tương tác (click, gõ phím, tap) và lúc trình duyệt render được frame tiếp theo.

App của bạn tải nhanh nhưng bấm vào dropdown thấy lag 300-400ms? Đó chính là INP xấu.

Nguyên nhân gốc rễ: JavaScript chạy quá nhiều việc trên main thread, block việc render.

// ❌ Block main thread - tính toán nặng chạy đồng bộ khi user click
btn.addEventListener('click', () => {
  const result = heavyCalculation(largeDataset); // 500ms blocking
  updateUI(result);
});

// ✅ Dùng scheduler để nhường thread cho render trước
btn.addEventListener('click', async () => {
  // Cập nhật UI ngay (feedback tức thì cho user)
  showLoadingState();
  
  // Nhường main thread, rồi mới chạy task nặng
  await scheduler.yield();
  const result = heavyCalculation(largeDataset);
  updateUI(result);
});

344835 scheduler.yield() - một dòng code, INP giảm đáng kể

Checklist cải thiện INP:

  • Break up long tasks: bất kỳ task nào >50ms trên main thread đều là "long task"
  • Dùng requestIdleCallback cho việc không khẩn cấp (analytics, prefetch)
  • Tránh layout thrashing: không đọc và ghi DOM xen kẽ trong cùng một vòng lặp
  • Debounce input handlers, scroll listeners
  • Kiểm tra third-party scripts - chúng thường là thủ phạm lớn nhất

Một điểm thực tế: phần lớn các vấn đề INP trên production đến từ third-party scripts như chat widget, analytics, hoặc A/B testing tools chạy trên main thread. Trước khi viết code tối ưu, hãy kiểm tra Network tab và xem scripts nào đang block.

// Defer third-party scripts sau khi page load xong
window.addEventListener('load', () => {
  // Load chat widget sau khi trang đã interactive
  loadChatWidget();
});

Lazy loading đúng cách: không phải cứ lazy là tốt

Lazy loading thường bị hiểu lầm là "cứ thêm loading=lazy vào mọi ảnh là xong". Thực tế phức tạp hơn một chút.

Quy tắc đơn giản: ảnh trong viewport ban đầu (above the fold) - KHÔNG lazy load. Ảnh phía dưới - lazy load.

Vấn đề là "above the fold" thay đổi theo từng thiết bị. Ảnh nằm dưới fold trên desktop có thể nằm trên fold trên mobile. Cách an toàn: lazy load từ ảnh thứ 3-4 trở đi.

Lazy loading cho JavaScript modules:

// ❌ Import tất cả upfront - bundle lớn
import { HeavyChart } from './charts';
import { PDFViewer } from './pdf-viewer';

// ✅ Dynamic import - chỉ load khi cần
const loadChart = async () => {
  const { HeavyChart } = await import('./charts');
  return HeavyChart;
};

// Trigger khi user click vào tab Charts
tabButton.addEventListener('click', async () => {
  const HeavyChart = await loadChart();
  renderChart(HeavyChart);
});

344836 Dynamic import giảm initial bundle, load khi cần - đây là lazy loading đúng nghĩa

Với React, React.lazy()Suspense làm việc này clean hơn:

import { lazy, Suspense } from 'react';

// Chỉ load component khi được render lần đầu
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));

function App() {
  return (
    <Suspense fallback={<div>Đang tải...</div>}>
      <HeavyDashboard />
    </Suspense>
  );
}

Khóa Xây Dựng Website với ReactJS có phần cover chi tiết về code splitting và React.lazy nếu bạn muốn đi sâu hơn vào phần này.

Intersection Observer cho lazy loading tùy chỉnh:

Khi loading="lazy" không đủ linh hoạt (ví dụ: lazy load video, iframe, hoặc component phức tạp), Intersection Observer là công cụ đúng:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // Phần tử vào viewport - load nội dung
      loadContent(entry.target);
      observer.unobserve(entry.target); // Chỉ load 1 lần
    }
  });
}, {
  rootMargin: '200px' // Bắt đầu load trước 200px
});

document.querySelectorAll('[data-lazy]').forEach(el => {
  observer.observe(el);
});

Checklist thực chiến và công cụ đo lường

Biết lý thuyết chưa đủ. Điểm Core Web Vitals thực tế phải đo trên real user data, không chỉ trên Lighthouse local.

Công cụ đo lường cần biết:

  • PageSpeed Insights (PageSpeed Insights): đo cả lab data và field data (CrUX), miễn phí, nhanh nhất để bắt đầu
  • Chrome UX Report (CrUX): data thực từ người dùng Chrome, phản ánh trải nghiệm thực tế
  • WebPageTest (WebPageTest): test chi tiết hơn, có waterfall view, hỗ trợ nhiều location và thiết bị
  • Vercel Speed Insights / Sentry: monitor Core Web Vitals trên production liên tục

Checklist nhanh trước khi deploy:

✅ LCP
□ Ảnh hero không có loading="lazy"
□ Thêm fetchpriority="high" cho ảnh LCP
□ Preload web font quan trọng
□ TTFB < 800ms (kiểm tra server response)

✅ CLS
□ Mọi ảnh đều có width và height
□ Ad container có min-height
□ Font dùng font-display: swap hoặc optional
□ Không insert DOM phía trên nội dung existing

✅ INP
□ Không có long task > 50ms khi user tương tác
□ Third-party scripts được defer
□ Input handlers được debounce
□ Không layout thrashing trong event listeners

✅ Lazy Loading
□ Chỉ lazy load ảnh below the fold
□ Dynamic import cho heavy components
□ Intersection Observer có rootMargin hợp lý

344837 Đo trên real user data (CrUX), không chỉ Lighthouse lab - hai con số này thường khác nhau khá nhiều

Tải checklist đầy đủ định dạng markdown để dùng offline: {{resource:2}}

Một điều mình hay nhắc: đừng tối ưu mù. Mở PageSpeed Insights, xem chỉ số nào thấp nhất, fix cái đó trước. Không phải lúc nào cũng cần fix cả 3 chỉ số cùng lúc. LCP 3.5s và CLS 0.25 thì ưu tiên CLS vì dễ fix hơn và impact lớn hơn với người dùng.

Core Web Vitals không phải việc làm một lần rồi xong. Mỗi lần thêm feature mới, thêm third-party script, hoặc thay đổi layout, hãy chạy lại PageSpeed Insights. Tốt nhất là tích hợp vào CI/CD pipeline để catch regression sớm.