動畫網站常被認為很慢,而且很多時候確實如此。但動效和良好的 Core Web Vitals 並不矛盾。大多數問題都來自一小串原因,而每個原因都有能保留動畫的解決辦法。
本站有影片首屏、WebGL 專案牆、WebGL 文字和貫穿全站的捲動動畫。下面是它遵循的規則。
三個指標,簡單說
| 指標 | 衡量什麼 | 良好標準 |
|---|---|---|
| LCP — 最大內容繪製 | 主要內容何時出現 | ≤ 2.5 秒 |
| INP — 互動到下一次繪製 | 頁面對點擊和觸控的響應速度 | ≤ 200 毫秒 |
| CLS — 累積佈局偏移 | 佈局跳動的程度 | ≤ 0.1 |
Google 以真實 Chrome 使用者的第 75 百分位來評判(即 PageSpeed Insights 和 Search Console 中的實地資料),而不是你在筆記本上跑的一次 Lighthouse。在一臺中端安卓手機上、用限速網路測試,這更接近你的第 75 百分位使用者,而不是 MacBook。
LCP:儘早顯示真實內容
不要把首屏藏在入場動畫後面
動畫網站最常見的 LCP 殺手,是首屏從 opacity: 0 開始、等 JavaScript 載入後再淡入。元素可見之前,瀏覽器不會把它算作已繪製,於是 LCP 要先等你的 JS 包,再等動畫。
解決方法:用 HTML 和 CSS 直接渲染首屏的最終狀態。讓次要元素做入場動畫,或者從一個已經可見的狀態開始動畫(輕微的縮放或位移,而不是從透明度 0 開始)。
給 LCP 元素一張海報圖和高優先順序
如果首屏是影片或 WebGL 畫布,LCP 候選元素通常是它的海報圖。讓這張圖儘量小、提前預載入、高優先順序:
<link rel="preload" as="image" href="/hero/poster.avif" fetchpriority="high" />
<img src="/hero/poster.avif" alt="" fetchpriority="high" width="1600" height="900" />在海報圖繪製之後再啟動影片或 WebGL 場景,並淡入切換。
需要時再載入重型庫
如果 Three.js、GSAP 外掛和 Lottie 驅動的是首屏以下的區塊,它們就不必出現在首個 JS 包裡。在區塊接近視口時動態匯入:
const observer = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting) return
observer.disconnect()
const { initScene } = await import('./scene')
initScene(canvas)
}, { rootMargin: '400px 0px' })
observer.observe(section)在 Next.js 中,next/dynamic 配合 ssr: false 可以對整個元件做同樣的事。
提供合適尺寸的媒體
在手機上播放 1440p 的循環影片是浪費頻寬,還會和 LCP 圖片搶資源。按視口寬度和網路情況選擇影片源,首屏循環影片保持簡短(6–10 秒,不帶音軌)。
INP:保持主執行緒空閒
INP 衡量從互動到下一幀之間的延遲。如果 JavaScript 每一幀都很忙,動畫就會拖累 INP。
只對 transform 和 opacity 做動畫
它們由合成器處理,不會觸發重排。對 top、left、width、height 或 margin 做動畫,每一幀都會強制重新佈局:
/* Slow: layout every frame */
.card { transition: top 0.3s; }
/* Fast: compositor only */
.card { transition: transform 0.3s; }不要在捲動回呼裡讀取佈局
樣式變化之後呼叫 getBoundingClientRect() 會強制同步佈局。在捲動回呼中,每幀只在 requestAnimationFrame 裡讀一次,然後再寫:
let ticking = false
window.addEventListener('scroll', () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
const top = section.getBoundingClientRect().top // read
progressBar.style.transform = `scaleX(${clamp(-top / range)})` // write
ticking = false
})
}, { passive: true })更好的做法是讓 GSAP 的 ScrollTrigger 之類的庫幫你批次處理,或者在支援的瀏覽器裡使用 CSS 捲動驅動動畫(animation-timeline: scroll())。
停止看不見的工作
頁面上的每個 requestAnimationFrame 循環都在主執行緒上執行。畫布、影片和循環離開視口時,以及標籤頁被隱藏時,都要暫停:
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()))
io.observe(canvas)
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()))限制畫布解析度
手機上 devicePixelRatio 為 3 的 WebGL 畫布,渲染的像素是 DPR 1 的九倍。限制它:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))對於霧、噪點這類全螢幕效果,用一半解析度渲染再放大,通常肉眼看不出區別。
拆分長任務
建置場景、解析大型 JSON 或準備大量文字網格,可能會阻塞主執行緒幾百毫秒。把工作拆成小塊,塊與塊之間讓出主執行緒(支援的瀏覽器用 await scheduler.yield(),否則用 setTimeout(0)),這樣使用者在初始化期間的點擊也能被快速響應。
CLS:為所有東西預留空間
給媒體設定尺寸
每張圖片、每個影片、iframe 和 canvas 都需要 width/height 屬性或 CSS aspect-ratio,讓瀏覽器在載入前就預留空間。
載入字型時不要跳動
展示字型是動畫網站個性的一部分,而字型晚到導致的替換會讓每一行都發生偏移。預載入一兩個關鍵字型檔案、做子集化,並使用 font-display: swap 加一個調整過度量參數的後備字型(size-adjust、ascent-override)——next/font 會自動生成這些。
動畫不算,佈局變化才算
CLS 忽略 transform 動畫,但不會忽略透過佈局屬性改變的尺寸和位置。載入後把內容往下推的橫幅會被計入;同樣的橫幅如果在預留好的空間裡用 transform 滑入,就不會。
尊重「減少動態效果」設定
有些訪客在作業系統裡要求減少動態效果。請尊重這個設定——這是無障礙要求,而且這些訪客還能得到更輕的頁面:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}在 JavaScript 中,啟動循環、視差和自動播放影片之前,先檢查 matchMedia('(prefers-reduced-motion: reduce)')。
能發現效能回退的工作流程
- 在 Chrome DevTools 中開啟 4 倍 CPU 降速,捲動整個頁面並錄製 Performance 記錄。找長任務和紫色的佈局條。
- 開啟 Rendering → Paint flashing 和 Layout Shift Regions,看哪些區域在重繪和移動。
- 上線後每週查看 PageSpeed Insights 的實地資料;實驗室分數只是參考,實地資料才是成績。
檢查清單
- 首屏內容在初始 HTML 中可見,而不是從透明淡入
- LCP 圖片使用
fetchpriority="high"預載入 - 重型庫按需載入
- 只對
transform和opacity做動畫 - 佈局讀取在
requestAnimationFrame中批次進行 - 螢幕外的循環、畫布和影片已暫停
- 限制了畫布像素比
- 所有媒體都有尺寸;字型有度量匹配的後備字型
- 尊重
prefers-reduced-motion
動畫實現方面,請看 在 React 和 Next.js 中使用 GSAP。

