跳到主要内容

流式渲染

流式渲染(Streaming)是 Next.js App Router 的核心特性之一,它允许你将页面内容逐步发送到客户端,而不是等待所有数据加载完成后才返回响应。这大大改善了用户的首屏体验。

为什么需要流式渲染?

传统服务端渲染的问题

在传统的服务端渲染(SSR)中,服务器必须完成以下所有步骤才能向客户端发送任何内容:

  1. 获取页面所需的所有数据
  2. 在服务器上渲染完整的 HTML
  3. 将完整的 HTML 发送给客户端

如果某个数据请求耗时较长,整个页面都会被阻塞。用户只能盯着空白页面等待。

流式渲染的解决方案

流式渲染改变了这一流程:

  1. 服务器立即发送页面的 HTML 外壳(包含静态部分和布局)
  2. 对于需要等待数据的部分,发送加载占位符
  3. 数据准备好后,流式地将实际内容推送到客户端
  4. 客户端逐步接收到完整的页面内容

这样,用户可以更快地看到页面内容,并且可以开始与已加载的部分进行交互。

React Suspense 基础

流式渲染依赖于 React 的 <Suspense> 组件。在深入 Next.js 的实现之前,让我们先理解 Suspense 的工作原理。

Suspense 的作用

<Suspense> 允许你声明性地指定组件的加载状态。当组件正在进行异步操作时(如数据获取),Suspense 会显示 fallback 内容,操作完成后自动切换到实际内容。

import { Suspense } from "react";

function Page() {
return (
<div>
<h1>我的博客</h1>
<Suspense fallback={<p>加载文章列表中...</p>}>
<PostList />
</Suspense>
</div>
);
}

Suspense 工作原理

<PostList /> 组件需要等待数据时:

  1. React 捕获这个"挂起"状态
  2. 渲染最近的 <Suspense> 的 fallback 内容
  3. 数据准备好后,React 用实际内容替换 fallback

在服务端渲染时,这意味着服务器可以先发送 fallback 的 HTML,然后流式地发送实际内容。

loading.tsx 约定

Next.js 提供了 loading.tsx 文件约定,自动将页面包装在 Suspense 边界中。

创建加载状态

// app/posts/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
{/* 骨架屏 */}
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
);
}

自动包装机制

loading.tsx 会被自动嵌套在 layout.tsx 中,并包装 page.tsx 和子路由:

app/
├── layout.tsx
├── loading.tsx → 自动创建 Suspense 边界
└── page.tsx → 被 Suspense 包装

这等同于:

// 等效结构
<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>

导航行为

当用户导航到有 loading.tsx 的路由时:

  1. 即时反馈:fallback UI 会被预取,导航是即时的(除非预取未完成)
  2. 可中断导航:用户可以在当前路由内容加载完成前切换到其他路由
  3. 布局保持交互:共享布局在加载新路由段时保持交互性

手动使用 Suspense

除了 loading.tsx,你也可以在组件内部手动使用 <Suspense>,获得更细粒度的控制。

并行数据加载

使用多个 Suspense 边界实现并行加载:

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
<div className="card">
<h2>团队动态</h2>
<Suspense fallback={<TeamActivitySkeleton />}>
<TeamActivity />
</Suspense>
</div>

<div className="card">
<h2>数据分析</h2>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</div>

<div className="card col-span-2">
<h2>最近项目</h2>
<Suspense fallback={<ProjectsSkeleton />}>
<RecentProjects />
</Suspense>
</div>
</div>
);
}

// 每个异步组件独立获取数据
async function TeamActivity() {
const activities = await getTeamActivities(); // 耗时操作
return <ActivityList items={activities} />;
}

async function Analytics() {
const data = await getAnalyticsData(); // 耗时操作
return <AnalyticsChart data={data} />;
}

async function RecentProjects() {
const projects = await getRecentProjects(); // 耗时操作
return <ProjectGrid projects={projects} />;
}

这样,三个区域会并行加载数据,每个区域独立显示加载状态和内容。

骨架屏组件

创建可复用的骨架屏组件:

// components/skeleton.tsx
export function Skeleton({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`animate-pulse bg-gray-200 rounded ${className}`}
{...props}
/>
);
}

export function CardSkeleton() {
return (
<div className="border rounded-lg p-4 space-y-3">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
);
}

export function AvatarSkeleton() {
return <Skeleton className="h-10 w-10 rounded-full" />;
}

export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2">
{[...Array(rows)].map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/4" />
</div>
))}
</div>
);
}

流式渲染的 SEO 影响

搜索引擎如何处理流式内容

对于搜索引擎爬虫:

  • 静态爬虫(如 Twitterbot):Next.js 会等待 generateMetadata 完成后才开始流式渲染,确保元数据在初始 HTML 的 <head>
  • 完整浏览器爬虫(如 Googlebot):可以执行 JavaScript,流式内容会被正常索引

robots 元标签

当通过流式方式渲染 404 页面时,Next.js 会自动添加 <meta name="robots" content="noindex"> 标签,防止搜索引擎索引该页面,即使 HTTP 状态码是 200。

这是因为流式渲染开始后,响应头已经发送,无法再修改状态码。

最佳实践

确保在 Suspense 边界之前进行 SEO 相关检查:

import { notFound } from "next/navigation";

export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;

// 在 Suspense 边界之前检查是否存在
const exists = await checkPostExists(slug);
if (!exists) {
notFound();
}

return (
<article>
<h1>{slug}</h1>
<Suspense fallback={<p>加载内容中...</p>}>
<PostContent slug={slug} />
</Suspense>
</article>
);
}

状态码处理

流式渲染的响应状态

流式渲染开始时,服务器会发送 200 状态码,因为请求本身是成功的。这意味着:

  • 无法在流式渲染开始后更改状态码
  • 如果需要 404 或其他错误状态,必须在开始流式渲染之前处理

正确处理 404

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`);

if (res.status === 404) {
return null;
}

return res.json();
}

export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;

// 在任何异步操作之前检查
const post = await getPost(slug);

if (!post) {
notFound(); // 这会触发 not-found.tsx
}

return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<p>加载评论...</p>}>
<Comments postId={post.id} />
</Suspense>
</article>
);
}

选择性水合

流式渲染配合选择性水合(Selective Hydration)提供更好的交互体验。

什么是选择性水合?

当 HTML 流式传输到客户端后,React 需要为客户端组件"水合"(添加事件处理)。选择性水合意味着:

  1. React 根据用户交互优先级决定水合顺序
  2. 用户点击或交互的组件会优先水合
  3. 其他组件在后台逐步水合

实际效果

export default function Page() {
return (
<div>
<Suspense fallback={<p>加载慢组件...</p>}>
<SlowComponent />
</Suspense>

<Suspense fallback={<p>加载快组件...</p>}>
<FastComponent />
</Suspense>
</div>
);
}

如果用户在页面加载时点击了 FastComponent 的按钮,React 会优先水合这个组件,即使 SlowComponent 的 HTML 先到达。

实战示例

仪表盘页面

一个典型的仪表盘页面有多个独立的数据区域:

// app/dashboard/page.tsx
import { Suspense } from "react";
import {
RevenueSkeleton,
UsersSkeleton,
OrdersSkeleton
} from "@/components/skeletons";

export default function DashboardPage() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold mb-6">仪表盘</h1>

<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Suspense fallback={<RevenueSkeleton />}>
<RevenueCard />
</Suspense>

<Suspense fallback={<UsersSkeleton />}>
<UsersCard />
</Suspense>

<Suspense fallback={<OrdersSkeleton />}>
<OrdersCard />
</Suspense>
</div>

<div className="mt-6">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
</main>
);
}

// 各个组件独立获取数据
async function RevenueCard() {
const revenue = await getRevenue(); // 可能很慢
return (
<div className="card">
<h3>总收入</h3>
<p className="text-3xl font-bold">¥{revenue.total.toLocaleString()}</p>
</div>
);
}

async function UsersCard() {
const users = await getUsers(); // 可能很快
return (
<div className="card">
<h3>活跃用户</h3>
<p className="text-3xl font-bold">{users.active}</p>
</div>
);
}

async function OrdersCard() {
const orders = await getOrders(); // 中等速度
return (
<div className="card">
<h3>订单数量</h3>
<p className="text-3xl font-bold">{orders.count}</p>
</div>
);
}

带错误处理的流式渲染

每个 Suspense 边界可以有独立的错误处理:

// app/dashboard/error.tsx
"use client";

export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-4 bg-red-50 text-red-600 rounded">
<h3>加载失败</h3>
<p>{error.message}</p>
<button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
重试
</button>
</div>
);
}

嵌套 Suspense

Suspense 可以嵌套,实现更复杂的加载策略:

export default function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="product-page">
{/* 基础信息加载快 */}
<Suspense fallback={<ProductBasicSkeleton />}>
<ProductBasicInfo productId={(await params).id} />

{/* 评论和推荐可能更慢 */}
<div className="mt-8 grid md:grid-cols-2 gap-8">
<Suspense fallback={<CommentsSkeleton />}>
<ProductComments productId={(await params).id} />
</Suspense>

<Suspense fallback={<RecommendationsSkeleton />}>
<ProductRecommendations productId={(await params).id} />
</Suspense>
</div>
</Suspense>
</div>
);
}

流式渲染的限制

静态导出不支持

使用 output: 'export' 静态导出时,流式渲染不可用。静态导出会预渲染所有页面,无法进行流式传输。

浏览器缓冲

某些浏览器会缓冲流式响应,直到响应超过约 1024 字节才开始显示。这主要影响非常简单的"Hello World"应用,实际应用通常不会有这个问题。

部分预渲染(Partial Prerendering)

部分预渲染(Partial Prerendering,简称 PPR)是 Next.js 的一项强大特性,它将静态预渲染和动态流式渲染完美结合,在单个 HTTP 请求中同时提供静态外壳和动态内容。

什么是部分预渲染?

传统的渲染方式各有优缺点:

  • 静态站点生成(SSG):构建时预渲染,性能极佳,但无法处理个性化或实时内容
  • 服务端渲染(SSR):每次请求渲染完整页面,支持动态内容,但等待时间长
  • 流式渲染:逐步发送内容,改善感知性能,但初始响应仍有延迟

部分预渲染取各家之长:

  1. 立即发送静态外壳:页面的静态部分(导航栏、布局、骨架结构)立即返回
  2. 流式填充动态内容:动态部分(用户数据、实时信息)通过流式传输逐步加载
  3. 单个 HTTP 请求:所有内容在一个请求中完成,无需额外的 API 调用

工作原理

考虑一个电商产品页面:

// app/product/[id]/page.tsx
import { Suspense } from "react";

export default function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="product-page">
{/* 静态部分:立即渲染 */}
<header className="site-header">
<nav>...</nav>
</header>

<main>
{/* 静态部分:产品基本信息 */}
<ProductInfo id={(await params).id} />

{/* 动态部分:延迟渲染 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={(await params).id} />
</Suspense>

{/* 动态部分:个性化推荐 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</main>

{/* 静态部分:立即渲染 */}
<footer>...</footer>
</div>
);
}

渲染流程:

  1. 服务器收到请求,立即开始构建响应
  2. 静态外壳(header、ProductInfo、footer)被预渲染并立即发送
  3. 动态部分开始获取数据(评论、推荐)
  4. Suspense 边界显示回退 UI(骨架屏)
  5. 数据准备好后,实际内容流式发送到客户端
  6. React 协调:用真实内容替换骨架屏

启用部分预渲染

next.config.ts 中启用:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};

export default nextConfig;
实验性功能

PPR 目前是实验性功能。在生产环境使用前,请关注 Next.js 官方文档的最新更新。PPR 仅适用于 Node.js 运行时。

静态与动态的划分

理解哪些内容会被静态渲染,哪些会动态渲染:

静态内容

  • 不使用动态 API(cookies、headers、searchParams)
  • 不依赖用户特定数据
  • 可以在构建时确定的内容

动态内容

  • 使用 Suspense 包裹的异步组件
  • 依赖用户会话的数据
  • 实时变化的内容
// 静态组件:无动态 API,可预渲染
async function ProductInfo({ id }: { id: string }) {
const product = await getProduct(id); // 构建时可确定
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}

// 动态组件:依赖用户数据,需要运行时渲染
async function ProductReviews({ id }: { id: string }) {
// 可能在运行时获取最新评论
const reviews = await getReviews(id);
return (
<div>
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
}

// 动态组件:依赖用户会话
async function PersonalizedRecommendations() {
const user = await getCurrentUser(); // 使用 cookies
const recommendations = await getRecommendations(user.id);
return <RecommendationList items={recommendations} />;
}

增量采用

可以逐步为特定路由启用 PPR:

// app/product/[id]/page.tsx
export const experimental_ppr = true;

export default function ProductPage() {
// ...
}

与流式渲染的关系

PPR 是流式渲染的增强:

特性流式渲染部分预渲染
静态内容等待数据后渲染立即返回预渲染的 HTML
动态内容流式传输流式传输
首次响应时间等待首个 Suspense 边界立即返回静态外壳
适用场景所有动态页面静态和动态内容混合

最佳实践

识别静态与动态边界

分析页面结构,将不依赖用户状态的内容标记为静态:

export default function Page() {
return (
<>
{/* 静态:网站通用导航 */}
<Navigation />

<main>
{/* 静态:文章内容 */}
<ArticleContent />

{/* 动态:用户评论 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</main>

{/* 静态:网站通用页脚 */}
<Footer />
</>
);
}

优化 Suspense 边界位置

将 Suspense 放在合理的位置,避免过度拆分:

// 推荐:合理的边界
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>

// 不推荐:过度拆分
<Suspense fallback={<div>加载中</div>}>
<Header />
</Suspense>
<Suspense fallback={<div>加载中</div>}>
<Content />
</Suspense>

提供有意义的加载状态

骨架屏应该反映实际内容的布局:

function ArticleSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-4 bg-gray-200 rounded w-4/6" />
</div>
</div>
);
}

小结

本章我们学习了:

  1. 流式渲染的核心价值:改善首屏体验,减少阻塞等待
  2. React Suspense 的工作原理
  3. loading.tsx 约定的自动包装机制
  4. 手动使用 Suspense 实现并行数据加载
  5. 流式渲染对 SEO 的影响和处理方式
  6. 选择性水合的工作原理
  7. 部分预渲染(PPR):静态外壳 + 动态流式内容
  8. 实战示例:仪表盘页面和错误处理

练习

  1. 创建一个博客列表页面,使用 loading.tsx 显示骨架屏
  2. 在仪表盘中使用多个 Suspense 边界实现独立加载状态
  3. 创建可复用的骨架屏组件库
  4. 实现嵌套 Suspense 结构的产品详情页