Skip to content

필사 모드: 真正把 Core Web Vitals 修好 — 用数字压下 LCP、INP、CLS 的顺序

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — Lighthouse 98 分,Search Console 却是红灯

在本地跑 Lighthouse,性能 98 分。发布一周后打开 Search Console,那个页面还老老实实待在「需要改进的网址」里。团队说「大概是谷歌的数据更新慢吧」,一个月后情况依旧。

这两个数字测的是不同的东西。Lighthouse 是在受控环境里加载一次的结果,而 Search Console 展示的是真实访客 28 天的分布。改善了前者却带不动后者的情况非常常见,而搞清楚这个落差从哪儿来,就是这项工作的一半。

本文先点出这个落差,然后按效果大小的顺序,讲怎么真正把三个指标压下去。

LCP、INP、CLS 各自测量什么,以及阈值

三个指标负责用户体验的不同侧面。LCP 是加载,INP 是响应性,CLS 是视觉稳定性。

指标测量的内容「良好」标准能否在实验室测最常见的原因
LCP最大内容元素被绘制的时点2.5 秒以内可以TTFB 慢、主视觉图被发现得太晚
INP从交互到下一次绘制200ms 以内不行 (用 TBT 代理)长任务、沉重的 DOM 更新
CLS非预期布局偏移的累计分数0.1 以内部分可以没有尺寸的图片、后插入的横幅

这里要点出大多数文章都会漏掉的两件事。

第一,这些阈值适用的不是平均值,而是 75 百分位。页面加载中有 75% 的 LCP 在 2.5 秒以内,才算「良好」。就算平均 LCP 是 2.1 秒,只要有 25% 超过 4 秒就算不合格。前面关于百分位的那套话在这里原样重演。

第二,移动端和桌面端是分开评估的。经常有人只看桌面端就判定通过了,可如果流量绝大部分来自移动端,该改善的就是移动端的数字。

CLS 的计算方式也顺带说一遍。CLS 不是整个页面生命周期的简单求和。它把发生偏移的区间按最长 5 秒、偏移间隔不超过 1 秒的规则归并成会话窗口,然后取其中总和最大的那个窗口的值。无限滚动的页面开着很久分数也不会无限增大,原因就在这里。

实验室数据与真实用户数据 — 哪一边才是真相

Lighthouse 会模拟设备与网络,加载一次。缓存是空的,第三方脚本受当天响应速度摆布,用户什么也不点。所以 Lighthouse 测不了 INP。因为根本没有交互。它转而拿总阻塞时间(TBT)作为代理指标,两者相关,但不是同一个值。

真实用户数据正相反。老旧的安卓设备、3G 路段、已经有缓存的回访、广告拦截器,以及真实的点击,全都混在一起。用于搜索评估的是这一边,用户实际经历的也是这一边。

结论很简单。实验室数据用于发现回归,而判断的依据是真实用户数据。把 Lighthouse 分数当成目标,你做出来的就会是分数上去了、用户体验纹丝不动的优化。

自建 RUM 采集,一小段脚本就能起步。

// web-vitals v4 — 用 attribution 构建版还能一并知道「是什么」慢
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating, // good | needs-improvement | poor
    nav_type: metric.navigationType,
    path: location.pathname,
    // 不区分设备就合在一起,什么也诊断不出来
    device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
    conn: navigator.connection?.effectiveType,
    attribution: metric.attribution,
  })
  navigator.sendBeacon('/rum/vitals', body)
}

onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)

采集之后一定要按路径、按设备去看 75 百分位。只有一个整体平均值,是看不出该修哪个页面的。

压下 LCP 的顺序 — 从服务端响应到字体

把 LCP 整个看,会觉得无处下手。拆成四段,答案就出来了。

  1. TTFB — 服务端发出第一个字节之前
  2. 资源加载延迟 — TTFB 之后到 LCP 资源请求发起之前
  3. 资源加载时长 — 把那个资源收完之前
  4. 渲染延迟 — 收完之后到真正绘制到屏幕上之前
import { onLCP } from 'web-vitals/attribution'

onLCP(({ value, attribution: a }) => {
  console.table({
    TTFB: Math.round(a.timeToFirstByte),
    '资源加载延迟': Math.round(a.resourceLoadDelay),
    '资源加载时长': Math.round(a.resourceLoadDuration),
    '渲染延迟': Math.round(a.elementRenderDelay),
    合计: Math.round(value),
    元素: a.target,
  })
})
// ┌──────────────────┬───────┐
// │ TTFB             │   412 │
// │ 资源加载延迟     │  1180 │  <-- 元凶
// │ 资源加载时长     │   340 │
// │ 渲染延迟         │    96 │
// │ 合计             │  2028 │
// └──────────────────┴───────┘

目标分配大致是 TTFB 与资源加载时长各占 40 个百分点左右,两项延迟应该接近于 0。上面这个例子里加载延迟是 1,180ms,意思是浏览器有一秒多的时间根本不知道主视觉图的存在。图片文件再怎么优化,这个数字也不会变小。

先确认 TTFB

curl -s -o /dev/null \
  -w 'dns:%{time_namelookup}s  tcp:%{time_connect}s  tls:%{time_appconnect}s  ttfb:%{time_starttransfer}s  total:%{time_total}s\n' \
  https://shop.example.com/products/12345
# dns:0.021s  tcp:0.043s  tls:0.118s  ttfb:0.412s  total:0.588s

# 顺便看缓存是否命中 — CDN 未命中是 TTFB 变慢的常见原因
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96

只要服务端下发 Server-Timing 头,就能在 RUM 数据里看到 TTFB 的内部构成。没有它,TTFB 一慢就没办法分清是网络还是后端。

资源加载延迟是最大的杠杆

如果 LCP 元素是图片,就必须让预加载扫描器能找到它。下面三种做法会让扫描器失效。

  • 用 CSS 的 background-image 放的主视觉图 — 要等 CSSOM 建好才会被发现。
  • 用 JavaScript 插入的图片 — 要等打包产物执行才会被发现。
  • 给主视觉图加了懒加载 — 最常见的自残。给视口内的图片挂 lazy,请求本身就被往后推了。
<!-- 主视觉图: 放在标记里,提高优先级,绝对不要挂 lazy -->
<img
  src="/hero-1200.avif"
  srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width="1200"
  height="675"
  fetchpriority="high"
  decoding="async"
  alt="夏季新品"
/>

<!-- 兜底: 如果实在没法放进标记里,就用 preload 代替扫描器 -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="(max-width: 768px) 100vw, 1200px"
  fetchpriority="high"
/>

如果用的是 Next.js 的 next/image,给主视觉加上 priority 属性,就同时处理了上面的 fetchpriority="high" 和取消 lazy 两件事。而列表下方的图片则相反,保持默认的懒加载。

渲染阻塞资源要减少,但不要整个干掉

有两条常见的错误建议。

「把所有 CSS 都内联。」内联关键 CSS 是对的,但把整份 CSS 都内联会让 HTML 变大,TTFB 之后的解析时间增加,而且缓存完全用不上。目标要限定在首屏所需的规则上。

「JavaScript 加个 defer 就行了。」defer 只是推迟执行,下载和解析的成本一分不少。如果 LCP 元素是由 JavaScript 渲染出来的,defer 反而会让 LCP 更慢。这种情况需要的是服务端渲染。

第三方脚本要单独处理。标签管理器、聊天挂件、A/B 测试工具通常都会比 LCP 元素更早加载,把带宽和主线程抢走。

# 看看是哪些资源在 LCP 之前抢走了带宽
npx lighthouse https://shop.example.com/ \
  --only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
  --output=json --output-path=./lh.json --quiet

node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
  console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager        412ms blocking 148KB
# Intercom                  288ms blocking 231KB
# Hotjar                    134ms blocking  92KB

字体

字体横跨 LCP 与 CLS 两边。font-display: swap 能消掉文字不可见的那一段,但兜底字体与真实字体之间 metrics 的差异会让布局跳动。解法是把兜底字体的度量对齐到真实字体。

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard-subset.woff2') format('woff2');
  font-weight: 400 700;
  font-display: swap;
  /* 只把韩文预组合字 + ASCII 做成子集 — 完整字体会有好几 MB */
  unicode-range: U+0020-007E, U+AC00-D7A3;
}

/* 把兜底字体的度量对齐到真实字体,消除交换时刻的偏移 */
@font-face {
  font-family: 'Pretendard Fallback';
  src:
    local('Apple SD Gothic Neo'),
    local('Malgun Gothic');
  size-adjust: 103%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}

只 preload 首屏用到的字体文件。把各种字重全都 preload,就会和主视觉图抢带宽。

INP 取代 FID 的原因,以及把长任务切开

FID 只测量了首次交互的输入延迟。也就是浏览器开始执行事件处理器之前的那段时间。哪怕处理器攥着主线程不放 400ms,FID 也不会把它算进去。所以大多数站点都能轻松通过 FID,而通过了的站点照样卡顿。

INP 把三段全都包含进来,而且看的不是第一次,而是页面生命周期内的所有交互。

  • 输入延迟:主线程正忙于别的工作,导致处理器启动被推后的时间
  • 处理时长:事件处理器执行的时间
  • 呈现延迟:处理器结束之后到下一帧被绘制出来之前

先确认问题出在哪一段。

import { onINP } from 'web-vitals/attribution'

onINP(({ value, attribution: a }) => {
  console.table({
    输入延迟: Math.round(a.inputDelay),
    处理时长: Math.round(a.processingDuration),
    呈现延迟: Math.round(a.presentationDelay),
    合计: Math.round(value),
    目标: a.interactionTarget,
    事件: a.interactionType,
  })
})
// 输入延迟 34 / 处理时长 268 / 呈现延迟 92 / 合计 394
// 目标: button.filter-apply

处理时长大就把处理器切开。这里有一件事必须准确理解。await 不是让出await 造出来的是微任务,而微任务会在当前任务结束之前执行,所以根本不给浏览器绘制的空隙。要真正让出,就必须制造任务边界。

// 差: 点一下就把主线程攥住 380ms
applyButton.addEventListener('click', () => {
  applyFilters(state) //  12ms
  rerenderTable(rows) // 260ms
  syncToServer(state) // 108ms
})

// 好: 先把看得见的画出来,其余切成任务并让出
applyButton.addEventListener('click', async () => {
  applyFilters(state)
  showPendingState() // 只有用户立刻会看到的反馈才同步执行

  await yieldToMain() // 浏览器在这里绘制
  rerenderTable(rows)

  await yieldToMain()
  syncToServer(state) // 与画面无关的工作放到最后
})

function yieldToMain() {
  // scheduler.yield 在让出后仍保留优先级,比 setTimeout 更有利
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

另一个经常搞错的点是 requestIdleCallback。它在空闲时间执行,所以把必须显示到屏幕上的更新放进去,呈现延迟就会原样拉长。空闲回调只用于用户并不在等的工作,比如发送分析事件或者预取。

呈现延迟大的情况,通常是 DOM 太大。要么让浏览器把屏幕外区域的渲染成本往后推,要么把长列表虚拟化。

/* 跳过屏幕外卡片的布局与绘制 */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px; /* 防止滚动条跳动的预估尺寸 */
}

长任务可以常态化监控。

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 120) continue
    console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
  }
}).observe({ type: 'longtask', buffered: true })

CLS 的三大元凶与各自准确的解法

CLS 的成因基本上是固定的那几种,处理掉三样,大多数情况就能降到 0.1 以下。

第一,没有声明尺寸的图片和 iframe。在资源到达之前,浏览器不知道该留出多大的位置。解法是明确写上 width 和 height 属性。现代浏览器会从这两个值自动算出 aspect-ratio,即使在响应式布局里也能把位置占住。就算用 CSS 控制尺寸,属性也要照样保留。

<!-- 属性是真实像素尺寸,CSS 是显示尺寸 — 两者都需要 -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="商品" />

第二,后来才插入的横幅、广告与 Cookie 提示。在已经绘制好的内容上方塞进一个元素,下面的一整块都会被推走。解法有两个。要么提前把位置占好,要么把它从文档流里拿出去。

/* 提前把位置占住 — 就算广告没出来也不会有偏移 */
.ad-slot {
  min-height: 250px;
  contain: layout;
}

/* Cookie 横幅不要放进文档流,用浮层的方式浮出来 */
.cookie-banner {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
}

这里有个常见的误解。「用户点击展开的折叠面板也会被算进 CLS。」不会。用户输入之后 500ms 以内发生的偏移会带上 hadRecentInput 标志,从分数里排除。不过如果超过这个时间、异步到达的内容把东西推开了,那就会被算进去。

第三,字体交换。用上一节的 size-adjust 方式解决。在文字多的页面上,仅这一项就占掉 CLS 一半的情况相当常见。

再补一条,动画要用 transform 来做。去动 topleftwidthheight,每一帧都会被算成布局偏移,原样堆进分数里。

到底是什么在动,可以直接观察。

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    if (shift.hadRecentInput) continue // 用户输入之后紧接着的偏移会被排除
    for (const src of shift.sources) {
      console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)

把性能预算放进 CI

靠手工测量改出来的优化,两次发布之内就会退回去。能挡住回归的只有闸门。

这里同样有个常见的错误。不要对合成分数设闸门。分数是多个指标的加权平均,而权重会随版本变化。仅仅是工具升级就可能让 CI 挂掉,或者反过来,某个指标变差了但另一个变好了,分数维持不动。闸门要挂在单项指标的绝对值上。

{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
      "numberOfRuns": 5,
      "settings": { "preset": "desktop" }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "server-response-time": ["error", { "maxNumericValue": 600 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
        "unsized-images": "error",
        "uses-responsive-images": "warn"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
npx lhci autorun
# ✅  .lighthouseci/ directory writable
# ✅  Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
#   ✘  largest-contentful-paint failure  expected <= 2500, found 2884
#      https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.

numberOfRuns 设成 5 很重要。单次运行的波动太大,闸门会随机崩掉。Lighthouse CI 是按多次运行的中位数来判定的。

打包体积要作为单独的预算来管理。它是对 LCP 与 INP 影响最直接的变量。

npx size-limit
#   Path: .next/static/chunks/pages/index-*.js
#   Size: 164.2 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#
#   Path: .next/static/chunks/pages/products/[id]-*.js
#   Size: 221.4 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#   ✘ Size limit has been exceeded by 41.4 kB

最后,CI 闸门过了并不等于结束。CI 只能抓住回归。发布之后要连着几天确认 RUM 的 75 百分位是不是真的降下来了,改善才算完成。实验室里砍掉 400ms 而真实用户数据纹丝不动的情况确实很常见,那种时候瓶颈通常是只存在于真实用户环境里的某个东西。

结语 — 要修的是 75 百分位,不是分数

要记住的只有一句话。Lighthouse 是抓回归的工具,而要修的对象是真实用户数据的 75 百分位。

工作顺序也由此而来。先接上 RUM,按路径、按设备看 75 百分位。挑出最差的那条路径,如果是 LCP 就拆成四段确定问题出在哪一段,如果是 INP 就在输入延迟、处理时长、呈现延迟里确定是哪一个。只应用对应那一段的解法。然后用单项指标的绝对值挂上 CI 闸门,让它不会再退回来。

最常见的一记重拳依然是主视觉图。仅仅是摘掉 lazy 属性、加上 fetchpriority="high"、写明 width 和 height,就能让 LCP 与 CLS 同时下降的页面多得是。就从那里开始。

值得继续深入的资料。

현재 단락 (1/243)

在本地跑 Lighthouse,性能 98 分。发布一周后打开 Search Console,那个页面还老老实实待在「需要改进的网址」里。团队说「大概是谷歌的数据更新慢吧」,一个月后情况依旧。

작성 글자: 0원문 글자: 10,066작성 단락: 0/243