外观
Core Web Vitals 性能优化实战
性能优化最容易陷入两个误区:一是只看实验室分数,上线后真实用户体验没有改善;二是对着 Lighthouse 报告逐条优化,却不清楚指标之间的因果关系。
Core Web Vitals(CWV)提供了一套以用户为中心的质量度量。2021 年 Google 将其纳入搜索排名信号后,它从"技术指标"变成了需要持续经营的业务指标。
三个核心指标
| 指标 | 衡量内容 | 良好阈值 | 说明 |
|---|---|---|---|
| LCP | 最大内容绘制 | ≤ 2.5s | 首屏主要内容何时可见 |
| CLS | 累积布局偏移 | ≤ 0.1 | 页面元素是否发生意外跳动 |
| INP | 交互到下一次绘制 | ≤ 200ms | 用户操作后界面多久有反馈 |
INP 在 2024 年 3 月正式取代 FID。FID 只衡量"第一次交互的输入延迟",INP 则覆盖整个会话中的交互,包括输入延迟、事件处理和绘制延迟,更接近用户真实感受。
阈值按第 75 百分位计算。也就是说,至少要保证 75% 的访问达到"良好",平均值好看没有意义。
先建立度量
优化之前先埋点。用 web-vitals 采集真实用户数据(RUM):
ts
import { onCLS, onINP, onLCP, onTTFB } from 'web-vitals'
function report(metric: {
name: string
value: number
id: string
rating: string
navigationType: string
}) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
rating: metric.rating,
navigationType: metric.navigationType,
url: location.href
})
navigator.sendBeacon('/api/rum', new Blob([body], { type: 'application/json' }))
}
onLCP(report)
onCLS(report)
onINP(report)
onTTFB(report)注意几点:
- 指标是分页面、分设备、分网络采集的。移动端 4G 与桌面宽带的问题完全不同。
sendBeacon在页面卸载时也能可靠发送,适合性能上报。- 上报要采样。全量上报在高流量场景下成本很高,可以按用户或会话采样。
- 同时记录页面类型、路由、版本号,否则无法定位是哪次发布导致回归。
实验室数据(Lighthouse、Performance 面板)用于复现和定位问题,真实用户数据用于判断优化是否有效。两者不能互相替代。
LCP:首屏主要内容何时可见
LCP 由四个阶段组成:
text
TTFB(首字节)
→ 资源加载延迟
→ 资源加载时长
→ 元素渲染延迟优化要按阶段拆解:
- 降低 TTFB:CDN、边缘缓存、服务端渲染、减少重定向和 DNS 查询。
- 提前发现资源:
preload关键图片和字体,避免被 CSS 或脚本延迟发现。 - 提高资源优先级:LCP 图片使用
fetchpriority="high"。 - 避免懒加载首屏图片:
loading="lazy"用在首屏会直接推迟 LCP。 - 减少渲染阻塞:内联关键 CSS,拆分非关键脚本。
html
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<img
src="/hero.avif"
width="1200"
height="630"
fetchpriority="high"
alt="产品首图"
/>对于文本型 LCP(常见于内容站),重点转成字体和渲染阻塞:
css
@font-face {
font-family: 'Display';
src: url('/fonts/display.woff2') format('woff2');
font-display: swap;
unicode-range: U+0000-00FF;
}font-display: swap 让文字先用后备字体显示,避免长时间不可见,但要注意字体切换可能带来 CLS。使用 size-adjust 或选择度量接近的后备字体可以缓解。
定位 LCP 元素:
js
new PerformanceObserver((list) => {
const entries = list.getEntries()
const last = entries.at(-1)
if (last) {
console.log('LCP element:', last.element)
console.log('LCP time:', last.startTime)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })拿到真实元素后,再判断是资源问题、渲染问题还是 TTFB 问题。
CLS:避免页面跳动
CLS 的计算基于"影响区域 × 距离比例",主要来源:
- 图片、视频没有设置尺寸。
- 异步插入的广告、弹窗、通知。
- 字体切换导致文本尺寸变化。
- 首屏骨架屏与真实内容高度不一致。
- 动画修改了布局属性。
最直接的修复是为所有媒体元素预留空间:
html
<img src="/cover.jpg" width="800" height="450" alt="" />
<iframe src="/embed" width="560" height="315" title="嵌入内容"></iframe>容器高度不确定时,用 aspect-ratio:
css
.cover {
aspect-ratio: 16 / 9;
background: #e5e7eb;
}动画只改 transform 和 opacity。如果必须改变尺寸,考虑用缩放代替高度变化,并在动画期间将其移出文档流。
INP:交互响应速度
INP 衡量从用户输入到下一帧绘制的完整时间,差的交互通常来自:
- 事件处理函数里有大量同步计算。
- 渲染了大列表或复杂组件。
- 第三方脚本占用主线程。
- 输入时发起同步布局查询。
优化思路是减少主线程上的工作:
js
async function onSearchInput(event) {
const keyword = event.target.value
// 立即反馈,不阻塞输入
showLoading()
// 把非紧急工作让出主线程
await new Promise((resolve) => setTimeout(resolve, 0))
const result = await search(keyword)
render(result)
}更现代的做法是使用 scheduler.yield() 把长任务主动切分,或者用 startTransition(React)标记非紧急更新。Vue 中则可以把重计算移到 watch 的异步回调或 Worker 中。
对于长列表,优先做虚拟滚动;对于大量 DOM 更新,使用 DocumentFragment 批量插入;对于非关键脚本,使用 defer、async 或动态导入延后执行。
建立性能预算与回归防护
优化不是一次性项目。把 CWV 纳入 CI,可以防止新代码把指标拖回去:
js
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:4173/'],
numberOfRuns: 3
},
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['warn', { maxNumericValue: 200 }]
}
}
}
}Lighthouse 无法直接测 INP,常用 Total Blocking Time(TBT)作为实验室代理指标。线上则以 RUM 的 INP 为准。
常见坑
- 只优化首页。核心转化路径可能是详情页、结算页,按页面类型分别建预算。
- 用平均值掩盖长尾。CWV 看第 75 百分位,平均值会掩盖低端设备和弱网用户的问题。
- 盲目预加载。预加载过多资源会抢占带宽,反而推迟关键资源。
- 第三方脚本失控。广告、客服、A/B 实验脚本常常是 INP 和 LCP 的主要来源,建立加载与执行预算。
- 忽略发布回归。性能问题通常不是一次引入的,而是每次发布多几十毫秒累积出来的。
小结
CWV 优化的闭环是:采集真实数据 → 定位具体元素和交互 → 针对性优化 → 用 CI 和监控防止回归。脱离度量谈优化,很容易把时间花在用户感知不到的地方。