第 9 章 Web 性能工程:Core Web Vitals 与关键渲染路径
性能不是"玄学调优",它有明确的指标、明确的阈值、明确的抓法。Google 把最重要的三个用户体验指标打包成 Core Web Vitals(核心网页指标),它既影响 SEO 排名,也直接决定用户跑不跑。后端同学请注意:这一章一半的工作量在后端和运维身上——首屏 HTML 的响应时间、CDN、压缩、缓存头,前端能做的只是剩下那一半。
论别一上来就优化:先搞清"谁在拖后腿"
性能优化的正确顺序永远是量 → 找到瓶颈 → 改 → 再量。跳过测量直接优化,八成在优化一个根本不重要的东西(比如为了省 3KB 图片折腾半天,结果首屏慢是因为接口 2 秒才返回)。
而且一定要分清两种数据:实验室数据(Lighthouse、DevTools)可复现、能定位到具体文件,但只代表一台"模拟中低端手机";真实用户数据(CrUX、自建 RUM)才是用户实际感受,但很难定位到原因。两者配合用:真实数据告诉你有没有问题,实验室数据告诉你问题在哪。
Core Web Vitals:三个指标,三种"难受"
这三个指标不是随便挑的,它们分别对应用户抱怨最多的三件事:等太久(LCP)、点了没反应(INP)、页面乱跳(CLS)。
| 指标 | 它衡量什么 | 阈值(好 / 待改进) | 用户的原话 |
|---|---|---|---|
| LCP | Largest Contentful Paint:视口内最大那块内容(大图、大标题、视频封面)渲染完成的时间。 | ≤ 2.5s 好 2.5–4.0s 待改进 > 4.0s 差 |
"怎么白屏这么久 / 图半天不出来。" |
| INP | Interaction to Next Paint:从用户点下去到界面给出反馈,全页面交互中最差的那一次(2024 年 3 月起取代 FID)。 | ≤ 200ms 好 200–500ms 待改进 > 500ms 差 |
"按钮点了没反应,是不是卡死了?" |
| CLS | Cumulative Layout Shift:页面加载过程中"元素位置突然跳动"的累计分数。 | ≤ 0.1 好 0.1–0.25 待改进 > 0.25 差 |
"我刚要点,它自己滑走了,点错了。" |
为什么要看第 75 百分位?因为平均值会骗人。100 个人里有 25 个人体验很差,平均下来可能还挺好看,但"四分之一的人想骂人"是产品不可接受的。不要算平均,要算 P75。
怎么量:三套工具,各管一段
| 工具 | 用途与局限 |
|---|---|
| Chrome DevTools Performance / Lighthouse 面板 | 本地诊断首选。Performance 面板能看主线程火焰图、长任务、网络瀑布;Lighthouse 一键出报告。局限:只代表你这一台机器的实验室结果。 |
| PageSpeed Insights | 同时给出 实验室数据(Lighthouse)和 真实用户数据(CrUX),还能看到"机会项"(Opportunities)排序,最省事的入口。 |
| web-vitals 库 + 自建 RUM | 把真实用户指标上报到自己的监控(配 Sentry / 自建埋点),能看到"哪个地区、哪台机器、哪个版本"慢。生产环境唯一能落地的长期方案。 |
| CrUX Dashboard / Search Console | Google 从 Chrome 用户那采集的公开真实数据,不用埋点就能看,但只有月度粒度、且只有流量够大的站点才有数据。 |
用 web-vitals 上报三个核心指标(约 10 行搞定)
顺便提一句:Lighthouse 分数不等于 Core Web Vitals。Lighthouse 里的 TBT(Total Blocking Time)只是 INP 的实验室代理指标,把 Lighthouse 刷到 100 分,真实用户的 INP 也可能一塌糊涂。两套数据不要混着看。
关键渲染路径:从一串字节到一屏像素
想优化首屏,必须先知道浏览器拿到 HTML 之后干了哪几件事。这条链路叫关键渲染路径(Critical Rendering Path)。
论六步走完,页面才可见
① 解析 HTML → DOM 树。边下边解析,遇到 <script>(无 defer/async)会暂停解析去下载并执行,这就是"JS 阻塞解析"。
② 解析 CSS → CSSOM 树。CSS 是渲染阻塞资源:只要有样式表没下载完,浏览器不敢画第一帧(怕闪一下再变),所以首屏 CSS 要小、要内联关键部分。
③ DOM + CSSOM → 渲染树(Render Tree)。只包含可见节点(display:none 的不算)。
④ 布局(Layout / Reflow):算出每个盒子在屏幕上的精确坐标和大小。任何影响几何属性的改动都会触发重排。
⑤ 绘制(Paint):把颜色、阴影、文字画成像素。
⑥ 合成(Composite):把各图层拼起来交给 GPU 显示。只改 transform / opacity 能跳过④⑤只走⑥,这就是"动画要用 transform 别用 top/left"的根本原因。
script 的三种加载方式:差别就在"阻不阻塞解析"
错误一:CSS 放到 body 底部。看起来"先渲染内容"了,实际是连第一帧都不敢画(没有 CSSOM 就没法构建渲染树),还可能 FOUC 闪一下裸样式。CSS 放 <head> 里,想快就把关键 CSS 内联进去。
错误二:给所有 script 都加 async。async 不保证顺序,你依赖的公共库还没加载,业务代码就先跑了 → xxx is not defined。有依赖关系就用 defer,或者干脆用 type="module"(模块脚本默认就是 defer 行为)。
错误三:用 JS 动态插入首屏的关键 CSS/字体。浏览器发现资源晚,等于自己给自己加了一次往返。首屏需要的东西要么写在 HTML 里,要么在 <head> 里用 <link rel="preload"> 提前声明。
LCP 优化:首屏那块大图 / 大标题
LCP 元素通常是首屏的大图或大段标题文字(文字的话,LCP 时间往往被"等字体"拖后腿)。所以 LCP 优化基本就是四件事:少延迟、早发现、小体积、不阻塞。
首屏大图的标准写法:preload + fetchpriority + 响应式 + 现代格式
字体的正确姿势:swap + preload + 尺寸兜底
- 后端/运维也能做的 LCP 优化:HTML 走 CDN 边缘缓存(TTFB 从 500ms 降到 50ms 是质变)、开 Brotli 压缩、HTTP/2 或 HTTP/3 多路复用、图片放在图片 CDN 上自动转 AVIF/WebP 并按需裁剪。
- 架构层面:纯客户端渲染(CSR)的 SPA,首屏要等 JS 下载并执行完才画东西,天生 LCP 差。上 SSR / SSG / 流式渲染,让服务器先把 HTML 吐出来,是最有效的一招。
- 别让首屏等接口:把"首屏必须的数据"在服务端取好直出,剩下的次要模块再客户端异步补,配合骨架屏。
INP 优化:别让主线程堵车
INP 差只有一个原因:主线程在忙,没空搭理你的点击。浏览器主线程既要跑 JS、又要算样式、又要布局绘制,只要有一坨任务跑超过 50ms(叫长任务),用户在那一刻的点击就得排队等它干完。
| 元凶 | 怎么治 |
|---|---|
| 大 JS 包一次性执行 | 路由级代码分割(lazy + Suspense / 动态 import()),第三方 SDK(统计、客服、地图)延迟到用户真需要时才加载。 |
| 渲染大列表不节制 | 虚拟滚动(只渲染视口内的几十行);分页或"加载更多",别一次渲染一万条;用 content-visibility: auto 让屏外内容跳过渲染。 |
| 高频事件里干重活 | 输入搜索用 debounce 300ms + 竞态处理;滚动/尺寸监听换成 IntersectionObserver / ResizeObserver;重计算丢进 Web Worker。 |
| 大计算阻塞渲染 | 把长任务切片(scheduler.yield() 或 setTimeout),让出主线程;React 里用 startTransition / useDeferredValue 把"不紧急的更新"降级。 |
把长任务切成小片,让浏览器喘口气
路由级代码分割:首屏只下当前页要的 JS
CLS 优化:页面别抖
CLS 的产生只有两种情形:① 图片/广告/iframe 没预留尺寸,加载完把下面的内容顶下去;② 动态插入的内容出现在已有内容上方。防御思路就一句话:任何"以后会变大"的东西,先给它占好位置。
坑一:用 JS 在页面顶部插入横幅公告或 Cookie 提示。这是最常见的 CLS 来源。要么在 HTML 里就渲染好(哪怕内容是空的、高度先占着),要么用 position: fixed 浮层,别推内容。
坑二:字体切换导致文字重排。回退字体和自定义字体宽度差太多,文字一换行,整段内容跳。用 font-display: swap 配合 size-adjust / ascent-override 把两者的度量对齐。
坑三:懒加载图片没写尺寸。loading="lazy" 只解决"什么时候下载",不解决"占多大地方"。宽高不写,图片一加载完就把底部内容推走,照样扣分。
懒加载与资源提示:把网络请求安排明白
| 写法 | 作用与适用场景 |
|---|---|
dns-prefetch | 只做 DNS 解析。开销极小,用于"可能会用到"的第三方域名,比如埋点、支付。 |
preconnect | DNS + TCP + TLS 全做完。用于确定马上要连的关键域名(字体 CDN、API 域名)。别对十几个域名都 preconnect,反而抢带宽。 |
preload | 当前页面马上要用的资源提前下载(首屏图、字体、关键 CSS)。用错会抢占带宽拖慢更重要的东西,而且没用上时会触发浏览器警告。 |
prefetch | 下一个页面可能会用的资源,最低优先级、空闲时下载。适合"用户八成会点的下一个路由"。 |
fetchpriority | 调同一类资源的相对优先级:首屏图 high,轮播里没露脸的图 low。比 preload 更轻量的一招。 |
懒加载的正确边界:只懒"屏外"的
更进一步的懒加载用 IntersectionObserver:元素快进视口了才去请求数据 / 挂载组件。React 里很多"无限滚动""图片渐进加载"都是这么实现的,比 scroll 事件监听省得多。
① Core Web Vitals = LCP(等太久)+ INP(点了没反应)+ CLS(页面乱跳),按 P75 评分,好 / 待改进 / 差各有明确阈值。
② 先量再改:实验室数据(Lighthouse / DevTools)定位问题,真实用户数据(CrUX / 自建 RUM)判断影响面。
③ 关键渲染路径:HTML→DOM、CSS→CSSOM(渲染阻塞)、合成渲染树、布局、绘制、合成;script 用 defer/async,CSS 放 head 且尽量瘦。
④ LCP 抓首屏大图与字体(preload + fetchpriority + 响应式 + 别 lazy);INP 抓长任务与代码分割;CLS 核心是"提前占位"。
1. LCP 很差,你会按什么顺序排查?
看答案
先看 LCP 元素是谁(大图?大标题?)→ 再看它的网络瀑布:① 服务器 TTFB 是否过长(后端/CDN 问题);② 资源是否被发现得太晚(该 preload / 放 head);③ 资源是否太大(换 AVIF/WebP、按需裁剪、响应式 srcset);④ 是否被渲染阻塞资源挡住(CSS/同步 JS)。最后检查是不是 CSR 架构导致"等 JS 才画首屏",那就得上 SSR/SSG。
2. INP 和 FID 有什么区别,为什么 FID 被淘汰了?
看答案
FID 只测"第一次交互的输入延迟"——即从点击到浏览器开始处理事件的时间,完全不管后续处理和执行要花多久。INP 测的是整个交互直到界面真正更新的全过程,而且取全页面最差的一次。所以 INP 更接近用户真实感受,也更能反映主线程被长任务阻塞的问题。
3. 图片都加了 loading="lazy",为什么 LCP 反而更差了?
看答案
因为 loading="lazy" 是"延迟加载",浏览器会刻意不在首屏竞速阶段请求它,导致 LCP 图片的发现和下载被推后。规律是:首屏(尤其视口内的主图)绝不 lazy,且要给 fetchpriority="high";只有首屏以下的图才 lazy。
4. 页面用 CSS 动画把按钮从 left: 0 动到 left: 100px,用户反馈"卡"。换成什么就不会卡?为什么?
看答案
换成 transform: translateX(100px)。改 left 会触发布局(重排)→ 绘制 → 合成全流程,每帧都要重算位置,还容易和其他元素互相影响;transform / opacity 只需合成,可以直接交给 GPU 处理,动画就顺了。同时 transform 动画不触发重排,也就不会贡献 CLS。
5. 产品要求页面顶部弹一个"双十一活动"横幅,但测试发现 CLS 超标,怎么改?
看答案
三条路:① 让横幅在 HTML 首屏直出(服务端判断要不要显示),浏览器一上来就给它预留了空间,不会顶内容;② 用 position: fixed / sticky 做成浮层,不参与文档流;③ 在 HTML 里先渲染一个固定高度的占位容器,内容异步填进去。最忌"JS 加载完才 insertBefore 插到 body 最前面"。