Core Web Vitals 入门:LCP、FID、CLS 分别在测什么,怎么下手优化
五月份 Google 正式宣布,把 Core Web Vitals 这一组指标纳入搜索排名信号,虽然官方说权重不会盖过内容相关性,但对一个内容型站点来说,这足够让人认真对待起来。团队里第一反应是先跑一遍 Lighthouse 看分数,但真正花时间弄明白的,是这三个指标——LCP、FID、CLS——各自到底在量什么,以及它们为什么会被单独挑出来,而不是继续沿用以前那套白屏时间、可交互时间的说法。
为什么不是白屏时间和可交互时间
以前判断页面快不快,常用的是 FP(首次绘制)、FCP(首次内容绘制)、TTI(可交互时间)这几个。它们不是错的,但都有一个共同问题:衡量的是"技术意义上发生了什么",不完全等于"用户实际感觉到了什么"。
FCP 只要求页面画出第一个像素——哪怕那是个空的骨架屏背景色,也算数。用户盯着屏幕,眼睛看到的可能还是一片空白或者一个占位框,但 FCP 已经"达标"了。TTI 更麻烦,它要求主线程连续 5 秒没有长任务才算"可交互",这个定义偏工程视角,用户不会在意主线程有没有安静满 5 秒,他们只在意自己点下去那一下有没有反应。
TTI 还有一个更实际的麻烦:它的计算本身依赖回看一段时间窗口之后才能确定,不是页面加载到某个时刻就能立刻给出结果的实时值,这导致 TTI 只适合事后跑一次 Lighthouse 去看,没法做成持续的实时监控指标去盯线上真实用户。团队里以前想用 TTI 搭一套线上监控,试了几次发现这个指标本身没法在真实用户浏览器里可靠地实时采集,只能退回去用 Lighthouse 定期跑分,这也是促使团队转向 Core Web Vitals 的一个具体原因——LCP、FID、CLS 这三个都能在真实用户的浏览器里用 PerformanceObserver 直接采集,不需要事后离线计算。
Core Web Vitals 想解决的就是这个错位。三个指标分别对应用户在浏览一个页面时会经历的三个真实瞬间:内容到底什么时候真正出现在眼前(LCP)、第一次点击或者输入有没有被及时响应(FID)、页面加载过程中内容会不会毫无预兆地跳动(CLS)。它们不追求覆盖性能的方方面面,只挑用户最容易感知到、也最容易被工程忽视的三件事。
三个指标各自还配了一条明确的阈值线——多少算好、多少算差,而不是像以前那样只能凭感觉说"这个页面感觉挺快"。这一点在跟产品、运营沟通优化优先级时格外有用。以前说页面慢,对方问"慢多少、优化到什么程度才算达标",答不上来;现在能直接说"当前 LCP 是 3.4 秒,目标是压到 2.5 秒以内",这种可量化、可对齐的表达方式,是这三个指标真正落地到团队协作里的地方,而不只是一份跑分报告。
也正因为有了明确阈值,这三个指标才会被 Google 选中当作排名信号——不是因为它们最难优化,而是因为它们最容易被客观量化、跨站点横向比较。像"页面观感是否精致"这类主观体验,工程上很难标准化,Core Web Vitals 反而选的是相对容易量化、又和真实体验强相关的三件事,这也是为什么它们只有三个,而不是列出十几个指标让所有网站疲于奔命。
LCP:最大内容多久画出来
LCP(Largest Contentful Paint)盯的是视口内面积最大的那个内容元素——通常是一张首屏大图、一段大字号标题,或者一块背景图——最终绘制完成的时间点。Google 给的及格线是 2.5 秒以内为"良好",超过 4 秒算"差"。
它和 FCP 的区别在于,FCP 只要有内容画出来就触发,哪怕那只是导航栏上一个图标;LCP 关心的是页面上视觉权重最大的那块东西,这块东西往往才是用户翻开页面真正想看的东西。想知道当前页面的 LCP 元素具体是谁,不需要跑复杂工具,浏览器控制台里贴一段代码就能当场看到:
1new PerformanceObserver((list) => { 2 const entries = list.getEntries() 3 const last = entries[entries.length - 1] // LCP 会多次上报候选,取最后一次即最终值 4 console.log('LCP:', last.startTime.toFixed(0), 'ms', last.element) 5}).observe({ type: 'largest-contentful-paint', buffered: true })
buffered: true 的作用是把页面加载早期已经产生的 LCP 候选一并补报,哪怕这段代码是页面画完之后才贴进控制台执行的,也能拿到完整的值。跑完之后控制台打出来的那个 DOM 元素,很多时候会让人意外——以为是首页主图,实际判定成了某个占了大半屏幕的渐变背景 div,或者一段特别大的营销文案。这提醒一件事:优化 LCP 之前,得先确认浏览器认定的"最大内容"是不是真的和产品期望用户看到的内容一致,如果不一致,说不定该调整的是页面结构本身,而不是加载策略。
除了控制台贴代码这种临场排查手段,DevTools 里也有更直观的入口。Performance 面板录制一次页面加载之后,时间轴上有一条专门的 LCP 标记线,鼠标悬停能直接看到这次判定选中的元素截图,不用自己再写一遍 PerformanceObserver。Lighthouse 报告里的"Largest Contentful Paint element"这一项审计同样会把具体的 DOM 节点路径列出来,比控制台打印的 last.element 更方便复制粘贴去对照代码。团队里后来养成的习惯是:每次上一个新落地页,先跑一遍 Lighthouse 看看这一项,确认判定的元素和产品经理心里想的"首屏重点"是不是同一个东西,避免优化了半天图片加载,结果 LCP 元素其实一直是页面顶部一段占位用的空白骨架。
还有一种容易被忽略的情况——LCP 元素会随着视口尺寸变化而变化。同一个页面,桌面端宽屏下最大的元素可能是一张横版banner图,移动端窄屏下同一张图按比例缩小之后,反而是下面一段两行的标题文字面积更大,被判定成了 LCP 元素。这意味着排查 LCP 不能只在一个视口宽度下测一次,至少要分桌面和移动两种典型宽度各跑一遍,两边优化的对象很可能完全不是同一个元素。
确认了 LCP 元素之后,优化路径基本分两条。如果 LCP 元素是图片,理论上最直接的手段是把它标成 importance="high",让浏览器提高这张图在资源队列里的优先级,不用等到 HTML 解析到它所在的位置才发现要下载——但这个属性眼下还在 origin trial 阶段,需要单独申请试用令牌才能在生产环境打开,Chrome 里也只能在 flag 后面才能直接体验,正式落地要等到明年,现在能做的是提前了解这个方向、在个别可控的内部页面上试验,还不能当成随手就能用在所有生产页面上的成熟属性:
1<img 2 src="hero.webp" 3 importance="high" 4 width="1200" 5 height="600" 6 alt="" 7/>
配合 <link rel="preload"> 效果更明显,尤其是图片来自 CSS 背景(background-image)时——浏览器的预加载扫描器不会主动解析 CSS 文件里的图片地址,只有等 CSSOM 构建完、真正要绘制时才发起请求,这段延迟往往就是 LCP 慢的直接原因:
1<link rel="preload" as="image" href="hero.webp" importance="high" />
这里有个团队里踩过的坑值得单独提一句:loading="lazy" 这个原生懒加载属性这一年已经很成熟,团队里图省事,给页面里所有 <img> 都统一加上了这个属性,结果发现首页 LCP 反而变差了。原因是 LCP 元素本身很可能就在首屏视口内,加了 loading="lazy" 之后,浏览器会把它当成"可能在视口外、延后处理"的资源,反而延迟了它的下载优先级。首屏内、尤其是判定为 LCP 元素的图片,一定不能加 loading="lazy",这个属性只该用在首屏视口之外、需要滚动才能看到的图片上。
如果 LCP 元素是文字块,问题常常出在字体加载上。自定义字体没到位之前,浏览器要么先用系统字体顶一下再切换(这叫 FOUT,闪一下但内容先出得来),要么直接把文字隐藏到字体下载完(这叫 FOIT,白屏时间变相拉长,而且这块空白正好可能就是 LCP 元素)。font-display: swap 能强制走 FOUT 那条路,让 LCP 不必等字体文件:
1@font-face { 2 font-family: 'BrandSans'; 3 src: url('/fonts/brand-sans.woff2') format('woff2'); 4 font-display: swap; 5}
还有一个容易被忽略的点:LCP 元素如果是通过 JS 异步渲染出来的(比如首屏关键内容要等一个接口返回才渲染),那 LCP 天然就被这个接口的响应时间兜住了。这种情况下光优化图片和字体没用,得往前查这个接口本身是不是走了不必要的串行等待,或者能不能用服务端渲染 / 预渲染把首屏内容直接吐在 HTML 里,省掉这趟客户端请求。
图片格式本身也值得单独说一句。项目里这一年逐步把首屏大图从 JPEG 换成 WebP,同样的视觉质量下体积能压掉三到四成,这部分节省直接反映在"加载耗时"这一段上。<picture> 标签配合多种格式声明,能让不支持 WebP 的老浏览器自动回落到 JPEG,不用在 JS 里判断兼容性:
1<picture> 2 <source srcset="hero.webp" type="image/webp" /> 3 <img src="hero.jpg" importance="high" width="1200" height="600" alt="" /> 4</picture>
响应式图片同样值得配合上——移动端没必要下载桌面端那张 1200 像素宽的原图,srcset 加 sizes 能让浏览器按实际渲染尺寸挑一张合适分辨率的图,减少的不只是"加载耗时",连带首屏总流量也会降下来:
1<img 2 src="hero-800.webp" 3 srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w" 4 sizes="(max-width: 600px) 100vw, 1200px" 5 importance="high" 6 width="1200" 7 height="600" 8 alt="" 9/>
LCP 拆开看:它其实是四段时间加起来的
光知道 LCP 的总值不够,真正定位问题要把这个总时间拆成几段。Google 在文档里把 LCP 拆成四块:TTFB(浏览器发出请求到收到第一个字节)、加载延迟(从有了 HTML 到浏览器发现这个 LCP 资源、真正开始下载之间的空档)、加载耗时(这个资源本身下载花的时间)、渲染延迟(资源下载完到真正绘制到屏幕之间,如果还要等 JS 执行或者字体解析,这里会拉长)。
四段时间里最容易被忽视的是"加载延迟"这一段。如果 LCP 图片的地址是通过一段内联 JS 拼出来的,或者要等一个客户端接口返回才知道该请求哪张图,浏览器的预加载扫描器完全帮不上忙——它只在解析 HTML 文本时才能提前发现资源地址,一旦资源地址被 JS 逻辑遮住,这段本可以并行的加载窗口就被硬生生浪费掉了。同理,把 LCP 图片包在一个懒加载组件里(哪怕这个组件本身逻辑很简单),也会让浏览器多等一轮组件挂载才能拿到真实的 src。这也是为什么首屏关键图片通常建议直接写死在服务端渲染出的 HTML 里,而不是走客户端渲染再补一层图片组件——不是图片组件不好,是这层间接性天然会拉长加载延迟这一段。
TTFB 这一段虽然通常不是前端能单独解决的问题,但也不是完全无关。如果页面走的是服务端渲染,后端接口查询慢、模板渲染耗时长,都会直接体现在 TTFB 上,进而推高整个 LCP。团队里排查过一次某个详情页 TTFB 明显比同类页面高出三百多毫秒,最后定位到是服务端渲染时同步调用了一个第三方推荐接口,接口本身响应不稳定,把这段调用改成异步、先返回页面骨架再补渲染推荐区域之后,TTFB 立刻降了下来——这个例子提醒一件事:LCP 优化不能只盯着前端资源,服务端这一段同样在预算之内。
FID:第一次点下去,跟不跟手
FID(First Input Delay)量的是用户在页面上第一次真实交互(点击、敲键盘、点选下拉框)到浏览器真正开始处理这个事件之间的延迟。Google 给的良好阈值是 100 毫秒以内。
这里有一个经常被搞混的地方:FID 量的不是事件处理函数跑完要多久,而是浏览器主线程被占用、没法立刻响应这次输入的等待时间。造成这段等待的原因几乎总是同一个——页面加载过程中,主线程正在忙着跑一段很长的 JS 任务(可能是框架的初始渲染、一个大依赖的解析执行、一段同步的数据处理),用户偏偏就在这段时间点了一下,只能排队。
这也是为什么 FID 只测"第一次"输入:它专门捕捉的是页面加载早期这段主线程最拥堵的窗口。用户在加载阶段点一下按钮结果没反应,这种体验伤害比加载完之后操作卡顿更强——用户会怀疑是不是没点中,往往会再点一次,甚至怀疑页面是不是坏了。也正因为只测第一次,FID 这个指标天生有个统计上的局限:一个页面哪怕加载阶段主线程堵了三四秒,只要用户凑巧没在这个窗口点任何东西,这次访问就完全不会产生 FID 数据——它衡量的是"运气不好点在了卡顿窗口上的那部分用户",不是"所有用户",看报表时得记住这层取样偏差。
还有一个细节容易被忽略:FID 只统计离散型的输入事件(点击、按键、点选),像滚动、缩放这类连续型手势不计入 FID,即便这些操作同样可能因为主线程繁忙而卡顿。这个边界是刻意划出来的——滚动卡顿属于另一套叫作"平滑度"的体验范畴,浏览器有专门针对合成层滚动的优化路径,和一次点击等待主线程响应的成因不完全一样,两者混在一起统计反而说不清问题出在哪。团队里排查移动端体验时,有一次误把"列表滚动一卡一卡"当成 FID 问题去查长任务,结果长任务确实存在但和滚动卡顿关系不大,真正原因是列表项用了昂贵的阴影效果导致每帧重绘代价高,这类问题该用帧率和重绘耗时去看,而不是长任务和 FID。
排查 FID 问题的核心工具还是长任务(Long Task)观察,但这里的目的和排查滚动卡顿时完全不同——不是找一次性的性能瓶颈,而是专门盯加载阶段:
1new PerformanceObserver((list) => { 2 for (const entry of list.getEntries()) { 3 console.warn('加载阶段长任务', entry.duration, entry.attribution) 4 } 5}).observe({ entryTypes: ['longtask'] })
entry.attribution 这个字段很关键,它能告诉你这个长任务是哪个 iframe 或者哪个来源触发的,不少时候真正的元凶是页面上跑的第三方脚本(统计 SDK、广告脚本、客服插件),而不是自己团队写的业务代码。
排查过程里还发现一个更直接的办法——Chrome DevTools 的 Performance 面板录制页面加载时,长任务会在火焰图上被标红色三角警示,鼠标点进去能展开调用栈,一层层看到具体是哪个函数占用了主线程。比起在控制台里看 entry.attribution 给出的笼统来源,火焰图能精确到某个具体的函数名和文件位置,团队里定位到"某个统计 SDK 在初始化时同步遍历了整个 DOM 树"这类问题,靠的都是这个面板,而不是单纯看长任务的持续时间数字。
优化 FID 本质上是在优化"主线程什么时候能腾出手来"。最直接的手段是把大任务拆小,用 requestIdleCallback 或者干脆用 setTimeout(fn, 0) 把非首屏必需的初始化逻辑往后推,让浏览器有机会插空处理用户输入:
1// 首屏渲染完成后,非关键的第三方脚本、埋点初始化往后让一让 2window.addEventListener('load', () => { 3 setTimeout(() => { 4 initAnalytics() 5 initChatWidget() 6 }, 0) 7})
代码分包上也一样,如果首屏 bundle 里塞了大量非首屏立刻要用的逻辑(比如整个路由表、所有弹窗组件),这些代码的解析和执行会占用主线程,直接推高 FID。把这些拆成按需加载的 chunk,首屏要跑的 JS 变少,主线程空闲的窗口自然变多:
1// 首屏不需要立刻用到的弹窗、编辑器一类,动态引入而不是打进主包 2const RichEditor = () => import('./components/RichEditor.vue')
框架层面还有一层容易被忽视的成本:像 Vue 这类框架在首屏挂载时,要把整棵组件树的响应式数据初始化一遍、把模板编译成的渲染函数跑一遍生成虚拟 DOM、再和真实 DOM 做首次挂载,这一整套叫作"注水"或者初始挂载的过程,本身就是一段不短的同步任务。页面组件树越深、首屏一次性挂载的组件数量越多,这段任务就越长,恰好和用户急着点导航、点按钮的时间窗口重叠。缓解办法除了前面说的分包,也可以考虑把首屏非核心区域(比如页面下半部分的推荐模块)延后一拍再挂载,而不是要求所有组件在同一个同步任务里一次性初始化完。
第三方脚本这一块单独拎出来说一句,因为它经常是团队自己完全没意识到的成本。中后台系统接的统计 SDK、客服插件、A/B 测试脚本,这些代码不受团队掌控,体积和执行时机都不透明。给这类脚本加上 defer 或者干脆把 <script> 标签移到 body 末尾只是第一步,更彻底的做法是用 requestIdleCallback 包一层,把初始化推迟到浏览器真正闲下来的时候:
1function loadWhenIdle(src) { 2 const start = () => { 3 const s = document.createElement('script') 4 s.src = src 5 s.async = true 6 document.body.appendChild(s) 7 } 8 if ('requestIdleCallback' in window) { 9 requestIdleCallback(start, { timeout: 3000 }) 10 } else { 11 setTimeout(start, 1000) 12 } 13} 14loadWhenIdle('https://cdn.example.com/analytics.js')
timeout 参数是个保险——如果浏览器一直没有空闲窗口,超过这个时间也会强制执行,不至于因为主线程一直很忙就永远不加载这段脚本。
FID 和长任务之间,还有一层排队顺序的讲究
同样时长的长任务,摆在页面加载的不同位置,对 FID 的影响并不一样。如果两个各占 150 毫秒的长任务紧挨着排在一起,用户点击如果落在这 300 毫秒的区间里,等待时间可能接近 300 毫秒;但如果把其中一个任务用 requestIdleCallback 移到另一个任务结束之后空出一个间隙再执行,哪怕总的 JS 执行时间完全没变,用户点在那个间隙里就能被立刻响应。也就是说,优化 FID 有时候不需要真的减少代码量,只需要在长任务之间人为切出喘息的缝隙,把原本连续占用的主线程时间打散。这也是分片执行(把一个大任务拆成多个通过 requestIdleCallback 或 setTimeout 调度的小任务)在 Core Web Vitals 语境下的价值——它不是为了让总耗时更短,是为了让主线程更频繁地把控制权交还给浏览器。
分片执行落到代码里,最简单的做法是把一个大循环切成一批批小任务,每批跑完之后主动让出主线程,而不是一口气跑完:
1function processInChunks(items, chunkSize, handler) { 2 let index = 0 3 function runChunk() { 4 const end = Math.min(index + chunkSize, items.length) 5 for (; index < end; index++) { 6 handler(items[index]) 7 } 8 if (index < items.length) { 9 // 每处理完一批,主动让出主线程一次 10 setTimeout(runChunk, 0) 11 } 12 } 13 runChunk() 14} 15 16// 首屏加载完之后,批量初始化一堆埋点监听器,不再一次性跑完 17processInChunks(trackingTargets, 20, bindTrackingListener)
这段代码不复杂,但它体现的是一个和"减少代码量"完全不同的优化思路:总的 JS 执行时间可能一点没少,甚至因为 setTimeout 本身的调度开销还略微增加了,但主线程在每一批之间都空出了一个可以响应用户输入的窗口,FID 反而会明显改善。这也是为什么排查 FID 时,看长任务的总时长意义有限,更该看的是这些长任务之间有没有留出喘息的缝隙。
还有一种情况值得单独说:如果长任务发生在一个跨域 iframe 里(比如页面上嵌的一个广告位、一个第三方登录组件),主页面这边的 PerformanceObserver 是看不到 iframe 内部具体在跑什么代码的,只能看到这个长任务的来源标注成了某个 iframe。这种情况下没法直接优化 iframe 内部的代码,能做的通常是评估这个第三方组件是不是必须在首屏就加载,能不能推迟到用户滚动到对应区域附近再动态插入,把这段风险隔离在首屏交互窗口之外。
CLS:内容会不会毫无预兆地跳
CLS(Cumulative Layout Shift)量的是页面加载和使用过程中,可见内容意外发生位移的累积程度。它和前两个指标不一样,不是一个时间值,而是一个通过几何计算得出的分数,阈值是 0.1 以内为良好。
CLS 的计算方式值得说一说,因为它直接决定了"多大的跳动才算数":每次布局偏移发生时,浏览器会用"影响分数"(这块区域移动前后占视口面积的比例)乘以"距离分数"(移动的距离占视口最大尺寸的比例),两者相乘得到这次偏移的分数,加总起来就是 CLS。这意味着,一块小元素移动一点点、和一大块内容整体挪了一屏,对分数的贡献天差地别——真正伤害用户体验的往往是后者。
这里有个反直觉的地方:不是所有的布局偏移都会被计入分数。如果偏移是用户主动触发的——比如点了一个"展开更多"的按钮,导致下面内容被撑开——浏览器认为这是用户预期之内的结果,不计分;只有那种用户完全没有操作、内容却自己动了的情况,才会累加进 CLS。判断"是不是用户触发"的窗口很短,通常是最近一次输入之后的 500 毫秒以内,超过这个窗口的偏移,哪怕看起来和之前的操作有关联,也会被计入。这个细节意味着,如果一个交互会导致内容变化,最好让这个变化在用户操作后的瞬间就完成,拖得越久,越可能被误判成"意外偏移"。
排查过一个具体的例子:详情页有个"加入购物车"按钮,点击后会在按钮上方弹出一条库存提示,这条提示是异步请求库存接口之后才决定要不要显示。库存接口正常情况下两三百毫秒就能返回,落在 500 毫秒窗口以内,不会计入 CLS;但如果接口偶尔慢一点,超过 500 毫秒才把提示插进 DOM,同样的交互反而被判定成了意外偏移。这类问题在实验室环境里几乎测不出来,因为本地接口通常很快,只有真实用户网络波动时才会暴露,也是前面提到的实验室数据和真实用户数据经常对不上的又一种成因。后来的处理办法是给这条提示预留一个固定高度的占位区域,不管接口多久返回,布局都不会因为它的插入而变化,从根上避免了这类"看运气"的判定结果。
最典型的 CLS 元凶是图片和广告位没有预留尺寸。图片加载完之前浏览器不知道它的宽高,只能先按 0 尺寸处理,等图片下载完之后再撑开,后面的内容跟着被顶下去——用户正准备点的按钮,可能就在这一下被推到了别处。修法很直接,提前声明尺寸,让浏览器在图片下载完之前就把位置占好:
1<img src="banner.jpg" width="1200" height="400" alt="" />
不确定具体像素时,aspect-ratio 同样能锁住比例,不需要精确到像素:
1.cover { 2 width: 100%; 3 aspect-ratio: 3 / 1; 4}
第二个常见元凶是异步插入的内容,典型的是页面顶部的通知条、Cookie 提示、活动广告——这些东西经常是接口返回之后才决定要不要插进 DOM 里,一旦插入,下面所有内容集体往下挪一截。处理办法是提前用固定高度的占位容器把这块位置卡住,不管最终插不插内容,布局都不会因为它的出现或消失而变化:
1/* 不管公告最终有没有内容,这块高度先占住 */ 2.announcement-slot { 3 min-height: 48px; 4}
第三个是自定义字体引起的位移,这一点和 LCP 那节提到的字体加载策略是同一件事的两面:font-display: swap 优化了 LCP,但换字体的那一刻,如果 fallback 字体和目标字体的字宽差异较大,文字块的实际占位会跟着变,导致下方内容跟着跳。缓解办法是用 size-adjust、ascent-override 这类 @font-face 描述符,把 fallback 字体的度量值调整到和目标字体接近,减小切换瞬间的尺寸差:
1@font-face { 2 font-family: 'BrandSans-fallback'; 3 src: local('Arial'); 4 size-adjust: 105%; 5 ascent-override: 90%; 6}
第四个容易被忽略的是动画本身写法不对。用 top、left、margin 这些几何属性做动画,每一帧都在改变布局,如果动画范围覆盖了别的内容,会被计入 CLS;换成 transform: translate() 做同样的位移效果,视觉上一样,但它走的是合成层,不影响文档流布局,不会被计入 CLS。
第五个是单页应用里路由切换带来的偏移,这一点在传统多页站点上完全不存在,是这一年做中后台和内容站点都要额外注意的新坑。路由切换时,如果新页面的骨架结构和上一个页面差异很大——比如从一个没有侧边栏的落地页跳到一个带侧边栏的详情页——切换瞬间主内容区域的宽度会重新计算一次,文字重新换行,画面上会有一次肉眼可见的"抖一下"。这类偏移出现在路由切换的瞬间,不容易在首次加载的性能报告里看到,得单独在路由切换后也测一遍 CLS 才会发现。处理办法通常是让页面级的骨架容器(侧边栏宽度、顶部导航高度)在路由切换前后保持不变,只替换容器内部的内容,避免整个页面骨架跟着重新布局。
第六个是吸顶导航、悬浮客服按钮这类固定定位元素的动态显隐。页面往下滚动时把顶部导航从静态定位切换成 position: fixed 吸顶,如果切换的那一刻导航栏本身占的文档流高度也跟着变化(比如从占位的 relative 高度突然变成脱离文档流的 fixed),底下的内容会跟着往上顶一截,这个偏移虽然常常很小,但发生在用户正在滚动浏览的过程中,格外容易被注意到。处理办法是让吸顶导航一直占着一个固定高度的占位容器,不管它当前是静态还是吸顶状态,容器本身的高度不变,切换的只是内部元素的定位方式:
1.nav-placeholder { 2 height: 56px; /* 不管导航是否吸顶,占位高度保持不变 */ 3} 4.nav-fixed { 5 position: fixed; 6 top: 0; 7 height: 56px; 8}
排查 CLS 具体是哪个元素造成的,同样有专门的观察手段,不用靠肉眼盯着页面反复刷新去猜:
1new PerformanceObserver((list) => { 2 for (const entry of list.getEntries()) { 3 if (!entry.hadRecentInput) { 4 console.log('意外偏移', entry.value, entry.sources) 5 } 6 } 7}).observe({ type: 'layout-shift', buffered: true })
entry.hadRecentInput 直接对应前面说的"500 毫秒窗口"判断,是浏览器帮你算好的结果,不用自己再判断这次偏移是不是用户主动触发的;entry.sources 里则列出了具体是哪几个 DOM 节点参与了这次偏移,包括它们偏移前后的矩形位置,比自己肉眼对着录屏一帧帧找准得多。DevTools 的 Performance 面板同样支持——录制时如果发生了布局偏移,时间轴上会有一条红色的 Layout Shift 标记,点开能看到具体偏移了多少、涉及哪些节点,这一点和排查长任务时用的是同一个面板,团队里养成的习惯是性能问题优先打开这个面板录一遍,很多时候 LCP、FID、CLS 三类问题会在同一次录制里一起暴露出来。
三个指标各自的采集逻辑讲完了,落到实际项目里还有一步容易被忽视:数据攒起来之后怎么归纳出结论,而不是只在后台看到一堆离散的数字。Core Web Vitals 官方建议按第 75 百分位数(P75)去看,而不是看平均值——平均值很容易被少数网络很差的用户拉得很难看,也很容易被大多数正常用户冲淡真实的糟糕体验;P75 的意思是"四分之三的用户体验不比这个差",一个页面如果 P75 的 LCP 都能进 2.5 秒以内,说明绝大多数用户的体验有保障,个别极端值不必过度纠结。
分设备类型看同样重要。同一个页面,桌面端和移动端的 Core Web Vitals 表现经常是两个世界——移动端 CPU 弱、网络更容易波动,LCP 和 FID 双双变差是常态。如果监控数据只按整体聚合,移动端的问题很容易被桌面端的好数据平均掉,看起来"总体还行",实际上占大头的移动用户体验一直不好。真正要看的是分设备、分页面模板(首页、列表页、详情页各自的骨架结构不同,指标表现也会不同)之后的 P75,而不是一个笼统的全站均值。
除了分设备,分浏览器版本这一层也值得留意。团队里统计过一次,同一批用户里用较旧安卓系统自带浏览器访问的比例不算低,这批用户的 CLS 明显比用新版 Chrome 的用户差一截——排查下来是旧内核对 aspect-ratio 支持不完整,图片占位失效,退化成了老问题。如果监控数据不拆开看内核版本,这批用户的糟糕体验很容易被主流版本的正常数据盖过去,指标好看,但实际有一部分用户完全没享受到优化效果。看数据不能只看均值,也不能只按一个维度切,得多切几刀才能看到真正卡住某一批用户的地方。
拿什么工具量,以及本地分数和真实用户的差距
本地跑 Lighthouse(DevTools 里的 Audits 面板改名成的那个)能拿到一次"实验室数据"(Lab Data),它在一台配置固定、网络条件模拟固定的机器上跑一次页面加载,给出 LCP、CLS 的估算值,还有一个和 FID 接近但不完全一样的模拟指标——TBT(Total Blocking Time,总阻塞时间)。FID 依赖真实用户交互才能测出来,实验室环境没有真人点击,只能用 TBT 这个"主线程被长任务占用的总时长"去近似估计交互会有多卡。
实验室数据的价值在于可复现、可对比——同一台机器、同一份脚本,改完代码跑一遍分数就能看到差异,很适合平时开发调试、上线前自查这类场景。但它天然有几个局限:Lighthouse 默认模拟的是一台中低端安卓机加节流过的 4G 网络,这个设定和团队里配的高配笔记本、公司内网带宽相去甚远,本地跑出来的分数往往比真实用户能拿到的好看不少;而且它只测一次页面加载,覆盖不到"用户在某个网络波动的地铁上打开页面"这类真实场景里天天发生的情况。
正因为实验室数据可复现,团队后来把它接进了持续集成流程,而不是只靠开发自己上线前手动跑一次。Lighthouse CI 这个官方配套工具能在每次提交时自动跑一遍审计,把这次的分数和历史基线比较,如果 LCP 或者 CLS 明显退步,就在流水线里直接标红,逼着改动者在合并之前处理,而不是等上线之后才从 CrUX 数据里发现问题:
1# lighthouserc.yml 里配置的性能预算,超出阈值流水线直接失败 2assertions: 3 "categories:performance": ["error", { "minScore": 0.85 }] 4 "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }] 5 "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
这套自动化流程解决的是另一类问题:真实用户的 CrUX 数据反馈周期是以天甚至周为单位的,等团队从生产环境的监控数据里发现某次发布让 LCP 变差了,往往已经过去好几天,中间受影响的用户体验已经既成事实。Lighthouse CI 把检查提前到代码合并这一步,虽然测的是实验室数据、不能完全代替真实用户监控,但能把明显的性能退化挡在上线之前,两套机制配合起来,一个管日常兜底,一个管长期真实趋势,谁都替代不了谁。
但实验室数据终究是单次、单机跑出来的,不代表真实用户的分布。Google 有一个免费的公开数据集——Chrome UX Report(简称 CrUX),它收集了海量真实 Chrome 用户在实际访问各个网站时上报的体验数据,按 75 分位数汇总,能查到某个具体网址过去 28 天里 LCP、FID、CLS 的真实分布,而不是某一次实验室测量。CrUX 的数据颗粒度是按具体 URL 或者整个 origin 两种维度提供的,如果网站流量不够大、单个页面的样本量不足以支撑统计意义,CrUX 只会返回 origin 级别的汇总,这也是为什么有些流量不大的详情页在 PageSpeed Insights 里查不到"真实用户体验报告"这一块——不是工具坏了,是这个页面本身样本不够。
CrUX 数据里还能按有效连接类型(4G、3G 这类)拆开看,这一层拆分对国内业务格外有意义。团队产品线里有一部分用户来自网络基础设施相对薄弱的地区,访问电商详情页时实际落地的连接类型不少是被判定为"3G"甚至更差,这批用户即便设备本身不差,也会因为网络环节被拖累。如果只看整体聚合的 CrUX 数据,可能会得出"页面表现良好"的结论,但一旦按连接类型拆开,会发现这批网络较差地区用户的 LCP 普遍超过 4 秒,早就落到了"差"的区间。这类问题不是靠优化图片体积能完全解决的,往往需要考虑针对弱网用户单独降级一套更轻量的资源版本,或者干脆先判断网络类型再决定要不要加载首屏大图。
PageSpeed Insights 这个网页工具会同时展示两块数据——上面是 CrUX 来的"真实用户体验报告",下面才是 Lighthouse 跑出来的实验室分数,两者经常对不上:实验室分数很好,真实用户数据却很差,通常说明问题出在实验室环境没有覆盖到的场景,比如低端设备、弱网、或者某个特定地区的 CDN 节点延迟。团队里查过一次首页,本地 Lighthouse 的 LCP 稳定在 1.8 秒左右,看起来很健康,但 PageSpeed Insights 上的 CrUX 数据显示真实用户的 LCP 只有 58% 落在"良好"区间,中间那段差距最后定位到是三四线城市用户命中的 CDN 节点延迟明显偏高,这类问题在公司内网测试环境下完全测不出来。
CrUX 除了嵌在 PageSpeed Insights 里,也有独立的公开 BigQuery 数据集和一个可视化的 CrUX Dashboard,如果需要跟踪自己站点历史几个月的真实用户体验趋势、而不是只看最近 28 天的快照,这两个入口比反复手动查 PageSpeed Insights 更适合。
如果网站需要持续监控而不是偶尔查一次,web-vitals 这个 Google 官方维护的库把三个指标的采集逻辑封装好了,不需要自己手写 PerformanceObserver 的边界情况处理:
这一年 web-vitals 还是 2.x 版本,三个指标对应的方法名是 getLCP、getFID、getCLS——都是"get 前缀 + 指标名"这种命名风格,调用方式是传入一个回调函数,指标计算完成后由库内部反过来调用这个回调,而不是返回一个 Promise 直接拿到值:
1import { getLCP, getFID, getCLS } from 'web-vitals' 2 3function report(metric) { 4 const body = JSON.stringify({ 5 name: metric.name, 6 value: Math.round(metric.value), 7 id: metric.id, 8 path: location.pathname, 9 }) 10 navigator.sendBeacon('/rum', body) 11} 12 13getLCP(report) 14getFID(report) 15getCLS(report)
sendBeacon 比普通 fetch 更适合这类上报场景,页面卸载时它依然能把数据可靠地发出去,不会像 fetch 那样容易被导航打断。真正上线时还要考虑采样比例,不必每个用户都全量上报,否则日志量会失控。
这套采集脚本落到具体项目里,还得多考虑几层实际问题。第一层是采样:全量上报在中大流量站点上很快就会把日志系统打爆,通常按用户维度做一个稳定的采样判断,同一个用户在一段时间内要么全上报要么全不报,而不是每次请求都掷一次骰子,否则同一个用户不同页面的数据没法拼成完整的会话链路:
1const SAMPLE_RATE = 0.1 2const shouldSample = () => { 3 let flag = sessionStorage.getItem('cwv-sample') 4 if (flag === null) { 5 flag = Math.random() < SAMPLE_RATE ? '1' : '0' 6 sessionStorage.setItem('cwv-sample', flag) 7 } 8 return flag === '1' 9} 10 11if (shouldSample()) { 12 getLCP(report) 13 getFID(report) 14 getCLS(report) 15}
第二层是区分设备和网络类型一起上报,不然拿到的只是一个笼统数字,没法定位是哪一类用户体验差。navigator.connection(Network Information API,虽然 Safari 一直没支持,但 Chrome 系可以拿到)能读出当前网络类型,一并塞进上报体里,后台统计时就能按网络类型分组看数据:
1function report(metric) { 2 const connection = navigator.connection || {} 3 const body = JSON.stringify({ 4 name: metric.name, 5 value: Math.round(metric.value), 6 id: metric.id, 7 path: location.pathname, 8 effectiveType: connection.effectiveType || 'unknown', 9 }) 10 navigator.sendBeacon('/rum', body) 11}
第三层是 getLCP、getFID、getCLS 这几个回调的触发时机不完全一样——CLS 会在页面生命周期内多次触发(因为累积偏移可能持续发生),web-vitals 默认只在页面隐藏或卸载时上报最终值,如果需要更及时地看到中间变化,可以传入 { reportAllChanges: true } 选项,但这样上报频率会明显增多,一般只在调试阶段临时开启,正式上线时用默认行为即可。
三个指标合在一起看,而不是孤立优化
三个指标之间有一些容易被忽视的相互牵扯。比如给 LCP 图片加 importance="high"(前提是申请到了 origin trial 试用资格,或者只是在开了对应 flag 的 Chrome 里做实验)确实能让它更早到位,但如果同时有好几个资源都标成高优先级,相当于谁都没有被优先,不但没有缩短 LCP,反而可能因为带宽被分薄而变慢——importance 是用来纠正相对优先级的,不是把所有东西都调高,这一点等它明年转正、能在生产环境放心使用之后同样成立。
再比如,为了压低 FID 而把大量逻辑都推迟到 requestIdleCallback 里执行,如果这些逻辑本身会往 DOM 里插入内容(而不是纯计算),推迟执行反而可能让内容在用户已经开始浏览之后才出现,变相制造了新的布局偏移,把 FID 的收益转嫁成了 CLS 的负担。这三个指标不是各自独立的任务清单,调整一个的加载时机,经常会影响另外两个,优化时得把三者放在同一张时间线上一起看,而不是各自单独调到最优。
还有一组牵扯发生在图片和字体之间,这是团队在申请到的 origin trial 试用环境里做实验时观察到的。为了让 LCP 图片更快显示,试验页面上一度把首屏所有图片都加上 importance="high",结果字体文件反而被挤到了资源队列后面,字体没到位之前文字先用系统字体顶着,等自定义字体加载完再切换,这一下切换刚好把旁边刚显示出来的图片挤歪了一点,CLS 跟着抬头。后来把优先级策略调整成:只给判定为 LCP 元素的那一张图标高优先级,字体文件单独用 <link rel="preload" as="font" crossorigin> 提前声明,两者都不抢对方的资源位,这一处调整之后三个指标才算真正一起达标,而不是拆开看各自达标、合在一起互相拖后腿。这套结论目前只在试用范围内的页面上验证过,等 importance 正式落地、可以铺到全部生产页面时还需要再复核一遍。
排查这类相互牵扯的问题,比较实用的做法是把 Performance 面板的一次完整录制当成排查起点——同一份火焰图上能同时看到长任务的红色三角、LCP 的标记线、Layout Shift 的红色标记,三者在时间轴上的先后顺序一目了然,比分别用三段 PerformanceObserver 代码各自去看更容易发现"哪个操作同时影响了不止一个指标"。
还有一点容易被误解:Core Web Vitals 只是 Google 搜索排名信号里很小的一个维度,内容相关性、原创性这些权重远比它大,把一个内容欠缺的页面死磕出满分性能,排名也不会有明显起色。它更实际的价值,是提供了一套有明确阈值、能跨站点横向比较的用户体验语言——以前说"这个页面感觉有点慢",现在能落到"LCP 3.2 秒、CLS 0.18"这样具体的数字上,团队内部沟通优化优先级时,终于有了共同的刻度。
年底这几周把这套采集脚本铺到了几个流量比较大的页面模板上,P75 的数字比想象中更能说明问题——列表页的 LCP 一直卡在 2.8 秒左右,比首页差了将近一秒,追下去才发现是列表页的骨架屏图片一直没加尺寸声明,属于前面提到的最典型元凶,只是这块页面模板一直没被单独测过,问题才拖到现在才被看见。这也是分模板看数据的价值所在:三个指标本身不难理解,难的是把它们真正铺到项目里每一类页面上去看,而不是只在首页测一次就当作全站达标。
指标之外,还有一层"用户没等到内容就走了"的盲区
铺开监控之后还发现一件容易被忽视的事:三个指标衡量的都是"用户已经打开这个页面、并且留下来跟它交互"这个前提下的体验,如果用户在页面还没画出内容之前就因为等太久直接离开了,这次访问不会产生完整的数据——web-vitals 的回调是在页面隐藏或卸载时才触发上报,一个提前跳出的用户,可能连累积到一半的 CLS 值都不会被上报。
这意味着后台看到的 Core Web Vitals 数据,天然偏向"愿意等到页面基本加载完"的那部分用户,跳出率高的页面反而可能在指标上显得"还不错",因为体验最差、等到直接关闭页面的那批用户根本没留下数据。团队后来把跳出率和 Core Web Vitals 放在同一张报表里对照看,才发现有个别落地页 LCP 的 P75 好看得不正常,一查访问量和跳出率才知道大部分用户等了两三秒看不到内容就直接划走了,留下来测出 LCP 的都是网络条件本来就不错的那一小撮。这条经验后来被写进了排查清单:看 Core Web Vitals 报表时,要拉上跳出率和会话时长一起看,单独看指标好看,不代表用户体验真的好。
采集之外,团队约定成什么样才算落地
指标和采集手段讲完了,真正让这套东西在项目里持续发挥作用的,是团队围绕它形成的几条具体约定,而不只是接了一段监控代码。第一条是把 LCP、FID、CLS 的 P75 门槛写进了新页面上线前的检查清单,和 lint、单元测试放在同一个流程里,而不是上线之后才想起来测一下。第二条是新增图片、字体、异步模块这几类容易引发问题的改动,评审时明确加了一句"这处改动有没有预留尺寸/有没有阻塞关键资源",把前面这些具体手段变成评审时能对照的问题,而不是指望每个人凭记忆想起来。第三条是每个季度抽一次时间,把几个核心页面模板的 CrUX 数据整体过一遍,而不是等某个具体页面被用户投诉了才去查——性能这类问题往往是慢慢劣化的,等到被投诉时通常已经拖了不短的时间。
这几条约定单独看都不复杂,但拼在一起,能把这三个指标从"评审时偶尔提一句"变成检查清单和评审流程里可以直接对照的具体条目。