轮播图和字体把移动端指标拖垮之后:CLS 与 INP 的排查与优化实战
去年 11 月接手一个零售电商站的移动端优化,OpenChrome 的 Core Web Vitals 报告里三项指标两项不合格:累积布局偏移(Cumulative Layout Shift, CLS)移动端实测 0.43,下一次绘制交互(Interaction to Next Paint, INP)移动端 P75 高到 520ms。LCP 反而早就修到了 2.3s 以内。结果就是 Search Console 里"移动端可用性没问题、页面体验分被拖垮",商品列表页的自然搜索流量连续三个月阴跌。这篇把整个排查和修复过程拆开讲,所有数据都是我们自己的 RUM(Real User Monitoring,真实用户监控)回传值。
问题是怎么被发现的
最先报警的不是前端,是运营。10 月中旬周报里写着"双 11 预热落地页的移动端加购率比去年同期低 18%",但 UV 没掉。第一反应是活动页改版出了交互问题,翻了两天埋点没发现异常。后来随手打开 Search Console 的"网页体验"报告,看到移动端 Core Web Vitals 那一栏是红的,点进去 214 个 URL 被标记"效果欠佳",其中商品列表页和购物车页占了 160 多个。

这里有个容易踩的坑:Search Console 报告的是 CrUX(Chrome UX Report)里 28 天滚动窗口的现场数据(field data),不是你现在跑一遍 Lighthouse 看到的实验室数据(lab data)。我们本地 Lighthouse 跑出来 CLS 只有 0.08,因为 Lighthouse 模拟的是首次访问冷加载,而真实用户大量是回访,字体和 JS 已有缓存的场景、首访场景混在一起,权重完全不同。所以定位问题必须以 CrUX 和 RUM 为准,Lighthouse 只用来做单页复现。
排查链路:三层工具各管一段
我们的排查链路是三层漏斗:先用 CrUX 找出哪些 URL 模板超标,再用 PageSpeed Insights 对单模板做归因,最后靠 web-vitals 库把线上每个用户会话的明细回传到自己的数仓。整体流程如下:
flowchart TD
A[Search Console CWV 报告<br/>锁定超标 URL 模板] --> B[PageSpeed Insights<br/>单页归因诊断]
B --> C{实验室数据能否复现?}
C -- 能 --> D[DevTools Performance 面板<br/>逐帧分析]
C -- 不能复现 --> E[web-vitals RUM 回传<br/>按会话归因]
D --> F[定位具体元素/任务]
E --> F
F --> G[修复上线]
G --> H[CrUX 28 天滚动窗口<br/>验证 P75 恢复]
三个工具的分工别搞混,各说各话会浪费大量时间:
| 工具 | 数据类型 | 粒度 | 用途 |
|---|---|---|---|
| Chrome UX Report(CrUX) | 现场数据 | URL/origin 级 P75 | 确认问题存在与影响面 |
| PageSpeed Insights | 现场+实验室 | 单 URL,含诊断项 | 归因到具体元素 |
| web-vitals 库自建回传 | 现场数据 | 每次会话每次交互 | 复现不了的线上问题 |
web-vitals 回传的接入很简单,官方库不到 2KB:
// 依赖:npm install web-vitals,版本 4.x
// 在移动端主入口最早的位置引入,尽量在首屏脚本之前
import { onCLS, onINP, onLCP } from 'web-vitals';
// 上报函数:用 navigator.sendBeacon 避免页面卸载时请求被取消
function sendToAnalytics(metric) {
const payload = JSON.stringify({
name: metric.name, // 指标名:CLS / INP / LCP
value: metric.value, // 数值,CLS 是小数,INP 是毫秒
id: metric.id, // 该指标的会话内标识 id,用于去重
path: location.pathname, // 页面路径,方便按模板聚合
navType: metric.navigationType // 导航类型,区分首访与回访
});
// sendBeacon 在页面关闭时也能把数据发出去
navigator.sendBeacon('/api/vitals', payload);
}
// 三项核心指标统一挂上报,attribution 选项会附带归因信息
onCLS(sendToAnalytics, { reportAllChanges: false });
onINP(sendToAnalytics, { reportAllChanges: false });
onLCP(sendToAnalytics, { reportAllChanges: false });
上线一周后数仓里攒了 40 多万条会话记录,问题立刻清楚了:CLS 超过 0.25 的会话里,83% 发生在商品列表页首屏,归因元素高度集中在轮播图容器和正文字体两个来源;INP 超过 300ms 的交互里,61% 落在"加入购物车"按钮的 click 事件上。下面分开讲。
CLS:轮播图和字体这两个惯犯
轮播图没有占位高度
商品列表页顶部有一个 6 张图的自动轮播,图片由 CDN 动态裁剪,前端只写了一个 <img> 标签挂在容器里,容器高度完全由图片加载后撑开。弱网下图片 1.2s 后才加载完成,加载瞬间整个商品流被往下顶 240px 左右——用户正要点的"筛选"按钮直接飞了。这就是典型的布局偏移:已渲染元素的位置因为后续资源到达而改变。
修复方式是给图片预留正确宽高比。设计稿里轮播位是 750×300,宽高比 2.5:1:
<!-- 修复前:容器无高度,图片加载后把内容顶下去 -->
<div class="carousel"><img src="banner-01.jpg" alt="促销"></div>
<!-- 修复后:写死宽高属性 + CSS aspect-ratio 双保险 -->
<div class="carousel">
<!-- width/height 属性让浏览器在图片加载前就算出宽高比 -->
<img src="banner-01.jpg" alt="促销" width="750" height="300">
</div>
/* 依赖:无,纯原生 CSS */
.carousel img {
/* 宽高比 2.5:1,加载前就占住空间 */
aspect-ratio: 750 / 300;
/* 宽度跟随容器,高度按比例自动计算 */
width: 100%;
height: auto;
/* 防止极端情况下图片溢出撑破容器 */
object-fit: cover;
}
还有个连带问题:轮播图从第 2 张开始切换时,如果下一张图片还没预加载完,切换瞬间也会产生偏移。我们加了 <link rel="preload" as="image"> 把前 3 张图预载,其余 3 张用 IntersectionObserver 在用户停留超过 1.5s 时再拉。
字体晚加载导致换行跳动
第二个来源更隐蔽。站点的中文字体子集化后是 340KB 的 woff2,走的是默认 font-display: auto,浏览器会先隐身等字体(约 3 秒块状期),字体到达后文字重排——标题从 2 行变 3 行,下面的商品卡片整体下移,CLS 直接累计。RUM 里能看到这种偏移大量发生在回访但缓存过期的会话里。
两步修复:给字体加 font-display: swap,并用 preload 提前拉取:
<!-- 在 head 里尽早声明,preload 的 crossorigin 不能省 -->
<link rel="preload" href="/fonts/main-cn-subset.woff2" as="font" type="font/woff2" crossorigin>
/* 字体声明改造,swap 表示字体未就绪时先用回退字体渲染 */
@font-face {
font-family: 'MainCN';
src: url('/fonts/main-cn-subset.woff2') format('woff2');
/* swap:回退字体先上屏,字体到了再换,可能短暂闪一下但不跳动布局 */
font-display: swap;
/* unicode-range 限定子集,避免全量字体被意外触发 */
unicode-range: U+4E00-9FFF;
}
swap 之后会有一个新的小问题:回退字体和品牌字体的字宽不同,字体到达瞬间文字还是会"重排一下",只是幅度小了。要彻底压平,需要用 size-adjust 或 ascent-override 把回退字体的度量对齐到品牌字体。我们把思源黑体作为回退,配了 size-adjust: 104% 后,残留偏移从每次 8px 降到 1px 以内,肉眼基本不可见。
CLS 的机制值得单独说一下
很多人不知道 CLS 的计算用的是会话窗口(session window)模型:偏移不是无限累加的,而是按 5 秒一个窗口切分,窗口内以间隔不超过 1 秒的连续偏移为一组,取全页面会话中最大的那个窗口分数,而不是所有偏移之和。这意味着:把大偏移在时间上打散(比如轮播图延迟 1 秒再初始化)并不能降低 CLS,因为只要一个窗口内累计到 0.25 就超标;必须从根上消除偏移本身,占位才是根本解法。这也是我们一开始试图用"延迟渲染轮播图"糊弄失败的原因。
INP:购物车点击为什么 500ms 才有反应
下一次绘制交互(Interaction to Next Paint, INP)衡量的是从用户交互(点击、点按、键盘输入)到下一帧视觉反馈完成的时间,取所有交互中接近最差的那个(P98 以上,严格说是取每页所有交互中最高的一档)。我们购物车页的"加入购物车"按钮 INP P75 是 520ms,远超 200ms 的良好线。
用 DevTools Performance 面板录制点击,瀑布是这样的:click 事件处理器同步执行 340ms——里面有同步请求优惠券接口、全量重算购物车列表、把 6 个商品卡片的 DOM 全部重建,然后才是浏览器绘制"已加入"的角标动画。INP 差不是因为任务多,是因为长任务把"视觉反馈"堵在了后面。用户视角就是"点了没反应,是不是坏了",然后连点两三次,反过来又制造了更多长任务。
修复思路是任务切片(task slicing):先把"立即反馈"做完(按钮置灰 + 局部 loading 态),再把重活拆成小步让出主线程:
// 依赖:现代浏览器原生 scheduler API;低版本回退 setTimeout
async function onAddToCart(item) {
// 第一步:同步的即时反馈,必须在 50ms 内完成
button.disabled = true;
button.textContent = '加入中…';
// 让出主线程,让浏览器先把 loading 态绘制出来
await yieldToMain();
// 第二步:拆开的重活,逐步执行
const coupon = await fetchCoupon(item.id); // 异步请求本身不阻塞绘制
renderCartList(coupon); // 重渲染拆到下一步
await yieldToMain();
trackAddCart(item.id); // 埋点最后做,不抢首帧
}
// 让出主线程:优先用 scheduler.yield,回退方案用 setTimeout(0)
function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
// scheduler.yield 会保持任务优先级,比 setTimeout 更不容易饿死
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
渲染那一步还有个坑:renderCartList 一次性重建 6 个卡片的 DOM 花了 90ms。改成只 diff 更新数量变化的 2 个卡片后降到 15ms。INP 优化的一半工作量其实花在"别动不需要动的 DOM"上。
顺带说下 Event Timing API,它是 INP 的底层机制:浏览器为每次交互记录四个关键时间点,INP 取的是从事件发生到绘制完成的全链路时长,所以处理器的执行时间和渲染管线的排队时间都算在内:
sequenceDiagram
participant U as 用户
participant M as 主线程
participant R as 渲染管线
U->>M: 点击"加入购物车" (event-time)
M->>M: click 处理器执行 340ms<br/>同步请求 + 全量重渲染
M->>R: 提交帧
R->>U: 下一帧绘制 loading 态 (presentation)
Note over U,R: INP = presentation - event-time<br/>我们实测 520ms,阈值 200ms
排查时看 Performance 面板的 Event Log,如果 processing-duration 长,问题在你的 JS;如果处理结束到绘制之间还有延迟,问题在渲染管线(比如强制同步布局 forced reflow)。我们的案例属于前者,但如果列表页有"读布局写样式读布局"的循环,就是后者,修法是批量化读写。
修复前后的数据对照
12 月初全量上线,CrUX 的 28 天窗口滚动更新,1 月上旬稳定后的对照:
| 指标(移动端 P75) | 修复前 | 修复后 | 良好阈值 |
|---|---|---|---|
| CLS | 0.43 | 0.04 | ≤ 0.1 |
| INP | 520ms | 140ms | ≤ 200ms |
| LCP | 2.3s | 2.1s | ≤ 2.5s |
流量侧(数据来自 Search Console,取同样 28 天窗口对比):
| 维度 | 修复前 28 天 | 修复后 28 天 | 变化 |
|---|---|---|---|
| 商品列表页自然点击 | 41,200 | 51,800 | +25.7% |
| 购物车页自然点击 | 9,600 | 11,300 | +17.7% |
| 移动端加购转化率 | 2.1% | 2.6% | +0.5pp |
必须诚实地说:流量涨 25% 不能全归功于 Core Web Vitals,同期还有内容更新和季节因素。但 Search Console 里"网页体验"从红变绿是明确的信号,且移动端加购转化那 0.5 个百分点很难用其他变量解释。站点速度类优化对网站优化的贡献,一半在排名信号,一半在转化,后者往往更大也更直接。
两个常见误区
误区一:Lighthouse 全绿就万事大吉。实验室数据是模拟环境单次首访,现场数据是真实设备、真实网络的分布统计。CrUX P75 达标才算达标,上线后的验证必须回到 Search Console 等 28 天窗口滚动,这个过程急不来,一般 2-4 周才能看到报告翻绿。
误区二:CLS 只盯图片。字体、广告位、异步插入的推荐组件、骨架屏到真实内容的切换,都是偏移源。广告位尤其要提前占位,我们信息流里一个 300×250 的广告位补了固定高度容器后,剩余偏移来源列表里它从第二名掉出了前十。
趋势上提一句:INP 取代 FID(First Input Delay,首次输入延迟)成为三大指标之一已经一年多,它是三个指标里衡量"交互全链路"的那个,对 SPA 框架的重渲染策略非常敏感。如果你在做面向 AI 搜索引擎的生成式引擎优化(Generative Engine Optimization, GEO),内容被抓取和引用的前提依然是页面能被正常渲染和抓取预算不被浪费——Core Web Vitals 这套移动端体验指标,就是传统搜索引擎网站优化里离用户最近的那一环,两件事是接力关系而不是替代关系。
参考与延伸
- web.dev — Cumulative Layout Shift (CLS):https://web.dev/articles/cls
- web.dev — Interaction to Next Paint (INP):https://web.dev/articles/inp
- Chrome UX Report(CrUX)文档:https://developer.chrome.com/docs/crux/
- web-vitals JavaScript 库(GitHub 官方仓库):https://github.com/GoogleChrome/web-vitals
Core Web Vitals · CLS 优化 · INP 优化 · 移动端性能 · 电商网站 · 网站优化