跳至内容
·10 分钟阅读

2026 年 Core Web Vitals 实战:到底什么才能真正提升性能

忘掉那些理论吧——这是我在一个真实的 Next.js 生产站点上把 LCP 降到 2.5 秒以下、CLS 归零、INP 控制在 200 毫秒以内的具体做法。不是含糊其辞的建议,而是切实可行的技巧。

分享:X / TwitterLinkedIn

我花了差不多两周时间让这个网站变快。不是那种"在我的 M3 MacBook 上跑 Lighthouse 看起来很快"的快,而是真正意义上的快。在地铁隧道里用一台 150 美元的 Android 手机、连着不稳定的 4G 也能飞快加载。在真正重要的场景下也能快。

最终结果:LCP 低于 1.8 秒,CLS 为 0.00,INP 低于 120 毫秒。三项指标在 CrUX 真实用户数据中全部达标,而不仅仅是实验室跑分。在这个过程中我学到了一点——互联网上大多数性能优化建议,要么过时了,要么太笼统,要么两者皆是。

"优化你的图片"不是建议。"使用懒加载"脱离上下文就是危险的。"减少 JavaScript"显而易见,但并没有告诉你该砍什么

以下是我实际做了什么,按照重要性排序。

为什么 2026 年 Core Web Vitals 仍然重要#

直说吧:Google 把 Core Web Vitals 作为排名信号。不是唯一的信号,甚至不是最重要的信号。内容相关性、外链和域名权重仍然占主导地位。但在边际情况下——当两个页面的内容和权重相当时——性能就是决胜因素。而在互联网上,数百万页面都处于这个边际地带。

但先别管 SEO。真正应该关注性能的原因是用户。过去五年数据变化不大:

  • 53% 的移动端访问会被放弃,如果页面加载超过 3 秒(Google/SOASTA 的研究,至今仍然成立)
  • 每 100 毫秒的延迟大约会损失 1% 的转化率(Amazon 最初的发现,已被多次验证)
  • 经历过布局偏移的用户完成购买或填写表单的概率会显著降低

2026 年的 Core Web Vitals 包含三个指标:

指标衡量内容良好需要改进
LCP加载性能≤ 2.5s2.5s – 4.0s> 4.0s
CLS视觉稳定性≤ 0.10.1 – 0.25> 0.25
INP响应性≤ 200ms200ms – 500ms> 500ms

这些阈值自 2024 年 3 月 INP 取代 FID 以来就没有变过。但达到这些阈值的技术手段已经发生了变化,尤其是在 React/Next.js 生态系统中。

LCP:最重要的那个指标#

Largest Contentful Paint(最大内容绘制)衡量的是视口中最大的可见元素完成渲染的时间。对于大多数页面来说,这个元素是一张主图、一个标题,或者一大段文字。

第一步:找到你真正的 LCP 元素#

在优化任何东西之前,你需要知道什么是你的 LCP 元素。人们通常假设是首屏大图。但有时候是一个 Web 字体渲染的 <h1>,有时候是一个通过 CSS 设置的背景图,有时候是一个 <video> 的封面帧。

打开 Chrome DevTools,进入 Performance 面板,录制一次页面加载过程,然后找到"LCP"标记。它会准确告诉你哪个元素触发了 LCP。

你也可以用 web-vitals 库以编程方式记录:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

在这个站点上,LCP 元素在首页是主图,在博客文章页是第一段文字。两个不同的元素,两种不同的优化策略。

第二步:预加载 LCP 资源#

如果你的 LCP 元素是一张图片,最有效的做法就是预加载它。默认情况下,浏览器在解析 HTML 时才会发现图片,这意味着图片请求要等到 HTML 下载、解析完毕并且走到 <img> 标签之后才会开始。预加载把这个发现时机提前到了最开始。

在 Next.js 中,你可以在 layout 或 page 中添加一个 preload 链接:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

注意 fetchPriority="high"。这是较新的 Fetch Priority API,堪称性能优化的杀手锏。没有它的话,浏览器会用自己的启发式算法来确定资源优先级——而这些启发式算法经常判断失误,特别是当首屏有多张图片的时候。

在这个站点上,给 LCP 图片加上 fetchPriority="high" 后,LCP 降低了约 400 毫秒。这是我见过的改一行代码就能获得的最大收益。

第三步:消除渲染阻塞资源#

CSS 会阻塞渲染。所有 CSS 都会。如果你通过 <link rel="stylesheet"> 加载了一个 200KB 的样式表,浏览器在它完全下载和解析完毕之前不会绘制任何内容。

解决方法有三个:

  1. 内联关键 CSS —— 提取首屏内容需要的 CSS,放到 <head> 中的 <style> 标签里。当你使用 CSS Modules 或正确配置了 purge 的 Tailwind 时,Next.js 会自动完成这一步。

  2. 延迟加载非关键 CSS —— 如果你有针对首屏以下内容的样式表(页脚动画库、图表组件),异步加载它们:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. 移除未使用的 CSS —— Tailwind CSS v4 的 JIT 引擎会自动处理。但如果你引入了第三方 CSS 库,请审查它们。我发现有一个组件库为了一个 tooltip 组件引入了 180KB 的 CSS。用一个 20 行的自定义组件替换掉,省了 170KB。

第四步:服务器响应时间(TTFB)#

如果 TTFB 很慢,LCP 不可能快。如果你的服务器响应需要 800 毫秒,你的 LCP 至少是 800 毫秒加上其他一切。

在这个站点上(Node.js + PM2 + Nginx 部署在 VPS 上),我测得冷启动 TTFB 约 180 毫秒。以下是我为保持这个水平所做的:

  • ISR(增量静态再生) 用于博客文章——页面在构建时预渲染,定期重新验证。首次访问直接从 Nginx 反向代理缓存提供静态文件。
  • 边缘缓存头 —— Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 用于静态页面。
  • Nginx 中的 Gzip/Brotli 压缩 —— 减少 60-80% 的传输体积。
bash
# nginx.conf 片段
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli(如果安装了 ngx_brotli 模块)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

我的 LCP 优化前后对比:

  • 优化前:3.8 秒(第 75 百分位,CrUX)
  • preload + fetchPriority + 压缩之后:1.8 秒
  • 总体提升:降低 53%

CLS:被一千次偏移拖垮#

Cumulative Layout Shift(累积布局偏移)衡量的是页面加载过程中可见内容移动了多少。CLS 为 0 表示什么都没有发生偏移。CLS 超过 0.1 表示有什么东西在视觉上让你的用户感到不爽。

CLS 是大多数开发者低估的指标。你在快速的开发机器上、所有资源都被缓存的情况下根本注意不到。你的用户会注意到——在他们的手机上,在慢速连接下,字体加载延迟、图片一张一张蹦出来。

常见元凶#

1. 没有指定明确尺寸的图片

这是最常见的 CLS 原因。图片加载时会把下方的内容推下去。修复方法简单得令人尴尬:始终给 <img> 标签指定 widthheight

tsx
// 差 —— 会导致布局偏移
<img src="/photo.jpg" alt="Team photo" />
 
// 好 —— 浏览器在图片加载前就预留空间
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

如果你使用 Next.js 的 <Image>,只要你提供了尺寸或者配合已设定尺寸的父容器使用 fill,它会自动处理。

但有个坑:如果你用 fill 模式,父容器必须有明确的尺寸,否则图片会引起 CLS:

tsx
// 差 —— 父元素没有尺寸
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// 好 —— 父元素有明确的宽高比
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Web 字体导致的 FOUT/FOIT

当自定义字体加载完成后,用后备字体渲染的文本会被重新渲染为自定义字体。如果两种字体的度量不同(几乎总是不同的),所有内容都会发生偏移。

现代的解决方案是 font-display: swap 配合尺寸调整的后备字体

tsx
// 使用 next/font —— Next.js 中的最佳方案
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font 会自动生成尺寸调整的后备字体
  // 这消除了字体切换导致的 CLS
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font 是 Next.js 中最出色的特性之一。它在构建时下载字体,自托管(运行时不需要请求 Google Fonts),并且生成尺寸调整的后备字体,这样从后备字体切换到自定义字体时不会产生任何布局偏移。切换到 next/font 后,我测得字体相关的 CLS 为 0.00。之前用标准的 Google Fonts <link> 时,是 0.04-0.08。

3. 动态内容注入

广告、Cookie 横幅、通知栏——任何在初始渲染后注入 DOM 的东西,如果它把内容往下推,就会导致 CLS。

解决方法:在动态内容加载之前预留空间

tsx
// Cookie 横幅 —— 在底部预留空间
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // 固定定位不会导致 CLS,因为
    // 它不影响文档流
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

对动态元素使用 position: fixedposition: absolute 是一种不会产生 CLS 的方案,因为这些元素不影响正常的文档流。

4. aspect-ratio CSS 技巧

对于你知道宽高比但不知道确切尺寸的响应式容器,使用 CSS aspect-ratio 属性:

tsx
// 无 CLS 的视频嵌入
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

aspect-video 工具类(即 aspect-ratio: 16/9)会预留恰好合适的空间。iframe 加载时不会发生偏移。

5. 骨架屏

对于异步加载的内容(API 数据、动态组件),展示一个与预期尺寸匹配的骨架屏:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

关键在于 PostCardSkeletonPostCard 应该有相同的尺寸。如果骨架屏高 200px 而实际卡片高 280px,你仍然会遇到偏移。

我的 CLS 结果:

  • 优化前:0.12(仅字体切换就占了 0.06)
  • 优化后:0.00 —— 真的是零,在 CrUX 数据中上千次页面加载都是零

INP:咬人的新指标#

Interaction to Next Paint(交互到下次绘制)在 2024 年 3 月取代了 First Input Delay,它是一个从根本上更难优化的指标。FID 只衡量第一次交互被处理之前的延迟。INP 衡量页面整个生命周期中的每一次交互,并报告最差的那次(第 75 百分位)。

这意味着一个页面可能有很好的 FID 但很差的 INP,比如在加载 30 秒后点击一个下拉菜单触发了 500 毫秒的回流。

什么导致了高 INP#

  1. 主线程上的长任务 —— 任何超过 50 毫秒的 JavaScript 执行都会阻塞主线程。在长任务期间发生的用户交互必须排队等待。
  2. React 中昂贵的重新渲染 —— 一次状态更新导致 200 个组件重新渲染是需要时间的。用户点击了某个东西,React 进行协调,绘制在 300 毫秒后才发生。
  3. 布局抖动 —— 在循环中先读取布局属性(如 offsetHeight)再写入它们(如修改 style.height),会强制浏览器同步重新计算布局。
  4. 庞大的 DOM —— 更多的 DOM 节点意味着更慢的样式重新计算和布局。一个有 5000 个节点的 DOM 明显比 500 个节点的慢。

用 scheduler.yield() 拆分长任务#

对 INP 最有效的技术是拆分长任务,这样浏览器就可以在各个块之间处理用户交互:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // 每 10 个项目,让出主线程给浏览器
    // 这样待处理的用户交互就能被处理
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() 在 Chrome 129+(2024 年 9 月)中可用,是推荐的让出主线程的方式。对于不支持它的浏览器,可以回退到 setTimeout(0) 的包装:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

用 useTransition 处理非紧急更新#

React 18+ 提供了 useTransition,它告诉 React 某些状态更新不是紧急的,可以被更重要的工作(比如响应用户输入)打断:

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // 这个更新是紧急的 —— 输入框必须立即反映按键
    setQuery(value);
 
    // 这个更新不紧急 —— 过滤 10000 个项目可以等一等
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

如果不用 startTransition,在搜索输入框中打字会感觉卡顿,因为 React 会尝试同步过滤 10000 个项目再更新 DOM。用了 startTransition,输入框立即更新,过滤在后台进行。

我在一个包含复杂输入处理的工具页面上测了 INP。使用 useTransition 之前:380 毫秒 INP。之后:90 毫秒 INP。仅仅是一个 React API 的变化就带来了 76% 的提升。

防抖输入处理器#

对于触发昂贵操作(API 调用、重度计算)的处理器,使用防抖:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// 用法
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300 毫秒是我常用的防抖值。足够短,用户不会注意到延迟;足够长,可以避免每次按键都触发。

Web Workers 处理重度计算#

如果你有真正重度的计算(解析大型 JSON、图片处理、复杂计算),把它彻底移到主线程之外:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // 对大型数据集可能需要 500 毫秒
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Workers 运行在单独的线程上,所以即便是 2 秒的计算也完全不会影响 INP。主线程始终保持空闲,可以处理用户交互。

我的 INP 结果:

  • 优化前:340 毫秒(最差的交互是一个正则测试工具的复杂输入处理)
  • 使用 useTransition + 防抖之后:110 毫秒
  • 提升幅度:降低 68%

Next.js 特有的优化手段#

如果你在用 Next.js(13+ App Router),你可以利用一些强大的性能原语,而大多数开发者并没有充分利用它们。

next/image —— 但要正确配置#

next/image 很棒,但默认配置在性能方面还有提升空间:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 年
  },
};
 
export default nextConfig;

关键配置项:

  • formats: ["image/avif", "image/webp"] —— AVIF 比 WebP 小 20-50%。顺序很重要:Next.js 先尝试 AVIF,回退到 WebP,再回退到原始格式。
  • minimumCacheTTL —— 默认是 60 秒。对于博客来说,图片不会变。缓存一年。
  • deviceSizesimageSizes —— 默认包含 3840px。除非你在提供 4K 图片,否则精简这个列表。每个尺寸都会生成一个单独的缓存图片,未使用的尺寸浪费磁盘空间和构建时间。

始终使用 sizes 属性告诉浏览器图片将以什么尺寸渲染:

tsx
// 全宽主图
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP 图片 —— 不要懒加载!
/>
 
// 响应式网格中的卡片图片
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

不用 sizes 的话,浏览器可能会为一个 300px 的插槽下载 1200px 的图片。这是浪费的字节和浪费的时间。

LCP 图片上的 priority 属性至关重要。它会禁用懒加载,并自动添加 fetchPriority="high"。如果你的 LCP 元素是一个 next/image,只需加上 priority 就已经成功了大半。

next/font —— 零布局偏移的字体#

我在 CLS 部分已经讲过了,但值得再强调一下。next/font 是我见过的唯一能持续实现零 CLS 的字体加载方案:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

两种字体,零 CLS,运行时零外部请求。字体在构建时下载,从你自己的域名提供。

使用 Suspense 进行流式渲染#

这就是 Next.js 在性能方面真正有趣的地方。使用 App Router,你可以在页面各部分准备就绪时将它们流式传输到浏览器:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* 这个加载很快 —— 立即流式传输 */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* 这个需要数据库查询 —— 准备好后再流式传输 */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* 侧边栏可以独立加载 */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

浏览器立即收到外壳(标题、导航、布局)。文章列表和侧边栏在其数据可用时流式传入。用户看到快速的初始加载,内容逐步填充。

这对 LCP 特别有用。如果你的 LCP 元素是标题(而不是文章列表),它会立即渲染,与数据库查询需要多长时间无关。

路由段配置#

Next.js 允许你在路由段级别配置缓存和重新验证:

tsx
// app/blog/page.tsx
// 每小时重新验证此页面
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// 这些工具页面完全静态 —— 在构建时生成
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API 路由 —— 永不缓存
export const dynamic = "force-dynamic";

在这个站点上,博客文章使用 revalidate = 3600(1 小时)。工具页面使用 force-static,因为它们的内容在两次部署之间不会变化。搜索 API 使用 force-dynamic,因为每个请求都是唯一的。

结果:大多数页面从静态缓存提供,缓存命中页面的 TTFB 低于 50 毫秒,服务器几乎毫无压力。

测量工具:相信数据,不要相信你的眼睛#

你对性能的感知是不可靠的。你的开发机器有 32GB 内存、NVMe SSD 和千兆网络连接。你的用户没有。

我使用的测量工具栈#

1. Chrome DevTools Performance 面板

最详细的可用工具。录制一次页面加载,看火焰图,识别长任务,找到渲染阻塞资源。这是我花最多调试时间的地方。

需要关注的关键点:

  • 任务角上的红色标记 = 长任务(>50 毫秒)
  • 由 JavaScript 触发的 Layout/Paint 事件
  • 大块的"Evaluate Script"(JavaScript 太多了)
  • 网络瀑布流中显示的延迟发现的资源

2. Lighthouse

做快速检查不错,但不要为 Lighthouse 分数做优化。Lighthouse 在模拟的限流环境中运行,并不能完美匹配真实世界的条件。我见过 Lighthouse 跑出 98 分但实际 LCP 高达 4 秒的页面。

把 Lighthouse 当作方向性指导,而不是计分板。

3. PageSpeed Insights

对生产站点来说最重要的工具,因为它显示真实的 CrUX 数据 —— 过去 28 天来自真实 Chrome 用户的实际测量值。实验室数据告诉你可能发生什么。CrUX 数据告诉你确实发生了什么。

4. web-vitals 库

把它添加到你的生产站点中来收集真实用户指标:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // 发送到你的分析端点
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // 使用 sendBeacon 这样不会阻塞页面卸载
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

这让你拥有自己的类 CrUX 数据,但更详细。你可以按页面、设备类型、连接速度、地理区域进行分段——随你需要。

5. Chrome User Experience Report(CrUX)

CrUX BigQuery 数据集是免费的,包含数百万来源的 28 天滚动数据。如果你的站点有足够的流量,你可以查询自己的数据:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

瀑布流黑名单#

第三方脚本是大多数网站的头号性能杀手。以下是我发现的问题以及我的处理方式。

Google Tag Manager(GTM)#

GTM 本身约 80KB。但 GTM 会加载其他脚本——分析工具、营销像素、A/B 测试工具。我见过 GTM 配置加载了 15 个额外脚本,总计 2MB。

我的做法:生产环境不要用 GTM。 直接加载分析脚本,全部延迟加载,对可以等待的脚本使用 loading="lazy"

tsx
// 不用 GTM 加载所有东西
// 只加载你需要的,在需要的时候
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

如果你确实必须用 GTM,在页面变得可交互之后再加载它:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // 等到页面加载完成后再注入 GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 秒延迟
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

是的,你会丢失前 3 秒内跳出的用户数据。根据我的经验,这个取舍是值得的。那些用户本来也不会转化。

聊天小部件#

在线聊天小部件(Intercom、Drift、Crisp)是最大的性能罪魁祸首之一。仅 Intercom 就加载 400KB 以上的 JavaScript。在一个只有 2% 用户实际点击聊天按钮的页面上,98% 的用户承受了 400KB JavaScript 的负担。

我的解决方案:交互时再加载小部件。

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // 只在用户点击时加载聊天小部件脚本
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // 脚本加载后初始化小部件
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

未使用的 JavaScript#

在 Chrome DevTools 中运行 Coverage(Ctrl+Shift+P > "Show Coverage")。它会准确显示当前页面实际使用了每个脚本的多少内容。

在一个典型的 Next.js 站点上,我通常会发现:

  • 整个组件库被加载 —— 你从一个 UI 库导入了 Button,但整个库都被打包了。解决方案:使用可 tree-shake 的库或从子路径导入(import Button from "lib/Button" 而不是 import { Button } from "lib")。
  • 面向现代浏览器的 polyfill —— 检查你是否在为 PromisefetchArray.prototype.includes 提供 polyfill。2026 年了,你不需要它们。
  • 失效的 feature flag —— 已经开启了六个月的 feature flag 背后的代码路径。移除 flag 和死代码分支。

我使用 Next.js bundle analyzer 来找到过大的 chunk:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // 你的配置
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

这会打开一个 bundle 的可视化 treemap。我发现了一个 120KB 的日期格式化库,用原生 Intl.DateTimeFormat 替换了。还发现了一个 90KB 的 markdown 解析器被导入到了一个根本不用 markdown 的页面上。小的收益积少成多。

渲染阻塞 CSS#

我在 LCP 部分提过了,但值得重复,因为太常见了。<head> 中的每个 <link rel="stylesheet"> 都会阻塞渲染。如果你有五个样式表,浏览器会等到全部五个加载完毕才绘制任何内容。

Next.js 配合 Tailwind 处理得很好——CSS 是内联的且最小化的。但如果你在导入第三方 CSS,请审查它:

tsx
// 差 —— 每个页面都加载整个库的 CSS
import "some-library/dist/styles.css";
 
// 好 —— 动态导入,只在需要的页面加载
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS 在动态组件内部导入
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

一个真实的优化故事#

让我带你走一遍这个站点工具页面的实际优化过程。这是一个有 15 个以上交互式工具的页面,每个工具都有自己的组件,其中一些(如正则测试器和 JSON 格式化器)是 JavaScript 密集型的。

起点#

初始测量值(CrUX 数据,移动端,第 75 百分位):

  • LCP:3.8 秒 —— 差
  • CLS:0.12 —— 需要改进
  • INP:340 毫秒 —— 差

Lighthouse 分数:62。

调查过程#

LCP 分析: LCP 元素是页面标题(<h1>),它应该立即渲染。但它被以下因素延迟了:

  1. 一个组件库的 200KB CSS 文件(渲染阻塞)
  2. 通过 Google Fonts CDN 加载的自定义字体(慢速连接上 800 毫秒的 FOIT)
  3. 420 毫秒的 TTFB,因为页面每次请求都在服务端渲染,没有缓存

CLS 分析: 三个来源:

  1. Google Fonts 后备字体到自定义字体的切换:0.06
  2. 工具卡片加载时没有预留高度:0.04
  3. 一个 Cookie 横幅注入到页面顶部,把所有内容往下推:0.02

INP 分析: 正则测试器工具是最大的问题。每次在正则输入框中按键都会触发:

  1. 整个工具组件的完整重新渲染
  2. 对测试字符串执行正则匹配
  3. 正则模式的语法高亮

每次按键的总时间:280-400 毫秒。

修复措施#

第一周:LCP 和 CLS

  1. next/font 替换了 Google Fonts CDN。字体现在自托管,构建时加载,有尺寸调整的后备字体。字体导致的 CLS:0.06 → 0.00

  2. 移除了组件库的 CSS。用 Tailwind 重写了我使用的那 3 个组件。移除的 CSS 总计:180KB。渲染阻塞 CSS:消除

  3. 给工具页面和工具详情页添加了 revalidate = 3600。第一次访问服务端渲染,后续访问从缓存提供。TTFB:420 毫秒 → 45 毫秒(缓存命中)

  4. 给所有工具卡片组件添加了明确的尺寸,并为响应式布局使用了 aspect-ratio卡片导致的 CLS:0.04 → 0.00

  5. 把 Cookie 横幅移到了 position: fixed 在屏幕底部。横幅导致的 CLS:0.02 → 0.00

第二周:INP

  1. 把正则测试器的结果计算包裹在 startTransition 中:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // 紧急:更新输入框
 
    startTransition(() => {
      // 非紧急:计算匹配结果
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* 结果渲染 */}
    </div>
  );
}

正则测试器的 INP:380 毫秒 → 85 毫秒

  1. 给 JSON 格式化器的输入处理器添加了防抖(300 毫秒延迟)。JSON 格式化器的 INP:260 毫秒 → 60 毫秒

  2. 把哈希生成器的计算移到了 Web Worker。大输入的 SHA-256 哈希现在完全在主线程之外进行。哈希生成器的 INP:200 毫秒 → 40 毫秒

结果#

两周优化之后(CrUX 数据,移动端,第 75 百分位):

  • LCP:3.8 秒 → 1.8 秒(提升 53%)
  • CLS:0.12 → 0.00(提升 100%)
  • INP:340 毫秒 → 110 毫秒(提升 68%)

Lighthouse 分数:62 → 97。

三项指标稳稳地处在"良好"范围。页面在移动端感觉是即时加载的。优化后的一个月内,自然搜索流量增长了 12%(虽然我无法证明因果关系——还有其他因素在起作用)。

检查清单#

如果你从这篇文章中只带走一样东西,那就是我在每个项目上都会过一遍的这份检查清单:

LCP#

  • 用 DevTools 确定 LCP 元素
  • 给 LCP 图片添加 priority(或 fetchPriority="high"
  • <head> 中预加载 LCP 资源
  • 消除渲染阻塞 CSS
  • 使用 next/font 自托管字体
  • 启用 Brotli/Gzip 压缩
  • 尽可能使用静态生成或 ISR
  • 为静态资源设置积极的缓存头

CLS#

  • 所有图片都有明确的 widthheight
  • 使用 next/font 配合尺寸调整的后备字体
  • 动态内容使用 position: fixed/absolute 或预留空间
  • 骨架屏与实际组件尺寸匹配
  • 加载后没有在页面顶部注入内容

INP#

  • 交互处理器中没有长任务(>50 毫秒)
  • 非紧急状态更新包裹在 startTransition
  • 输入处理器已防抖(300 毫秒)
  • 重度计算已移至 Web Workers
  • 尽可能将 DOM 节点数控制在 1500 以下

通用#

  • 第三方脚本在页面可交互后加载
  • 已分析和 tree-shake bundle 大小
  • 已移除未使用的 CSS
  • 图片以 AVIF/WebP 格式提供
  • 生产环境中有真实用户监控(web-vitals 库)

最后的想法#

性能优化不是一次性任务。它是一门修炼。每个新功能、每个新依赖、每个新的第三方脚本都是潜在的退步。保持快速的站点是那些有人在持续关注指标的站点,而不是那些做了一次性优化冲刺的站点。

搭建真实用户监控。设置指标退步的告警。让性能成为代码审查流程的一部分。当有人添加了一个 200KB 的库时,问问是否有 5KB 的替代品。当有人在事件处理器中添加了同步计算时,问问能否延迟执行或移到 worker 中。

这篇文章中的技术不是理论性的。它们是我在这个站点上实际做的,有真实数据支撑。你的情况会有所不同——每个站点不同,每个受众不同,每个基础设施不同。但原则是通用的:加载更少,加载更智能,不要阻塞主线程。

你的用户不会因为网站快而给你写感谢信。但他们会留下来。他们会回来。Google 也会注意到。

相关文章