三个指标其实在回答三个不同问题:内容多久能看到(LCP)、操作多久有响应(INP)、页面视觉稳不稳(CLS)。它们各自的成因几乎不重叠,优化手段也必须分开谈。
三个指标各自衡量什么
- LCP(Largest Contentful Paint):视口内最大内容元素(图片、视频封面、块级文本)的渲染时间。衡量”主要内容加载完成”的感知速度。
- INP(Interaction to Next Paint):从用户交互到下一帧绘制出来的延迟,取页面生命周期内所有交互的近似最大值。衡量交互响应能力。
- CLS(Cumulative Layout Shift):所有意外布局偏移的累计分数。衡量视觉稳定性。
INP 与前身 FID 的本质区别:FID 只测首次输入的等待时间,INP 测的是完整往返——包括事件处理本身和之后的渲染。一个处理了 300ms 的点击在 FID 下很健康,在 INP 下不合格。
LCP 的四个子阶段
LCP 时间是四段相加的结果,拆开才定位得准:
| 子阶段 | 含义 | 典型问题 | 手段 |
|---|---|---|---|
| TTFB | 首字节到达 | 服务端慢、重定向链、无 CDN | 边缘缓存、SSR/流式响应、减少重定向 |
| 资源加载延迟 | 发现 LCP 资源到开始下载 | 资源被 JS 动态插入、懒加载误用 | 提前声明、<link rel="preload">、别懒加载首屏图 |
| 资源加载时长 | 下载耗时 | 体积大、未压缩、图太重 | AVIF/WebP、响应式 srcset、合适的压缩率 |
| 渲染延迟 | 资源就绪到绘制 | 渲染被阻塞、主线程忙 | 内联关键 CSS、推迟非关键 JS、拆长任务 |
最常见的坑是第二项:<img> 被一个 React 组件在 useEffect 里塞进 DOM,浏览器要等 JS 下载、解析、执行完才知道要下载这张图,白白浪费一个 RTT 以上。
<!-- 首屏主图:不要 lazy,要主动提高优先级 -->
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
<img src="hero.webp" fetchpriority="high" width="1200" height="600" alt="">
另一个高频问题:fetchpriority="high" 之外,还要注意 <img> 默认优先级其实不高,浏览器会先抓 CSS 和 JS。手动提权往往立竿见影。
LCP 元素在后续也可能变化——比如一张更大的图稍后加载完,LCP 会被更新。所以 PerformanceObserver 要持续监听直到页面隐藏。
INP 与长任务:主线程是唯一瓶颈
INP 的三段构成:输入延迟(input delay)+ 处理时间(processing)+ 呈现延迟(presentation)。
绝大多数 INP 问题出在后两段。根本原因一致:主线程一次只能干一件事。一个 500ms 的长任务期间,用户的点击只能排队等着。
// 把长循环切片,让出主线程
async function processAll(list) {
for (let i = 0; i < list.length; i++) {
if (i % 100 === 0) {
await new Promise(r => setTimeout(r, 0)); // 一种让出方式
}
heavyStep(list[i]);
}
}
更现代的做法是用 scheduler.yield()(可用时用,不可用时退化为 setTimeout)。它的优势是让出后回调排在队列靠前位置,比 setTimeout(0) 更快被重新调度。
const yieldToMain = () =>
'scheduler' in globalThis && 'yield' in scheduler
? scheduler.yield()
: new Promise(r => setTimeout(r, 0));
诊断手段是 Long Tasks API,把超过 50ms 的任务记下来:
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
report({ type: 'longtask', duration: e.duration, name: e.name });
}
}).observe({ type: 'longtask', buffered: true });
还要留意事件回调里的同步布局读取——它把”处理时间”和”呈现延迟”一起放大。以及第三方脚本:聊天插件、埋点 SDK、A/B 测试框架是长任务的重灾区,值得逐个测量而不是全盘接受。
CLS 的常见元凶
布局偏移分数的计算方式是 impact fraction × distance fraction:受影响视口比例乘以位移距离比例。哪怕是巨大区域移动 1px,分数也可能不低。
四类经典成因:
- 无尺寸的图片/视频/广告位:内容到达时把下方推开。解法是写死
width/height或用aspect-ratio:
.thumb { aspect-ratio: 16 / 9; width: 100%; }
-
网页字体切换:Fallback 字体与自定义字体的度量不同,文本换行位置变化导致高度跳变。解法是
font-display: optional(不产生切换期)或用size-adjust、ascent-override让 fallback 的度量对齐目标字体。 -
动态插入内容:Cookie 提示条、顶部通知插入文档流上方,把整页往下顶。解法是预留空间,或用
position: fixed浮层——注意浮层本身不计入 CLS,但它遮挡的交互会影响体验。 -
动画走布局属性:用
top/height做动画。改成transform就不产生布局偏移。
/* 会产生 CLS */
.panel { transition: top 0.3s; }
/* 不会 */
.panel { transition: transform 0.3s; }
还有一个容易被忽略的来源:SPA 路由切换时新页面内容高度未知,滚动位置与内容错位。这需要用真实的路由级指标去观测,而不是只看首次加载。
回访用户是另一个战场
第一次访问受限于网络与冷缓存,回访用户受限于缓存策略和启动逻辑。只优化冷启动的结果是回访体验并没有跟着改善。
要点是:带内容哈希的静态资源可以设较长的 max-age,HTML 本身要 no-cache 或短缓存,Service Worker 的更新策略要避免”永远命中旧版本”。用 PerformanceNavigationTiming 的 type 字段把 navigate、reload、back_forward 分开统计,才能看清三类访问各自的指标。
浏览器前进后退缓存(bfcache)是最容易被浪费的性能红利:命中时页面从内存恢复,几乎零成本。常见的破坏因素是 unload 监听、未关闭的 WebSocket 或 IndexedDB 连接、以及某些 Cache-Control: no-store 设置。命中率值得单独作为一个监控项。
实验室数据与真实数据的分歧
Lighthouse 跑在特定的网络节流与 CPU 降速设定下,是一种受控复现。它适合做 A/B 对比和回归检测,不适合直接当作用户体感。分歧来源有几种。
一是设备分布。实验室通常用中端配置的模拟,真实用户里有大量低端安卓机,JS 执行时间可能是实验室的数倍,INP 的差距往往远大于 LCP 的差距。
二是网络形态。实验室假设稳定带宽加固定延迟,真实用户处在弱信号、频繁切换 WiFi 与蜂窝、丢包重传的环境里,TTFB 的方差极大。
三是用户行为。实验室只测首次加载的冷启动路径,真实用户会带着缓存回来、会在页面还没加载完就开始点击、会同时开着多个标签页争抢主线程。”加载中途就交互”这一类实验室几乎测不到,而这恰恰是 INP 最差的场景。
四是第三方脚本的差异。实验室环境里广告、埋点、客服插件可能因为被拦截而完全没跑,真实用户那边却是完整的。
用真实用户数据决定优先级
实验室数据(Lighthouse)只有一个网络与设备组合,它的价值在于复现与对比;真实用户监控(RUM)才告诉你用户实际处在什么状况。
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(m => send({ name: 'LCP', value: m.value, rating: m.rating, target: m.entries.at(-1)?.element?.tagName }));
上报时带上归因维度(LCP 元素类型、INP 的目标选择器、CLS 的偏移源),比只报一个数字有用得多。
判断优先级的思路:
- 看分布而非均值。P75 达标而 P95 崩掉,说明特定人群或特定页面有问题;
- 按页面分组。全站均值常被流量最大的首页掩盖,真正糟糕的可能是下单页;
- 按设备分档。低端安卓机上 INP 恶化往往源自 JS 执行时间,与中高端机不是同一套解法;
- 指标与业务指标做交叉。LCP 改善但转化没动,说明瓶颈在别处。
一个务实的顺序:先修那些”改法明确、收益确定”的项(图片尺寸、首屏图优先级、字体度量),再去攻坚长任务拆分这类需要重构的工作。前者通常能让 P75 明显下移,后者决定长尾。
Comments (2)
感觉作者是被生产事故教育过的。
这个坑我踩过两次,第一次怪框架,第二次怪自己。
Please Sign in to join the discussion.