2024 年 3 月,Google 将 INP(Interaction to Next Paint)正式替换 FID 成为 Core Web Vitals 的第三项指标。自此,CWV 三件套确定为 LCP、CLS、INP。这三项分别对应加载体验、视觉稳定性、交互响应性,是搜索排序的官方参考,也是我们团队内部对每个页面强制卡的红线指标。
这篇文章记录我们把一个日均 PV 80 万的电商详情页 LCP 从 3.4s 压到 1.4s 的全过程,包含指标定义、判定方法、优化手段与优化前后的真实数据对比。
一、三项指标的定义与评分阈值
Core Web Vitals 的评分基于真实用户数据(CrUX),而非实验室数据。每项指标都有“良好 / 需改进 / 差”三档阈值,且都按 75 分位数(p75)来评判。
| 指标 | 含义 | 良好 | 需改进 | 差 |
|---|---|---|---|---|
| LCP | 最大内容绘制,衡量加载体验 | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | 累计布局偏移,衡量视觉稳定性 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | 交互到下一次绘制,衡量响应性 | ≤ 200ms | 200ms – 500ms | > 500ms |
需要强调:我们的内部目标比 Google 的“良好”更严。详情页的 LCP 红线是 1.5s,因为详情页的主图直接决定转化率,每多 100ms 都在掉单。
二、LCP 元素的判定方法
优化 LCP 的第一步是搞清楚“到底谁是 LCP 元素”。LCP 元素是页面加载过程中,可视区域内面积最大的内容元素,通常是 hero 图片、大标题、或大块文本块。
2.1 用 Performance 面板定位
Chrome DevTools 的 Performance 面板录制加载过程,在 Timings 区会直接画出 LCP 标记。点击 LCP 标记,可以在 Bottom-Up 里看到该元素和它的请求链路(Initiator 列会指向触发该资源的 JS/HTML)。
2.2 用 web-vitals 库上报
实验室环境看不到真实分布,我们在线上用 web-vitals 库采集 p75:
import { onLCP, onCLS, onINP } from 'web-vitals';
function send(metric) {
navigator.sendBeacon('/__metrics', JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
id: metric.id,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
entries: metric.entries.map(e => ({
startTime: Math.round(e.startTime),
renderTime: Math.round(e.renderTime),
element: e.element?.tagName,
url: e.element?.currentSrc || e.element?.src,
size: e.size
}))
}));
}
onLCP(send);
onCLS(send);
onINP(send);
注意 LCP 元素的 entries 会带上 element 信息,能直接定位到是哪张图或哪段文字成为 LCP。我们的上报显示详情页 92% 的会话里 LCP 元素都是商品主图 <img class="hero-img">。
三、LCP 优化:从 3.4s 到 1.4s
定位到 LCP 元素是主图后,LCP 时间本质上是下面这条公式:
LCP 时间 = TTFB + 资源加载延迟 + 资源加载时长 + 渲染延迟
逐项压低即可。
3.1 关键资源 preload
主图原本是 JS 渲染时才请求的,要等到首屏 JS 执行完、商品数据请求回来才发出图片请求。改为在 HTML <head> 里提前 preload:
<link rel="preload" as="image"
href="https://cdn.example.com/p/main-800.webp"
fetchpriority="high"
imagesrcset="https://cdn.example.com/p/main-400.webp 400w,
https://cdn.example.com/p/main-800.webp 800w,
https://cdn.example.com/p/main-1200.webp 1200w"
imagesizes="(max-width: 600px) 100vw, 50vw">
fetchpriority="high" 告诉浏览器这条请求插队优先处理,配合 preload 把图片请求提前到与首屏 JS 并行。仅这一项,详情页 LCP 降了约 800ms。
3.2 字体加载与 font-display
详情页标题用了自定义字体,字体未就绪时文字被压住不渲染,最多拖 1.5s。处理方式:
@font-face {
font-family: 'TbnSans';
src: url('/fonts/TbnSans-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* 用系统字体先绘,字体就绪后无缝替换 */
unicode-range: U+0000-00FF, U+4E00-9FFF;
}
同时只 preload 主图,字体走常规加载 + font-display: swap,避免与主图抢带宽。对于需要精确控制字体就绪时机的页面,也可以监听 document.fonts.ready 再触发首屏动画。
3.3 图片格式与 responsive images
主图统一切到 WebP/AVIF,配合 srcset 按视口下发不同尺寸:
<picture>
<source type="image/avif"
srcset="/p/main-400.avif 400w, /p/main-800.avif 800w, /p/main-1200.avif 1200w"
sizes="(max-width: 600px) 100vw, 50vw">
<source type="image/webp"
srcset="/p/main-400.webp 400w, /p/main-800.webp 800w, /p/main-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 50vw">
<img class="hero-img"
src="/p/main-800.jpg"
width="800" height="800"
decoding="async" fetchpriority="high"
alt="商品主图">
</picture>
关键点:width/height 必须写,否则浏览器无法提前算出预留空间;decoding="async" 让解码不阻塞主线程。同图从 JPEG 1.2MB 降到 AVIF 180KB,传输时间下降约 60%。
3.4 SSR 与骨架屏
原方案是 CSR:空 HTML → 拉 JS → 拉 API → 渲染。改 SSR 后首屏 HTML 直出主图标签,浏览器解析到 <img> 立即发请求,不必等 JS。对 SSR 还没覆盖的路径,退一步用骨架屏占住区域,至少不让 LCP 元素从无到有剧烈跳动。
四、CLS 优化:给动态内容预留空间
详情页 CLS 主要来自三处:主图懒加载导致的高度塌陷、商品描述区图片加载后撑开、底部广告位注入。统一用 aspect-ratio 预留空间:
.hero-img-wrap {
width: 100%;
aspect-ratio: 1 / 1; /* 主图固定 1:1 */
background: #f5f5f7;
}
.ad-slot {
min-height: 90px; /* 广告位最小高度 */
aspect-ratio: 728 / 90;
}
.rich-text img {
aspect-ratio: var(--img-ratio, 16 / 9);
width: 100%;
height: auto;
}
对于异步注入的 DOM,先放一个等高预留元素,数据回来后替换内容而非新增节点。广告 SDK 我们包了一层,要求必须带预留容器。优化后 CLS 从 0.31 降到 0.04。
五、INP 优化:拆长任务,让出主线程
详情页 INP 痛点是“加入购物车”按钮点击后,要等购物车数据 diff + 重新渲染整套 SKU 面板,p75 一度到 480ms。本质是主线程被长任务(>50ms)占住,输入事件无法在下一帧响应。
5.1 长任务拆分:yield 让出主线程
把重计算切成块,用 scheduler.yield()(Chrome 129+ 稳定)让出主线程,低版本降级到 setTimeout:
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(r => setTimeout(r, 0));
}
async function rebuildSkuPanel(skus) {
const chunkSize = 50;
for (let i = 0; i < skus.length; i += chunkSize) {
renderSkuChunk(skus.slice(i, i + chunkSize));
if (i + chunkSize < skus.length) {
await yieldToMain(); // 让出,给输入事件机会响应
}
}
}
5.2 用 scheduler.postTask 设优先级
非首屏的统计上报、推荐列表预取等降级为后台任务,避免抢占用户交互:
if ('scheduler' in window && 'postTask' in scheduler) {
scheduler.postTask(() => trackEvent('view_detail'),
{ priority: 'background' });
} else {
requestIdleCallback(() => trackEvent('view_detail'));
}
5.3 避免主线程阻塞的其它点
- 大列表(评价、相似商品)用虚拟滚动,只渲染可视区 20 条而非全部 2000 条。
- 避免在 input 事件里同步跑正则匹配大量文本,改 debounce + requestIdleCallback。
- 第三方 SDK(埋点、客服)一律 defer 到
requestIdleCallback之后加载。
六、优化前后真实数据对比
以下数据来自 CrUX 28 天窗口的 p75,采样自详情页线上真实用户:
| 指标 | 优化前 | 优化后 | 评级变化 |
|---|---|---|---|
| LCP (p75) | 3.4s | 1.4s | 需改进 → 良好 |
| CLS (p75) | 0.31 | 0.04 | 差 → 良好 |
| INP (p75) | 480ms | 156ms | 需改进 → 良好 |
| TTFB (p75) | 620ms | 280ms | — |
| 主图体积 (中位数) | 1.2MB | 180KB | — |
三指标全部进入“良好”档后,详情页跳出率下降 7.2%,加购转化提升 4.1%。这是性能优化最直观的回报。
七、一些容易踩的坑
- preload 滥用反而拖慢 LCP:每个
rel="preload"都在抢带宽。只对 LCP 元素和首屏关键字体 preload,其余走常规加载。 - fetchpriority 写错位置:它在
<img>和<link rel="preload">上生效,写在<source>上无效。 - 只看实验室数据不看真实数据:Lighthouse 跑出来的 LCP 是中端机型 + 模拟 4G,和线上 p75 可能差 1s 以上,务必双轨看。
- INP 优化只盯点击:INP 考核所有交互(点击、键盘、拖拽),表单输入的响应同样计入,别忘了测输入框。
- scheduler.yield 不做降级:Firefox/Safari 仍不支持,必须 fallback 到
setTimeout(0),否则直接报错。
八、写在最后
Core Web Vitals 不是一锤子买卖,是持续运营的指标。我们把它接进了发布流水线:每次详情页发版,灰度 5% 流量跑 24 小时,LCP/CLS/INP 任一 p75 退化超过 10% 自动拦截发布。性能是个会回退的东西,没有护栏,三个月后它就会爬回去。
希望这篇拆解对你有帮助。下一篇会写 INP 在长列表场景下的更深度优化,包括 React Concurrent 渲染与 startTransition 的实战取舍。