流式渲染
流式渲染(Streaming)是 Next.js App Router 的核心特性之一,它允许你将页面内容逐步发送到客户端,而不是等待所有数据加载完成后才返回响应。这大大改善了用户的首屏体验。
为什么需要流式渲染?
传统服务端渲染的问题
在传统的服务端渲染(SSR)中,服务器必须完成以下所有步骤才能向客户端发送任何内容:
- 获取页面所需的所有数据
- 在服务器上渲染完整的 HTML
- 将完整的 HTML 发送给客户端
如果某个数据请求耗时较长,整个页面都会被阻塞。用户只能盯着空白页面等待。
流式渲染的解决方案
流式渲染改变了这一流程:
- 服务器立即发送页面的 HTML 外壳(包含静态部分和布局)
- 对于需要等待数据的部分,发送加载占位符
- 数据准备好后,流式地将实际内容推送到客户端
- 客户端逐步接收到完整的页面内容
这样,用户可以更快地看到页面内容,并且可以开始与已加载的部分进行交互。
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 /> 组件需要等待数据时:
- React 捕获这个"挂起"状态
- 渲染最近的
<Suspense>的 fallback 内容 - 数据准备好后,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 的路由时:
- 即时反馈:fallback UI 会被预取,导航是即时的(除非预取未完成)
- 可中断导航:用户可以在当前路由内容加载完成前切换到其他路由
- 布局保持交互:共享布局在加载新路由段时保持交互性
手动使用 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 需要为客户端组件"水合"(添加事件处理)。选择性水合意味着:
- React 根据用户交互优先级决定水合顺序
- 用户点击或交互的组件会优先水合
- 其他组件在后台逐步水合
实际效果
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):每次请求渲染完整页面,支持动态内容,但等待时间长
- 流式渲染:逐步发送内容,改善感知性能,但初始响应仍有延迟
部分预渲染取各家之长:
- 立即发送静态外壳:页面的静态部分(导航栏、布局、骨架结构)立即返回
- 流式填充动态内容:动态部分(用户数据、实时信息)通过流式传输逐步加载
- 单个 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>
);
}
渲染流程:
- 服务器收到请求,立即开始构建响应
- 静态外壳(header、ProductInfo、footer)被预渲染并立即发送
- 动态部分开始获取数据(评论、推荐)
- Suspense 边界显示回退 UI(骨架屏)
- 数据准备好后,实际内容流式发送到客户端
- 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>
);
}
小结
本章我们学习了:
- 流式渲染的核心价值:改善首屏体验,减少阻塞等待
- React Suspense 的工作原理
loading.tsx约定的自动包装机制- 手动使用 Suspense 实现并行数据加载
- 流式渲染对 SEO 的影响和处理方式
- 选择性水合的工作原理
- 部分预渲染(PPR):静态外壳 + 动态流式内容
- 实战示例:仪表盘页面和错误处理
练习
- 创建一个博客列表页面,使用
loading.tsx显示骨架屏 - 在仪表盘中使用多个 Suspense 边界实现独立加载状态
- 创建可复用的骨架屏组件库
- 实现嵌套 Suspense 结构的产品详情页