把 Web 性能理解成「把 JS 包减到 100KB」或「Lighthouse 再涨 10 分」,会在真实用户时间上立刻失效。Core Web Vitals 量的是用户能感到的三种等待:看见(LCP)、点了之后下一次绘制(INP)、布局跳(CLS)。三者独立。压了 bundle,LCP 仍可能被一张没设优先级的图拖死;主线程空了,CLS 仍可能被晚到的字体和广告撑开。优化要对着指标的定义下手,对着实验室分数下手会优化到一个不存在的用户。
这篇文章只讲定义、测量、以及 Next/React 里最常见的凶手。读完你应该能独立回答三个问题:这一页的 LCP 元素到底是哪一块像素、INP 的 200ms 里三段时间各是什么、为什么 next/font 能压 CLS 却可能伤 LCP。
1. 指标是定义,不是分数
实验室(Lighthouse、本地 Performance 面板)是一台空闲机器、固定视口、冷缓存或热缓存你说了算。线上 RUM(CrUX、web-vitals、平台 Analytics)是真用户的网络、CPU、视口、扩展程序。以 RUM 的 P75 为门禁,实验室用来找原因。两者打架时信 RUM,用实验室解释 RUM。
阈值(作为工作目标,不是道德):LCP P75 < 2.5s,INP P75 < 200ms,CLS P75 < 0.1。不到阈值的页面,先不要为包体积开新的构建插件。到了阈值的页面,再谈微优化。
FID 已经退出 Vitals。还在用 FID 做看板的,换 INP。FID 只量第一次输入的延迟,INP 量整个生命周期里最差的那几次交互(取近似最坏)。SPA 里点了十次按钮,FID 可能很好,INP 已经炸了。
2. LCP:最大那一块内容的绘制时间
LCP 记录视口内最大的图、图文块、或视频封面,从导航开始到它的那次绘制。元素可能换:标题先画,大图后到,LCP 元素从 h1 换成 img。最终值以那次最大的为准。优化错对象(把 logo 优化成 LCP)是常见的自我感动。
测量:Performance 面板看 Largest Contentful Paint 标记;RUM 里上报 entry.url / element 选择器。先问「是哪一个节点」,再谈怎么快。
构成大约是:TTFB + 资源加载 + 渲染。TTFB 慢:源站、没有 Full Route Cache、没有 CDN、SSR 等数据库。资源慢:LCP 图没有 priority、走了懒加载、域名未预连、体积按 3x 图发给 1x 屏。渲染慢:CSS 阻塞、字体未就绪用了不可见文本、主线程被 hydration 占满到画不出那一帧。
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.jpg"
alt=""
width={1280}
height={720}
priority
sizes="100vw"
/>
);
}
priority 等价于高优先级 preload,并关掉懒加载。LCP 图必须有。非 LCP 图不要 priority,否则和 LCP 抢带宽。sizes 写错成 100vw 而实际只有 400px 宽,浏览器会下过大的图——LCP 是解码和传输的函数,不是「用了 next/image」的布尔。
fetchpriority="high" 给原生 <img> 同样的信号。背景 CSS 图很难成为可预测的 LCP 优化对象:发现晚、优先级低。首屏大图用 <img> / next/image,不要用 CSS background-image。
3. INP:到下一帧绘制,不是到事件回调
一次交互的 INP 样本 = 输入延迟 + 处理时间 + 呈现延迟。点按钮到视觉反馈的那一帧。回调跑完但没改 DOM、或改了 DOM 却被长任务挡住没画,INP 仍然差。优化 onClick 里的 setState 不够,还要问这一帧有没有画出来。
长任务(>50ms,LoAF 更细)是主线程的病。来源:hydration、一次 filter 大列表、同步 JSON.parse 大包、第三方脚本、CSS-in-JS 运行时、无虚拟化的表格。拆分:
- 把非紧急更新丢进
startTransition,让输入保持紧急。 scheduler.yield()或把工作切到requestIdleCallback/ Worker。Worker 碰不了 DOM,只能算。- 减少 hydration 量:RSC 少下水合子树,客户端叶子变小。
"use client";
import { startTransition, useState } from "react";
export function Filter({ all }: { all: Item[] }) {
const [q, setQ] = useState("");
const [shown, setShown] = useState(all);
return (
<>
<input
value={q}
onChange={(e) => {
const v = e.target.value;
setQ(v);
startTransition(() => setShown(all.filter((x) => x.name.includes(v))));
}}
/>
<List items={shown} />
</>
);
}
输入的 setQ 紧急,过滤在过渡里。没有过渡时,一次按键要等 filter 完才显示字符,INP 和体感都差。列表本身还要虚拟化,否则呈现延迟在 layout 上。
第三方脚本(分析、聊天、A/B)默认在主线程。strategy="lazyOnload"、worker 包装、或干脆晚到交互之后再加载。第一屏就 hydrate 聊天 widget,是买 INP 事故。
4. CLS:意外位移的加权和
一次位移分数 ≈ 影响面积比例 × 移动距离比例。累计是页面生命周期里意外位移之和。用户点击引起的位移可以豁免(在 500ms 左右的窗口内)。广告在已渲染内容上方插入、图片没有宽高、字体 swap 把文字撑开,是三大来源。
<Image src={src} alt="" width={640} height={360} />
width/height 或 CSS aspect-ratio 给浏览器占位。响应式图用 sizes + 同一比例。广告位写死最小高度。不要在已有内容上方插公告条——插下面,或预留那一条的高度。
字体:next/font 把字体文件打进自有源,并用 size-adjust / fallback 度量对齐,减少 swap 位移。font-display: swap 无度量对齐时,先用后备字体再换成 Web 字体,字宽一变就是 CLS。optional 几乎无 CLS,但慢网络可能永远看不到 Web 字体。next/font 默认在 CLS 和展示之间取了工程甜点,不要随手改成 swap 还关掉调整。
暗色主题闪白也是 CLS / 观感问题:第一帧亮、水合后暗。主题脚本放 <head>,见样式那篇。
5. 实验室 vs RUM:两套数对不上时怎么办
实验室用模拟 4G、慢 CPU,容易把 TTFB 和 LCP 放大。RUM 含缓存命中的回访,P75 可能比实验室好看。对不上时:
- 按路由切 RUM,不要全站一个数。
/blog/slug和/checkout的 LCP 元素根本不同。 - 按设备切。移动 P75 不达标、桌面达标,优化对象是移动 LCP 图和 TTFB,不是桌面 bundle。
- 看导航类型。
back_forward/reload/navigate。Router Cache 让回退很快,RUM 会被这些样本拉好——那不代表落地页好。落地页看navigate。 - 实验室关扩展、关 React Scan、用生产构建。
next dev的数字没有资格进讨论。
web-vitals 上报要带:路由、LCP 元素类型、INP 事件类型(click / key)、attribution(INP 的长任务脚本 URL)。没有 attribution 的看板只能吓人,不能指文件。
6. Next 里的常见凶手
TTFB / LCP: 整页 await 慢查询,没有 Suspense 流式。首字节等最慢的那个 fetch。洞和流式把壳先吐出去,LCP 如果在壳上(标题、已有的 hero),数字会掉下来。LCP 如果在洞里(用户头像当了最大块——少见但有),流式救不了,要换 LCP 元素或给洞更快的源。
hydration: 根 layout 塞了巨大 'use client' 树,JS 下载+执行挡住交互和绘制。叶子才 client。能 RSC 的图表用静态 SVG 或服务器算好的 path。
图片: 没 priority 的 hero;fill 却父级没有定位和尺寸;GIF 当 hero。用静态图或视频海报。
字体: 多个 next/font 家族各一份,阻塞 CSS。子集化,只引用到的 weight。
中间件: 每个请求跑一堆 JWT 解析和地理库,TTFB 直接加几十毫秒。中间件要短,见 Edge 那篇的短文;长逻辑放 Route Handler。
缓存: 可静态的页 force-dynamic,每次 SSR。LCP 的 TTFB 部分是缓存生命周期那篇的锅,不是图片插件的锅。先问这一页能不能进 Full Route Cache。
7. 流式、Suspense、和 LCP 的关系
流式让已准备好的 HTML 先出管。对 LCP 有用的前提是:LCP 元素在先出的那一段里。整页一个大 await,流式不存在。loading.tsx 是段级 Suspense,fallback 若是骨架,骨架通常不是 LCP 元素(不够大或不是图/文本块的最终形态),最终 LCP 仍等真实内容。骨架能改善体感,不一定改善 LCP。把 hero 放进不 await 的壳,把评论列表放进洞。
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</>
);
}
Hero 里不要再 await 慢接口。评论再慢也不该拖 LCP。这是布局决策,不是微优化。
PPR / Cache Components 的静态壳同理:壳里放 LCP,洞里放个性化。个性化块当 LCP,等于放弃静态壳对 LCP 的帮助。
8. 主线程长任务:hydration、第三方、大渲染
把 Performance 面板的 Main 轨按任务切开。50ms+ 的黄条点进去看栈。
- hydration。 栈在
hydrateRoot/ 组件函数。减客户端树,或延迟 hydrate 折迭区(lazy+ IntersectionObserver)。 - 事件处理。 栈在你的
onClick。拆过渡、虚拟化、Worker。 - 第三方。 栈在
gtag/ 未知 eval。晚加载、代理、删除。 - 布局 thrash。 读几何再写 DOM 循环。批量读、批量写,或不要在渲染期读
getBoundingClientRect。
INP attribution 会告诉你「这次 click 的处理里最长的脚本是谁」。没有这条日志时不要猜是「React 慢」。React 19 的编译器和 use 不会自动删掉你 20MB 的客户端表格。
useTransition 不能把已经同步跑完的 80ms 变快,它只能让这段工作不堵住输入。80ms 的 filter 仍占主线程,INP 的处理段还在。要降处理段,算法和数据量,不是再包一层过渡。
9. 取舍
| 手段 | 帮谁 | 伤害谁 |
|---|---|---|
| 静态 / CDN | LCP-TTFB | 个性化实时性 |
| 流式壳 | LCP(若元素在壳) | 复杂度、洞的 CLS(若未占位) |
priority 图 | LCP | 其它带宽 |
next/font 对齐 | CLS | 可能的 CSS 阻塞 / 字体下载 |
| 少客户端 JS | INP、也帮 LCP 渲染 | 交互必须下沉到叶子 |
| 晚加载第三方 | INP | 分析少样本 |
| 虚拟列表 | INP、内存 | SEO / 无障碍(要补) |
不要用 loading.tsx 盖住整页来「优化 LCP」——用户看见的是骨架,LCP 可能变成骨架或更晚的真内容,分数和体验一起糊。
10. 排障顺序
- 看 RUM 哪个指标炸、哪条路由、哪个设备。 不要一上来
analyze包。 - LCP:确认元素。 图?字?图就 priority+尺寸+格式(AVIF/WebP)+ 别用 CSS 背景。字就字体和 TTFB。TTFB 就缓存层和源站。
- INP:确认事件类型和 attribution。 click 慢看处理器;keydown 慢看每键工作。hydration 阶段的 INP 单独归「首交互」,和「用了十分钟之后的 INP」不是同一个修复。
- CLS:录屏或 layout shift 区域。 图、字体、插入、广告、cookie 条。cookie 条预留高度。
- 实验室复现要用生产构建和类似 CPU 节流。 复现不了就补 RUM attribution,不要改无关代码。
- 改完看同一路由的 P75,不要看一次 Lighthouse。 样本不足时分数乱跳。
next/script 的 beforeInteractive 会挡解析,只给主题脚本这类必须。分析用 afterInteractive 或 lazyOnload。beforeInteractive 塞聊天,LCP 和 INP 一起死。
11. 和包体积的关系
包体积影响:下载时间(弱网 LCP/INP)、解析编译时间(INP、LCP 的渲染段)、hydration 时间。它是原因的一种,不是定义。一份 30KB 的客户端组件若在 onClick 里同步排序 10 万行,INP 照样炸;一份 200KB 但拆在折叠后 lazy 的图表,可能不影响任何 Vitals。
看路由级 bundle(@next/bundle-analyzer),按入口而不是按仓库总量。把 moment 换成 Intl、把巨大图标库换成按图标 import,只在分析器证明它在 LCP/INP 路径上时才是 Vitals 工作。否则它是工程卫生,另开 PR,不要和「修 LCP」混成一次提交。
图片和字体常常比 JS 更重。Vitals 看板如果只盯 JS KB,会错过 2MB 的 hero PNG。资源面板按字节排,比按框架排诚实。
12. 小结
LCP、INP、CLS 是三种等待:像素、CPU 到下一帧、布局稳定。测量先 RUM 后实验室,先元素/事件后手段。Next 里最贵的决策是:LCP 在不在静态壳、客户端树有多大、图和字体有没有占位和优先级。减包是手段之一,不是指标本身。下一篇把前后端放进同一张构建图:Turborepo 缓存的是任务哈希,不是「文件夹在一起」;Nest 和 Next 只共享类型和纯函数,不共享运行时。
7. 深入交互核心:INP (Interaction to Next Paint) 底层三阶段耗时全解剖
在 Google 最新的 Core Web Vitals 规范中,INP 正式取代了 FID(First Input Delay)。FID 仅仅度量了用户第一次点击到浏览器开始处理的微小延迟,无法反映整个应用生命周期内的持续卡顿;而 INP 追踪的是用户在页面停留期间所有关键交互(点击、按键、轻触)的最差 98 百分位延迟。
一次用户交互的完整耗时拆解 (Total INP):
+-------------------------------------------------------------------------+
| [阶段 1: 输入延迟 Input Delay] |
| 用户物理触发事件 -> 必须等待主线程正在执行的重任务结束 (Long Tasks) |
+-------------------------------------------------------------------------+
| [阶段 2: 处理耗时 Processing Time] |
| 开发者编写的 JavaScript 事件监听回调 (onClick) 执行耗时 |
+-------------------------------------------------------------------------+
| [阶段 3: 呈现延迟 Presentation Delay] |
| 浏览器重新进行样式计算 (Recalculate Style)、布局 (Layout) 与合成绘制 (Paint) |
+-------------------------------------------------------------------------+
7.1 使用 Long Animation Frames API (LoAF) 进行工业级根因定位
现代 Chrome 浏览器(123+)引入了革命性的 LoAF API,允许我们直接在前端监控脚本中捕获阻塞超过 50ms 的脚本源文件与具体函数调用栈:
// 生产级 LoAF 性能探针
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 找出导致卡顿的长任务
if (entry.duration > 50) {
console.warn('捕获到长动画帧卡顿:', {
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map((s) => ({
invoker: s.invoker,
sourceURL: s.sourceURL,
executionTime: s.executionTime,
})),
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
通过将 LoAF 监控日志上报至 Datadog、Sentry 或自建 ClickHouse 大数据中枢,工程团队能够以函数级的颗粒度直接揪出拖垮 INP 指标的罪魁祸首,实现精准定点爆破。
8. 自动化流水线保障:Lighthouse CI 与 GitHub Actions 性能守门人
性能优化不是一次性的突击作战,而必须融入持续集成流水线中。通过在仓库中配置 .lighthouserc.json 并设定阈值(例如 LCP < 2.5s, CLS < 0.1, Performance Score >= 95),任何导致关键性能指标劣化的 Pull Request 都会被 CI 自动化检查直接阻断合并,从制度上捍卫极致的性能水准。