返回博客

动画网站的 Core Web Vitals 优化:保留动效,通过 LCP、INP 和 CLS

动画网站的 Core Web Vitals 优化:保留动效,通过 LCP、INP 和 CLS

2026年10月6日 — 性能

发布于

2026年10月6日

阅读时长

5 分钟

分类

性能

标签

  • Core Web Vitals
  • Performance
  • Animation
  • WebGL
  • LCP
  • INP
  • CLS

(摘要)如何在保留滚动动画、WebGL 和视频的同时通过 Core Web Vitals:LCP、INP、CLS 变慢的具体原因和解决方法。

动画网站常被认为很慢,而且很多时候确实如此。但动效和良好的 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 候选元素通常是它的海报图。让这张图尽量小、提前预加载、高优先级:

html
<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 包里。在区块接近视口时动态导入:

ts
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 做动画,每一帧都会强制重新布局:

css
/* Slow: layout every frame */
.card { transition: top 0.3s; }

/* Fast: compositor only */
.card { transition: transform 0.3s; }

不要在滚动回调里读取布局

样式变化之后调用 getBoundingClientRect() 会强制同步布局。在滚动回调中,每帧只在 requestAnimationFrame 里读一次,然后再写:

ts
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 循环都在主线程上运行。画布、视频和循环离开视口时,以及标签页被隐藏时,都要暂停:

ts
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()))
io.observe(canvas)
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()))

限制画布分辨率

手机上 devicePixelRatio 为 3 的 WebGL 画布,渲染的像素是 DPR 1 的九倍。限制它:

ts
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 滑入,就不会。

尊重"减少动态效果"设置

有些访客在操作系统里要求减少动态效果。请尊重这个设置——这是无障碍要求,而且这些访客还能得到更轻的页面:

css
@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)')。

能发现性能回退的工作流程

  1. 在 Chrome DevTools 中开启 4 倍 CPU 降速,滚动整个页面并录制 Performance 记录。找长任务和紫色的布局条。
  2. 打开 Rendering → Paint flashing 和 Layout Shift Regions,看哪些区域在重绘和移动。
  3. 上线后每周查看 PageSpeed Insights 的实地数据;实验室分数只是参考,实地数据才是成绩。

检查清单

  • 首屏内容在初始 HTML 中可见,而不是从透明淡入
  • LCP 图片使用 fetchpriority="high" 预加载
  • 重型库按需加载
  • 只对 transform 和 opacity 做动画
  • 布局读取在 requestAnimationFrame 中批量进行
  • 屏幕外的循环、画布和视频已暂停
  • 限制了画布像素比
  • 所有媒体都有尺寸;字体有度量匹配的后备字体
  • 尊重 prefers-reduced-motion

动画实现方面,请看 在 React 和 Next.js 中使用 GSAP。

订阅

通过邮件接收新文章

有新文章时发一封简短的邮件。不发垃圾邮件,随时可以退订。

在 React 和 Next.js 中使用 GSAP:useGSAP、清理与 ScrollTrigger
在 React 和 Next.js 中使用 GSAP:useGSAP、清理与 ScrollTrigger
下一篇 →