Skip to content

Lesson 27:性能优化 — Core Web Vitals、流式渲染与缓存策略 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:120~180 分钟(首次学习)
  • 先修要求:完成 L17–26,理解 Server Component、数据库查询和生产构建
  • 学习产出:测量生产构建,按瓶颈调整图片、加载边界和公开数据缓存
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:理解 Web 性能的核心指标,掌握 Next.js 的图片优化、字体优化、动态导入、流式渲染和缓存 API 等全方位优化手段。

📦 本节产出:一份同环境下的优化前后测量记录,并能解释各项优化的适用范围;分数是否提升以实测为准。

一、Core Web Vitals — Google 的衡量标准 ​

Core Web Vitals 分别衡量加载、交互响应和布局稳定性。判断是否达标时,按移动端和桌面端分别查看真实访问数据的第 75 百分位,而不是取一次本地测试结果。Web Vitals 官方说明。

1.1 使用 Lighthouse 检查你的分数 ​

  1. 运行 npm run build 与 npm run start,在生产构建上打开 Chrome DevTools → Lighthouse 标签页
  2. 选择 "Performance",点击 "Analyze page load"
  3. 等待 10-20 秒,查看报告

固定设备、网络限速和缓存条件,多次测量后比较。Lighthouse 的导航测试没有真实用户交互,不能直接测出 INP;用其 TBT 指标查找主线程阻塞,再通过交互录制和真实用户数据验证 INP。不要把 Lighthouse 总分当作 Core Web Vitals 全部达标。


二、next/image — 图片优化 (改善 LCP) ​

先确认页面的 LCP 元素是否为图片,再决定优先加载哪一张。next/image 提供尺寸生成和格式协商;原生 <img> 也可以设置宽高和 loading="lazy",并非一定会导致布局偏移。

L19 的 Product.image 可以为空。将下面组件导入商品卡片或详情页,传入 src={product.image} 和 alt={product.name};不要直接把可空字段传给 Image。本地地址对应的文件必须存在于 public/,远程图片还需要下方的允许列表。

tsx
// src/components/ProductImage.tsx
import Image from 'next/image'

export default function ProductImage({ src, alt, priority = false }: {
  src: string | null; alt: string; priority?: boolean
}) {
  return (
    <div className="relative aspect-[4/3] bg-gray-100">
      {src ? (
        <Image src={src} alt={alt} fill
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
          className="object-contain" priority={priority} />
      ) : <span className="absolute inset-0 flex items-center justify-center">暂无图片</span>}
    </div>
  )
}

sizes 按实际布局调整;详情大图不一定是桌面端的 33vw。Next.js 15 的 priority 用于实测的首屏关键图片,不要给所有图片都设置。字符串路径或远程 URL 使用 placeholder="blur" 时,还必须提供 blurDataURL;静态导入受支持的图片文件才可自动获得模糊占位数据。

ts
// 合并到 next.config.ts 的现有配置;example 域名须改成你实际使用的图片来源。
images: {
  remotePatterns: [{
    protocol: 'https', hostname: 'images.example.com',
    port: '', pathname: '/products/**', search: '',
  }],
},

默认优化格式是 WebP,并根据浏览器支持情况选择;AVIF 需显式配置。图片仍需通过网络下载,懒加载也可能在接近视口时提前触发。尺寸容器有助于避免图片造成的 CLS,不能保证整页 CLS 为零。详见 Next.js 15 Image。


三、next/font — 字体优化 (改善 CLS + LCP) ​

自定义字体是导致 CLS (布局偏移) 的常见原因。浏览器先用系统字体渲染文字,等自定义字体下载好后切换——文字突然跳了一下!

在现有 src/app/layout.tsx 中加入字体声明,并把 inter.className 合并到 <html> 或 <body> 的 className。保留原有 metadata、导航、UserMenu、CartBadge 和样式导入,不要用简化示例覆盖整个布局。

tsx
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], display: 'swap' })
// 例如把原有 <html lang="zh-CN"> 改成:
// <html lang="zh-CN" className={inter.className}>

next/font/google 在构建时下载字体,再由应用托管;浏览器仍需请求本站字体资源,只是不再直接请求 Google Fonts。自动调整 fallback 字体可以减少切换时的布局变化,不能保证整页零 CLS。subsets: ['latin'] 选择预加载的子集,不是自动扫描正文只下载用过的字符;Inter 也不包含中文,中文仍会使用后备字体。

若构建环境不能访问字体源,可以将有使用授权的字体放进项目,改用本地字体。文件路径相对于声明字体的文件,必须包含真实的 src:

tsx
// src/app/layout.tsx:与上面的 Google 字体方案二选一
import localFont from 'next/font/local'
const appFont = localFont({ src: './fonts/app-font.woff2', display: 'swap' })
// 将 appFont.className 合并到现有布局的 className;先准备该字体文件。

更多选项见 Next.js 15 Font。


四、loading.tsx — 文件级 Loading 态 ​

Next.js App Router 有一个约定式的 Loading UI 文件:在路由文件夹中放一个 loading.tsx,它会自动作为该路由的 <Suspense fallback>。

src/app/products/
├── page.tsx           ← 商品列表(Server Component,可能慢)
├── loading.tsx        ← 自动在 page.tsx 加载时显示
└── [id]/
    ├── page.tsx       ← 商品详情
    └── loading.tsx    ← 自动在详情加载时显示
tsx
// src/app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="max-w-7xl mx-auto px-4 py-12">
      <div className="h-8 bg-gray-200 rounded w-48 mb-8 animate-pulse" />
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="bg-white rounded-2xl border overflow-hidden animate-pulse">
            <div className="h-48 bg-gray-200" />
            <div className="p-5 space-y-3">
              <div className="h-4 bg-gray-200 rounded w-3/4" />
              <div className="h-6 bg-gray-200 rounded w-1/3" />
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

在 /products 的页面内容等待期间,Next.js 可显示 loading.tsx,查询完成后替换为真实内容。数据或路由已可用时,骨架屏可能不会出现;它也不包住同层 layout 中发生的等待。

框架把该 loading 边界放在布局内部,包住页面及其后代路由。


五、流式渲染 (Streaming) + Suspense ​

传统 SSR 的流程是:等所有数据都加载完 → 一次性渲染完整 HTML → 发送。 如果某个数据源特别慢(如调第三方 AI 推荐 API),整个页面都被拖慢。

流式渲染允许服务端边渲染边发送,用 <Suspense> 精细地控制每个区域的 loading 状态:

下面新增独立演示页,保留 L22 的搜索、分类和分页。两个异步区域各有边界;慢区域故意等待两秒用于观察,并不是实际推荐 API 的性能数据。

tsx
// src/app/streaming-demo/page.tsx
import { Suspense } from 'react'
import { prisma } from '@/lib/prisma'

export const dynamic = 'force-dynamic'
export default function StreamingDemo() {
  return (
    <div className="max-w-7xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">流式渲染演示</h1>
      <Suspense fallback={<p>加载商品…</p>}><ProductList /></Suspense>
      <Suspense fallback={<p>加载推荐区域…</p>}><SlowRecommendations /></Suspense>
    </div>
  )
}
async function ProductList() {
  const products = await prisma.product.findMany({ take: 3, orderBy: { id: 'asc' } })
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
async function SlowRecommendations() {
  await new Promise(resolve => setTimeout(resolve, 2000))
  return <p>推荐区域已就绪(演示)</p>
}

Suspense 改变等待边界,不会缩短数据库执行时间;代理缓冲、网络和边界之外的工作都可能延迟首屏。L21 根布局的 UserMenu 会读取会话,它的等待不在页面内部边界中。若实测它拖慢页面,可在根布局中给 <UserMenu /> 单独加 Suspense;这也不会让整条路由自动变成静态页面。


六、动态导入 — 减少首屏 JS 体积 ​

假设已实现默认导出的 src/components/RichTextEditor.tsx,下面增加它的客户端入口。ssr: false 必须位于 Client Component 中,不能直接写在 Server Component 里。

tsx
// src/components/EditorLauncher.tsx
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'

const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  loading: () => <p>加载编辑器…</p>,
  ssr: false,
})
export default function EditorLauncher() {
  const [open, setOpen] = useState(false)
  return open ? <RichTextEditor /> : <button onClick={() => setOpen(true)}>打开编辑器</button>
}

动态导入让构建器生成独立 chunk;这里通过交互后才渲染组件来推迟加载。若组件首屏就渲染,拆包本身不意味着省掉下载。Next.js 15 的 next build 默认使用 Webpack,但脚手架生成的 build 脚本可能带有 --turbopack,应检查自己的 package.json;不能把所有动态导入都归因于 Turbopack。用 Network 和包分析结果确认请求时机与收益。Next.js 15 懒加载。


七、Next.js 缓存体系 ​

Next.js 有一套复杂但强大的缓存机制:

7.1 fetch 缓存控制 ​

下面是服务端 fetch 的配置对照,URL 是说明用占位地址,需换成真实 API。Next.js 15 的 fetch 默认不写入 Data Cache;页面是否静态预渲染是另一个判断,不能只凭单个 fetch 选项确定整页的 SSR/SSG/ISR。

ts
// 默认不把响应存入 Data Cache
await fetch('https://api.example.com/data')
// 明确不使用 Data Cache
await fetch('https://api.example.com/data', { cache: 'no-store' })
// 显式缓存
await fetch('https://api.example.com/data', { cache: 'force-cache' })
// 缓存,并允许在 60 秒后被请求触发重新验证
await fetch('https://api.example.com/data', { next: { revalidate: 60 } })

revalidate: 60 不是每分钟执行的定时任务。时间到期后的访问可能先读到旧值,再触发重新验证。参见 Next.js 15 缓存指南。

7.2 unstable_cache 用于非 fetch 操作 ​

Prisma 查询不自动进入 Next.js Data Cache。公开且允许短暂陈旧的数据可用 unstable_cache 跨请求缓存;cache 来自 React,主要在一次 Server Component 请求内去重,两者不能互换。

ts
// src/lib/public-products.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

export const getCachedProducts = unstable_cache(
  async () => prisma.product.findMany({
    select: { id: true, name: true, price: true, image: true, category: true },
    orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 6,
  }),
  ['public-products-preview-v1'],
  { revalidate: 300, tags: ['products'] },
)

此查询适合首页的公开预览区,不可直接覆盖 L22 带搜索/分页条件的列表。缓存带参数的查询时,参数必须参与缓存键;不要在共享缓存内部调用 auth()、cookies() 或缓存用户订单。L20 商品变更成功后可调用 Next.js 15 的 revalidateTag('products') 使该数据缓存失效,并保留原有 revalidatePath。结算仍必须像 L23 一样在事务中查询当前价格与库存。unstable_cache。

7.3 数据缓存不等于整页 ISR ​

本课程根布局渲染 L21 的 UserMenu,auth() 读取请求会话,因此当前路由树需要动态渲染。公开查询使用 Data Cache 可以减少数据库工作,但整页不会因此进入 Full Route Cache。L22 商品详情还显式设置了 dynamic = 'force-dynamic'。如果另行练习 ISR,需要设计不读取会话或其他动态请求 API 的公开路由树,并移除该路由的强制动态配置,再检查生产构建输出和请求行为;不要为了静态化跳过认证。


八、Bundle 分析与性能预算 ​

8.1 分析包体积 ​

bash
npm install -D @next/bundle-analyzer@15

下面是合并示意,nextConfig 指当前文件中已有的配置对象:

ts
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

// 用它包装已有 nextConfig,保留 images 等配置。
export default withBundleAnalyzer(nextConfig)
bash
ANALYZE=true npx next build

@next/bundle-analyzer@15 分析 Webpack 构建,因此这里直接调用不带 --turbopack 的 next build,不沿用可能带该参数的 npm 脚本。Windows 用户需按所用 Shell 设置 ANALYZE=true 后再运行 npx next build。构建会生成并打开矩形树图(Treemap),用于查看包组成;无图形界面的 CI 中查看生成的报告文件。

8.2 性能预算 (Performance Budget) ​

预算要先定义测量对象,例如指定路由冷加载时传输的 gzip JS 总量、测试设备与网络条件,再按基线制定上限。package.json 中自创的 performanceBudget 字段不会被 Next.js 或 Bundle Analyzer 自动执行;Analyzer 用于观察包组成,CI 失败门槛还要由实际读取测量结果的脚本或 Lighthouse CI 配置实现。

Core Web Vitals 的官方分档如下,JS 体积没有适用于所有应用的统一“优秀”阈值:

指标良好需要改进较差
LCP≤ 2.5s> 2.5s 且 ≤ 4s> 4s
INP≤ 200ms> 200ms 且 ≤ 500ms> 500ms
CLS≤ 0.1> 0.1 且 ≤ 0.25> 0.25

九、useTransition 在 Next.js 中的应用 ​

L16 的 useTransition 可以在导航时提供 isPending 状态。App Router 的导航本身已集成 Transition;外层 Hook 主要让这个组件显示等待反馈,并不保证所有同步计算都不会阻塞:

tsx
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'

function CategoryFilter({ categories }: { categories: string[] }) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()

  const handleFilter = (category: string) => {
    // 将路由导航标记为低优先级
    // React 可优先处理更紧急的更新;同步 JavaScript 仍会占用主线程
    startTransition(() => {
      const params = new URLSearchParams({ category })
      router.push(`/products?${params.toString()}`)
    })
  }

  return (
    <div className={isPending ? 'opacity-50 transition-opacity' : ''}>
      {categories.map(cat => (
        <button key={cat} onClick={() => handleFilter(cat)}>{cat}</button>
      ))}
    </div>
  )
}

十、练习 ​

  1. 为有实际图片的商品加入 ProductImage,给空图片保留占位;配置真实图片来源,再比较相同环境下的 LCP 与图片传输量。
  2. 为 /products 路由添加 loading.tsx 骨架屏。
  3. 使用 Chrome DevTools Performance 标签页录制一次商品浏览操作,找出最耗时的环节。

📌 本节小结 ​

你做了什么你学到了什么
了解了 Core Web Vitals 三大指标LCP / INP / CLS 的含义与优化方向
使用了 next/image 优化图片自动 WebP、懒加载、CLS 预防
使用了 next/font 优化字体self-host、后备字体调整与子集配置
创建了 loading.tsx 骨架屏约定式 Loading 态
用 Suspense 实现了流式渲染边渲染边发送
了解了 Next.js 缓存体系fetch cache / unstable_cache / revalidate
用 Bundle Analyzer 做了体积分析性能预算概念

项目驱动 · 边写边学 · React 19