把 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. 取舍

手段帮谁伤害谁
静态 / CDNLCP-TTFB个性化实时性
流式壳LCP(若元素在壳)复杂度、洞的 CLS(若未占位)
priority 图LCP其它带宽
next/font 对齐CLS可能的 CSS 阻塞 / 字体下载
少客户端 JSINP、也帮 LCP 渲染交互必须下沉到叶子
晚加载第三方INP分析少样本
虚拟列表INP、内存SEO / 无障碍(要补)

不要用 loading.tsx 盖住整页来「优化 LCP」——用户看见的是骨架,LCP 可能变成骨架或更晚的真内容,分数和体验一起糊。

10. 排障顺序

  1. 看 RUM 哪个指标炸、哪条路由、哪个设备。 不要一上来 analyze 包。
  2. LCP:确认元素。 图?字?图就 priority+尺寸+格式(AVIF/WebP)+ 别用 CSS 背景。字就字体和 TTFB。TTFB 就缓存层和源站。
  3. INP:确认事件类型和 attribution。 click 慢看处理器;keydown 慢看每键工作。hydration 阶段的 INP 单独归「首交互」,和「用了十分钟之后的 INP」不是同一个修复。
  4. CLS:录屏或 layout shift 区域。 图、字体、插入、广告、cookie 条。cookie 条预留高度。
  5. 实验室复现要用生产构建和类似 CPU 节流。 复现不了就补 RUM attribution,不要改无关代码。
  6. 改完看同一路由的 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 自动化检查直接阻断合并,从制度上捍卫极致的性能水准。