楼层: 首页/ 软件技术/ 前端基础/ 第 9 章 Web 性能工程:Core Web Vitals 与关键渲染路径
效

第 9 章 Web 性能工程:Core Web Vitals 与关键渲染路径

Core Web Vitals, Critical Rendering Path & Asset Optimization

性能不是"玄学调优",它有明确的指标、明确的阈值、明确的抓法。Google 把最重要的三个用户体验指标打包成 Core Web Vitals(核心网页指标),它既影响 SEO 排名,也直接决定用户跑不跑。后端同学请注意:这一章一半的工作量在后端和运维身上——首屏 HTML 的响应时间、CDN、压缩、缓存头,前端能做的只是剩下那一半。

论别一上来就优化:先搞清"谁在拖后腿"

性能优化的正确顺序永远是量 → 找到瓶颈 → 改 → 再量。跳过测量直接优化,八成在优化一个根本不重要的东西(比如为了省 3KB 图片折腾半天,结果首屏慢是因为接口 2 秒才返回)。

而且一定要分清两种数据:实验室数据(Lighthouse、DevTools)可复现、能定位到具体文件,但只代表一台"模拟中低端手机";真实用户数据(CrUX、自建 RUM)才是用户实际感受,但很难定位到原因。两者配合用:真实数据告诉你有没有问题,实验室数据告诉你问题在哪。

Core Web Vitals:三个指标,三种"难受"

这三个指标不是随便挑的,它们分别对应用户抱怨最多的三件事:等太久(LCP)、点了没反应(INP)、页面乱跳(CLS)。

阈值以官方文档为准(评分取第 75 百分位,移动端与桌面端分开看)
指标它衡量什么阈值(好 / 待改进)用户的原话
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 ConsoleGoogle 从 Chrome 用户那采集的公开真实数据,不用埋点就能看,但只有月度粒度、且只有流量够大的站点才有数据。

用 web-vitals 上报三个核心指标(约 10 行搞定)

import { onLCP, onINP, onCLS } from "web-vitals"; // web-vitals 4+/5+ function send(metric) { // metric: { name, value, rating, id, navigationType, ... } // rating 已经是 'good' | 'needs-improvement' | 'poor',直接用 navigator.sendBeacon("/rum", JSON.stringify({ name: metric.name, value: Math.round(metric.value), rating: metric.rating, id: metric.id, url: location.pathname, ua: navigator.userAgent, })); } onLCP(send); // 最大内容绘制 onINP(send); // 交互响应 onCLS(send); // 布局偏移(注意:页面隐藏时才该报终值)

顺便提一句: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 的三种加载方式:差别就在"阻不阻塞解析"

<!-- ❌ 同步:下载 + 执行都阻塞 HTML 解析,首屏直接往后拖 --> <script src="/app.js"></script> <!-- ✅ defer:并行下载,等 HTML 解析完、按顺序执行(DOMContentLoaded 前)--> <!-- 现代项目默认选它:有顺序依赖、又要操作 DOM --> <script src="/app.js" defer></script> <!-- ✅ async:并行下载,下载完立刻执行(不保证顺序)--> <!-- 适合独立脚本:统计 SDK、广告、不依赖别的脚本的小工具 --> <script src="/analytics.js" async></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 + 响应式 + 现代格式

<!-- ① 提前建立连接(字体/CDN 域名越多,收益越明显)--> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <!-- ② 首屏关键资源用 preload 提前拉,别等解析到才去请求 --> <link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w" imagesizes="100vw" fetchpriority="high"> <!-- ③ img 自身:宽高防抖动、modern 格式、响应式候选、首屏别 lazy --> <img src="/hero-1280.avif" srcset="/hero-640.avif 640w, /hero-1280.avif 1280w, /hero-1920.avif 1920w" sizes="100vw" width="1280" height="720" fetchpriority="high" decoding="async" alt="首页主视觉"> <!-- ⚠️ 首屏图千万别加 loading="lazy":浏览器会故意延后它,LCP 直接崩 -->

字体的正确姿势:swap + preload + 尺寸兜底

/* font-display: swap —— 先用系统字体渲染,自定义字体到了再换(不挡文字显示) optional 更激进:慢字体直接放弃本次加载,CLS 最小但字体可能不生效 */ @font-face { font-family: "BrandSans"; src: url("/fonts/brand-sans.woff2") format("woff2"); font-display: swap; unicode-range: U+0000-00FF, U+4E00-9FFF; /* 只加载用得上的字集 */ size-adjust: 98%; /* 让回退字体和自定义字体宽度接近,压 CLS */ ascent-override: 92%; } /* 首屏字体也在 head 里提前拉。注意:字体请求必须带 crossorigin, 否则浏览器会当成两个不同资源、白下载两次 */
<link rel="preload" href="/fonts/brand-sans.woff2" as="font" type="font/woff2" crossorigin>
  • 后端/运维也能做的 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(叫长任务),用户在那一刻的点击就得排队等它干完。

INP 差的四大元凶与对应解法
元凶怎么治
大 JS 包一次性执行路由级代码分割(lazy + Suspense / 动态 import()),第三方 SDK(统计、客服、地图)延迟到用户真需要时才加载。
渲染大列表不节制虚拟滚动(只渲染视口内的几十行);分页或"加载更多",别一次渲染一万条;用 content-visibility: auto 让屏外内容跳过渲染。
高频事件里干重活输入搜索用 debounce 300ms + 竞态处理;滚动/尺寸监听换成 IntersectionObserver / ResizeObserver;重计算丢进 Web Worker。
大计算阻塞渲染把长任务切片(scheduler.yield() 或 setTimeout),让出主线程;React 里用 startTransition / useDeferredValue 把"不紧急的更新"降级。

把长任务切成小片,让浏览器喘口气

// 老写法:一次循环处理 10 万条,主线程直接锁死几百毫秒 for (const row of rows) heavyCompute(row); // ✅ 写法一:每处理一批就主动让出主线程(scheduler.yield 是新的首选) async function processInChunks(rows, size = 500) { for (let i = 0; i < rows.length; i += size) { rows.slice(i, i + size).forEach(heavyCompute); await (globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0))); // 这一让,用户点击就能插进来,INP 立刻好看 } } // ✅ 写法二:React 里把"打字过滤大列表"标成非紧急更新,输入框先响应 const deferred = useDeferredValue(keyword); const list = useMemo(() => big.filter(x => x.includes(deferred)), [deferred, big]);

路由级代码分割:首屏只下当前页要的 JS

// React 19:lazy + Suspense 按需加载路由组件 import { lazy, Suspense } from "react"; const Settings = lazy(() => import("./pages/Settings")); export function App() { return ( <Suspense fallback={<Skeleton />}> <Settings /> </Suspense> ); } /* 顺手:重依赖也这么处理,别塞进首屏 bundle const Chart = lazy(() => import("./Chart")); // echarts / chart.js 之类 */

CLS 优化:页面别抖

CLS 的产生只有两种情形:① 图片/广告/iframe 没预留尺寸,加载完把下面的内容顶下去;② 动态插入的内容出现在已有内容上方。防御思路就一句话:任何"以后会变大"的东西,先给它占好位置。

/* ① 图片、视频、iframe:显式尺寸或 aspect-ratio,浏览器才能预留空间 */ .hero-img { aspect-ratio: 16 / 9; width: 100%; height: auto; } <!-- 现代浏览器用 width/height 属性就能自动算出比例,别省这两个属性 --> <img src="/cover.jpg" width="800" height="450" alt="封面"> /* ② 广告 / 推荐位:先用 min-height 占坑,内容来了往坑里填,别往上顶 */ .ad-slot { min-height: 250px; contain: layout; } /* ③ 动画只动 transform / opacity(不触发重排,因此不产生 CLS) */ .toast { transform: translateY(8px); opacity: 0; transition: transform .2s, opacity .2s; } /* ④ 内容跳进来前先占位,骨架屏尺寸要和真实内容一致 */ .skeleton { height: 120px; background: linear-gradient(90deg,#eee,#f7f7f7,#eee); }
CLS 三个容易忽略的坑

坑一:用 JS 在页面顶部插入横幅公告或 Cookie 提示。这是最常见的 CLS 来源。要么在 HTML 里就渲染好(哪怕内容是空的、高度先占着),要么用 position: fixed 浮层,别推内容。

坑二:字体切换导致文字重排。回退字体和自定义字体宽度差太多,文字一换行,整段内容跳。用 font-display: swap 配合 size-adjust / ascent-override 把两者的度量对齐。

坑三:懒加载图片没写尺寸。loading="lazy" 只解决"什么时候下载",不解决"占多大地方"。宽高不写,图片一加载完就把底部内容推走,照样扣分。

懒加载与资源提示:把网络请求安排明白

资源提示(Resource Hints)优先级用法对照
写法作用与适用场景
dns-prefetch只做 DNS 解析。开销极小,用于"可能会用到"的第三方域名,比如埋点、支付。
preconnectDNS + TCP + TLS 全做完。用于确定马上要连的关键域名(字体 CDN、API 域名)。别对十几个域名都 preconnect,反而抢带宽。
preload当前页面马上要用的资源提前下载(首屏图、字体、关键 CSS)。用错会抢占带宽拖慢更重要的东西,而且没用上时会触发浏览器警告。
prefetch下一个页面可能会用的资源,最低优先级、空闲时下载。适合"用户八成会点的下一个路由"。
fetchpriority调同一类资源的相对优先级:首屏图 high,轮播里没露脸的图 low。比 preload 更轻量的一招。

懒加载的正确边界:只懒"屏外"的

<!-- 首屏图:不懒加载,反而给高优先级 --> <img src="/hero.avif" fetchpriority="high" width="1280" height="720" alt="主视觉"> <!-- 首屏以下的图:lazy 省流量、加快首屏 --> <img src="/photo.avif" loading="lazy" decoding="async" width="640" height="360" alt="配图"> <!-- 非关键 iframe / 视频同理:省下的都是首屏时间 --> <iframe src="/embed" loading="lazy" title="嵌入内容"></iframe>

更进一步的懒加载用 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 最前面"。