← Tất cả bài viết

[ Blog ]

27 tháng 6, 2026

10 phút đọc

Core Web Vitals Cho Web Nhiều Chuyển Động

Tối ưu Core Web Vitals cho web nhiều chuyển động mà không phải bỏ animation. Cách giữ LCP, INP, CLS xanh trên site GSAP, Lenis, WebGL — kèm code thật.

PerformanceCore Web VitalsGSAPWebGLNext.jsAnimation
Core Web Vitals Cho Web Nhiều Chuyển Động

Lời khuyên quen thuộc khi muốn cải thiện Core Web Vitals là "bỏ animation đi." Đó không phải lời khuyên — đó là đầu hàng. Tôi làm những website đạt giải Awwwards với section pin theo scroll, hiệu ứng chuyển cảnh WebGL displacement và timeline GSAP nặng, mà chúng vẫn pass Core Web Vitals. Chuyển động và hiệu năng không đối nghịch nhau; chúng chỉ "đánh nhau" khi animation được viết cẩu thả. Bài này là phiên bản thực chiến về cách tôi giữ Core Web Vitals cho web nhiều chuyển động luôn xanh: mỗi chỉ số thực sự đo gì, vài quy tắc cốt lõi, và đoạn code tôi dùng trên mọi dự án.

Nếu chỉ nhớ một điều: chỉ animate transformopacity, đừng đụng vào thứ gì gây layout, và giữ JavaScript nặng ra khỏi critical path. Tất cả phần dưới là chi tiết của hai ý đó.

Core Web Vitals đo gì (và chuyển động đe doạ ra sao)

Ba chỉ số, ba kiểu lỗi khác nhau. Animation tác động lên mỗi chỉ số theo cách riêng, nên hãy xử lý từng cái.

Chỉ sốĐo gìNgưỡng "tốt"Chuyển động đe doạ điều gì
LCP (Largest Contentful Paint)Thời gian phần tử lớn nhất hiện ra≤ 2.5sJS nặng (GSAP, Three.js) chặn main thread trước khi hero paint
INP (Interaction to Next Paint)Độ phản hồi suốt phiên truy cập≤ 200msHandler scroll/pointer và vòng lặp rAF chiếm main thread
CLS (Cumulative Layout Shift)Dịch chuyển layout ngoài ý muốn≤ 0.1will-change, pin, animation vào trang làm reflow

INP đã thay thế FID để trở thành Core Web Vital chính thức từ 2024, và nó là chỉ số mà phần lớn web nhiều chuyển động trượt. FID chỉ đo độ trễ của tương tác đầu tiên; INP nhìn vào độ trễ của (gần như) mọi tương tác trong cả phiên, gồm cả thời gian paint kết quả. Một site bị "scroll-jack" với vòng requestAnimationFrame bận rộn sẽ giật ở mọi cú chạm — đó chính là thứ INP bắt được.

Quy tắc 1: chỉ animate transform và opacity

Trình duyệt render theo pipeline: style → layout → paint → composite. Animate một thuộc tính làm đổi hình học (width, height, top, left, margin) ép trình duyệt chạy layout — bước tốn kém nhất — mỗi frame, rồi lan xuống paint và composite. Animate transformopacity thì nhảy thẳng tới compositor, chạy được trên GPU và thường nằm ngoài main thread.

// Giật: animate `left` làm reflow cả trang mỗi frame
gsap.to(".card", { left: 400, duration: 1 }); // ❌ kích hoạt layout

// Mượt: translate được composite, không layout
gsap.to(".card", { x: 400, duration: 1 }); // ✅ transform: translateX

x/y trong GSAP biên dịch ra transform, nên hãy ưu tiên chúng thay vì CSS định vị. Cần ảo giác đổi kích thước thì animate scale thay cho width/height. Cần reveal thì animate opacityclipPath (được composite) thay vì bật/tắt display hay animate height.

Kỹ thuật "FLIP" là lối thoát khi bạn buộc phải di chuyển phần tử giữa hai layout: đo vị trí đầu và cuối, rồi animate độ lệch transform — không bao giờ animate thuộc tính layout. GSAP có sẵn plugin Flip cho đúng việc này.

Quy tắc 2: kỷ luật với will-change (nó không miễn phí)

will-change nâng phần tử lên một compositor layer riêng trước, giúp bỏ cú giật ở frame đầu. Nhưng mỗi layer tốn bộ nhớ GPU, và một trang rải đầy will-change: transform có thể tăng CLS và áp lực bộ nhớ, đôi khi làm crash Safari trên mobile. Hãy coi nó như chất cần kiểm soát.

// Nâng layer ngay trước khi animate, hạ ngay sau đó.
const el = document.querySelector(".hero");
el.style.willChange = "transform";

gsap.to(el, {
  x: 200,
  duration: 0.8,
  onComplete: () => {
    el.style.willChange = "auto"; // giải phóng layer
  },
});

Đừng để will-change vĩnh viễn trên hàng chục phần tử trong CSS. Thêm đúng lúc, gỡ khi xong. Với tween ngắn dùng GSAP, nó đã tự gợi ý transform, bạn hiếm khi cần tự đặt will-change.

Quy tắc 3: đừng thrash layout trong vòng lặp scroll

Layout thrash là đọc một thuộc tính hình học (offsetTop, getBoundingClientRect, scrollHeight) rồi ghi style ngay trong cùng frame — ép trình duyệt tính lại layout đồng bộ, lặp đi lặp lại. Trong một scroll handler chạy 60+ lần mỗi giây, đây là kẻ giết INP.

// ❌ Đọc → ghi → đọc → ghi ép layout đồng bộ mỗi vòng
items.forEach((el) => {
  const top = el.getBoundingClientRect().top; // đọc
  el.style.transform = `translateY(${top * 0.2}px)`; // ghi
});

// ✅ Gom toàn bộ đọc, rồi toàn bộ ghi
const tops = items.map((el) => el.getBoundingClientRect().top); // pha đọc
items.forEach((el, i) => {
  el.style.transform = `translateY(${tops[i] * 0.2}px)`; // pha ghi
});

Tốt hơn nữa: để công cụ đã tự batch lo việc đó. ScrollTrigger cache các phép đo và cập nhật trên một rAF tick chung — xem hướng dẫn GSAP ScrollTrigger: pin, scrub và parallax cho các pattern pin và scrub né được việc đo thủ công. Và hãy điều khiển smooth scroll qua một vòng lặp duy nhất, như trong bài cuộn mượt trong Next.js với GSAP và Lenis — đừng chạy rAF của Lenis một ticker ScrollTrigger riêng.

Quy tắc 4: bảo vệ LCP — giữ JS nặng ngoài critical path

Hero gần như luôn là phần tử LCP. Nếu một bundle Three.js 400KB parse trước khi chữ hero paint, LCP của bạn sập bất kể server nhanh cỡ nào. Cách xử lý là sắp xếp thứ tự.

Render phần tử LCP bằng HTML/CSS, gắn chuyển động sau. Để tiêu đề và ảnh hero paint từ markup render sẵn ở server với CSS, rồi mới gắn GSAP/WebGL khi trang đã tương tác được.

Lazy-load WebGL và Three.js. Một canvas dưới màn hình, hay cả canvas hero, có thể import động để bundle của nó không chặn first paint:

// Next.js: đẩy scene WebGL ra khỏi bundle ban đầu
import dynamic from "next/dynamic";

const Scene = dynamic(() => import("@/components/Scene"), {
  ssr: false,
  loading: () => <div className="hero-fallback" />, // paint tức thì, không CLS
});

Chỉ khởi tạo canvas khi nhìn thấy. Đừng tạo renderer Three.js cho tới khi nó cuộn vào khung nhìn, và dừng vòng render khi nó ra khỏi màn hình:

const io = new IntersectionObserver(([entry]) => {
  entry.isIntersecting ? renderer.setAnimationLoop(tick) : renderer.setAnimationLoop(null);
});
io.observe(canvas);

Riêng pattern này — ngừng render những frame bạn không nhìn thấy — là cú cải thiện INP và pin lớn nhất trên trang nặng WebGL. Ở một dự án gần đây, việc dừng ba canvas ngoài màn hình giảm khoảng hai phần ba khối lượng main-thread lúc rảnh [VERIFY con số chính xác].

Defer JS không thiết yếu. Mọi thứ không cần cho first paint — analytics, hiệu ứng con trỏ, timeline phụ — nên tải bằng next/script strategy="lazyOnload" hoặc sau một requestIdleCallback.

Quy tắc 5: dẹp CLS từ animation vào trang và font

Phần lớn CLS liên quan animation đến từ ba nguồn:

  • Animate layout khi vào trang. Một section animate height: 0 → auto đẩy mọi thứ bên dưới. Hãy animate transform/opacity, và chừa chỗ bằng min-height.
  • Media tải muộn không có kích thước. Luôn đặt width/height (hoặc aspect-ratio) cho ảnh và canvas để trình duyệt chừa chỗ trước khi asset về.
  • Web font. Dùng font-display: optional hoặc swap kèm fallback khớp metric để tiêu đề không reflow khi font thương hiệu tải xong — một cú reflow biến thành nhảy hình rõ rệt trên hero có animation.

Chừa chỗ trước, đừng để chuyển động tự tạo chỗ.

Quy tắc 6: tôn trọng prefers-reduced-motion

Đây vừa là yêu cầu khả năng truy cập vừa là van xả hiệu năng: người tắt chuyển động nhận trang nhẹ và êm hơn, và INP thực địa của bạn cải thiện cho nhóm đó. Cấu hình một lần, toàn cục.

import gsap from "gsap";

gsap.matchMedia().add(
  { reduce: "(prefers-reduced-motion: reduce)", ok: "(prefers-reduced-motion: no-preference)" },
  (ctx) => {
    const { reduce } = ctx.conditions as { reduce: boolean };
    if (reduce) {
      gsap.set(".reveal", { opacity: 1, y: 0 }); // hiện trạng thái cuối, không animate
      return;
    }
    gsap.from(".reveal", { opacity: 0, y: 40, stagger: 0.1 });
  }
);

Với WebGL, nhánh reduced-motion nên bỏ hẳn auto-rotate và shader chạy liên tục, hoặc render một ảnh poster tĩnh. Bản CSS tương đương đặt trong reset: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }.

Đo lường: lab vs field, và tin cái nào

Có hai loại dữ liệu, và người mới hay nhầm lẫn.

  • Lab (Lighthouse / DevTools): một lần chạy giả lập. Tốt để gỡ vì sao chậm — mở DevTools → Performance, ghi lại một tương tác, tìm long task (>50ms) trong scroll handler. Lighthouse báo proxy Total Blocking Time, không phải INP thật.
  • Field (CrUX / người dùng thật): phân vị 75 của khách thật trong 28 ngày. Đây là cái Google dùng để xếp hạng. Tự thu nó bằng thư viện web-vitals để thấy INP trên đúng thiết bị của khán giả mình:
import { onLCP, onINP, onCLS } from "web-vitals";

onLCP(console.log);
onINP(console.log); // theo dõi cái này trên Android tầm trung
onCLS(console.log);

Luôn test trên Android tầm trung bị throttle trong DevTools (làm chậm CPU 4x), đừng test trên laptop chip M. Animation mượt trên GPU desktop có thể vỡ ngân sách INP 200ms trên điện thoại thật. Các quy tắc chuyển động này là một trụ cột trong bức tranh hiệu năng Next.js rộng hơn — chia bundle, ảnh, chiến lược render.

FAQ

GSAP có hại Core Web Vitals không?

Không tự thân. Mặc định GSAP animate qua transform/opacity và chạy trên một rAF tick tối ưu — đúng thứ bạn muốn. Nó chỉ hại CWV khi bạn animate thuộc tính layout, chạy nhiều vòng ticker, hoặc tải cả bundle (kèm plugin) trên critical path. Chỉ import plugin bạn dùng và khởi tạo sau first paint.

WebGL có xấu cho Core Web Vitals không?

Không, nhưng nó nặng nên phải defer và gate. Lazy-load bundle Three.js, tránh để canvas là phần tử LCP nếu được, dừng vòng render khi ngoài màn hình, và cung cấp fallback reduced-motion/tĩnh. Làm đúng cách, một hero WebGL vẫn pass cả ba chỉ số.

Web nhiều chuyển động trượt chỉ số nào nhiều nhất?

INP, cách biệt lớn. Scroll handler nặng, layout thrash và vòng rAF liên tục giữ main thread bận, nên cú chạm và click phải chờ paint kế tiếp. Hãy batch đọc/ghi DOM, gom việc vào một vòng lặp, và dừng animation ngoài màn hình.

Có được animate height hay width khi thật sự cần không?

Nên tránh. Dùng transform: scale() cho đổi kích thước, hoặc kỹ thuật FLIP (animate độ lệch transform giữa hai trạng thái đã đo) khi buộc phải di chuyển giữa layout. Plugin Flip của GSAP tự động hoá việc này mà không tốn layout mỗi frame.


Chuyển động nhanh là một kỷ luật, không phải sự đánh đổi. Nếu bạn muốn một website vừa đoạt giải vừa pass Core Web Vitals trên điện thoại tầm trung, đó đúng là việc tôi làm — xem case study website sự kiện Iventions đoạt giải để thấy ví dụ thật, hoặc đọc chi phí làm website chuyển động trước khi lên kế hoạch. Sẵn sàng bắt tay? Đây là cách tôi giúp bạn.

Tham khảo: web.dev — Core Web Vitals.