2026 年 Core Web Vitals 实战:到底什么才能真正提升性能
忘掉那些理论吧——这是我在一个真实的 Next.js 生产站点上把 LCP 降到 2.5 秒以下、CLS 归零、INP 控制在 200 毫秒以内的具体做法。不是含糊其辞的建议,而是切实可行的技巧。
我花了差不多两周时间让这个网站变快。不是那种"在我的 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.5s | 2.5s – 4.0s | > 4.0s |
| CLS | 视觉稳定性 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | 响应性 | ≤ 200ms | 200ms – 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 库以编程方式记录:
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 链接:
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 的样式表,浏览器在它完全下载和解析完毕之前不会绘制任何内容。
解决方法有三个:
-
内联关键 CSS —— 提取首屏内容需要的 CSS,放到
<head>中的<style>标签里。当你使用 CSS Modules 或正确配置了 purge 的 Tailwind 时,Next.js 会自动完成这一步。 -
延迟加载非关键 CSS —— 如果你有针对首屏以下内容的样式表(页脚动画库、图表组件),异步加载它们:
<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>- 移除未使用的 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% 的传输体积。
# 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> 标签指定 width 和 height。
// 差 —— 会导致布局偏移
<img src="/photo.jpg" alt="Team photo" />
// 好 —— 浏览器在图片加载前就预留空间
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />如果你使用 Next.js 的 <Image>,只要你提供了尺寸或者配合已设定尺寸的父容器使用 fill,它会自动处理。
但有个坑:如果你用 fill 模式,父容器必须有明确的尺寸,否则图片会引起 CLS:
// 差 —— 父元素没有尺寸
<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 配合尺寸调整的后备字体:
// 使用 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。
解决方法:在动态内容加载之前预留空间。
// 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: fixed 或 position: absolute 是一种不会产生 CLS 的方案,因为这些元素不影响正常的文档流。
4. aspect-ratio CSS 技巧
对于你知道宽高比但不知道确切尺寸的响应式容器,使用 CSS aspect-ratio 属性:
// 无 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 数据、动态组件),展示一个与预期尺寸匹配的骨架屏:
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>
);
}关键在于 PostCardSkeleton 和 PostCard 应该有相同的尺寸。如果骨架屏高 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#
- 主线程上的长任务 —— 任何超过 50 毫秒的 JavaScript 执行都会阻塞主线程。在长任务期间发生的用户交互必须排队等待。
- React 中昂贵的重新渲染 —— 一次状态更新导致 200 个组件重新渲染是需要时间的。用户点击了某个东西,React 进行协调,绘制在 300 毫秒后才发生。
- 布局抖动 —— 在循环中先读取布局属性(如
offsetHeight)再写入它们(如修改style.height),会强制浏览器同步重新计算布局。 - 庞大的 DOM —— 更多的 DOM 节点意味着更慢的样式重新计算和布局。一个有 5000 个节点的 DOM 明显比 500 个节点的慢。
用 scheduler.yield() 拆分长任务#
对 INP 最有效的技术是拆分长任务,这样浏览器就可以在各个块之间处理用户交互:
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) 的包装:
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 某些状态更新不是紧急的,可以被更重要的工作(比如响应用户输入)打断:
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 调用、重度计算)的处理器,使用防抖:
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、图片处理、复杂计算),把它彻底移到主线程之外:
// 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;
}
}
});// 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 很棒,但默认配置在性能方面还有提升空间:
// 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 秒。对于博客来说,图片不会变。缓存一年。deviceSizes和imageSizes—— 默认包含 3840px。除非你在提供 4K 图片,否则精简这个列表。每个尺寸都会生成一个单独的缓存图片,未使用的尺寸浪费磁盘空间和构建时间。
始终使用 sizes 属性告诉浏览器图片将以什么尺寸渲染:
// 全宽主图
<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 的字体加载方案:
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,你可以在页面各部分准备就绪时将它们流式传输到浏览器:
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 允许你在路由段级别配置缓存和重新验证:
// 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 库
把它添加到你的生产站点中来收集真实用户指标:
// 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 天滚动数据。如果你的站点有足够的流量,你可以查询自己的数据:
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":
// 不用 GTM 加载所有东西
// 只加载你需要的,在需要的时候
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}如果你确实必须用 GTM,在页面变得可交互之后再加载它:
"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 的负担。
我的解决方案:交互时再加载小部件。
"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 —— 检查你是否在为
Promise、fetch或Array.prototype.includes提供 polyfill。2026 年了,你不需要它们。 - 失效的 feature flag —— 已经开启了六个月的 feature flag 背后的代码路径。移除 flag 和死代码分支。
我使用 Next.js bundle analyzer 来找到过大的 chunk:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// 你的配置
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run build这会打开一个 bundle 的可视化 treemap。我发现了一个 120KB 的日期格式化库,用原生 Intl.DateTimeFormat 替换了。还发现了一个 90KB 的 markdown 解析器被导入到了一个根本不用 markdown 的页面上。小的收益积少成多。
渲染阻塞 CSS#
我在 LCP 部分提过了,但值得重复,因为太常见了。<head> 中的每个 <link rel="stylesheet"> 都会阻塞渲染。如果你有五个样式表,浏览器会等到全部五个加载完毕才绘制任何内容。
Next.js 配合 Tailwind 处理得很好——CSS 是内联的且最小化的。但如果你在导入第三方 CSS,请审查它:
// 差 —— 每个页面都加载整个库的 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>),它应该立即渲染。但它被以下因素延迟了:
- 一个组件库的 200KB CSS 文件(渲染阻塞)
- 通过 Google Fonts CDN 加载的自定义字体(慢速连接上 800 毫秒的 FOIT)
- 420 毫秒的 TTFB,因为页面每次请求都在服务端渲染,没有缓存
CLS 分析: 三个来源:
- Google Fonts 后备字体到自定义字体的切换:0.06
- 工具卡片加载时没有预留高度:0.04
- 一个 Cookie 横幅注入到页面顶部,把所有内容往下推:0.02
INP 分析: 正则测试器工具是最大的问题。每次在正则输入框中按键都会触发:
- 整个工具组件的完整重新渲染
- 对测试字符串执行正则匹配
- 正则模式的语法高亮
每次按键的总时间:280-400 毫秒。
修复措施#
第一周:LCP 和 CLS
-
用
next/font替换了 Google Fonts CDN。字体现在自托管,构建时加载,有尺寸调整的后备字体。字体导致的 CLS:0.06 → 0.00 -
移除了组件库的 CSS。用 Tailwind 重写了我使用的那 3 个组件。移除的 CSS 总计:180KB。渲染阻塞 CSS:消除
-
给工具页面和工具详情页添加了
revalidate = 3600。第一次访问服务端渲染,后续访问从缓存提供。TTFB:420 毫秒 → 45 毫秒(缓存命中) -
给所有工具卡片组件添加了明确的尺寸,并为响应式布局使用了
aspect-ratio。卡片导致的 CLS:0.04 → 0.00 -
把 Cookie 横幅移到了
position: fixed在屏幕底部。横幅导致的 CLS:0.02 → 0.00
第二周:INP
- 把正则测试器的结果计算包裹在
startTransition中:
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 毫秒
-
给 JSON 格式化器的输入处理器添加了防抖(300 毫秒延迟)。JSON 格式化器的 INP:260 毫秒 → 60 毫秒
-
把哈希生成器的计算移到了 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#
- 所有图片都有明确的
width和height - 使用
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 也会注意到。