在开发博客时,我们经常会遇到一个看似矛盾的问题。
博客文章通常不会频繁变化,因此非常适合静态生成。静态页面可以直接从 CDN 返回,拥有更快的响应速度、更低的服务器开销以及更好的搜索引擎优化效果。
但博客页面又不完全是静态的。
例如:
如果使用纯静态生成,那么评论可能无法及时更新。
如果将整个文章页面改成动态渲染,那么每次访问都需要重新查询文章、解析 Markdown、生成目录并渲染页面。明明只有评论发生变化,却要让整个页面承担动态渲染的成本。
传统的渲染模式似乎要求我们二选一:
要么选择快速但可能过期的静态页面,要么选择实时但成本更高的动态页面。
而 PPR,也就是部分预渲染(Partial Prerendering),正是为了打破这种二选一而设计的。
它允许我们在同一个页面中同时使用:
对于博客而言,我们可以提前生成文章主体,在用户请求页面时立即返回;评论区域则在请求阶段查询数据库,并通过流式渲染填充到页面中。
最终效果是:
文章像静态网站一样快速,评论像动态应用一样实时。
PPR 的全称是 Partial Prerendering,中文通常翻译为部分预渲染。
它是一种将静态预渲染与动态服务端渲染组合在同一个路由中的渲染模式。
在传统的 Next.js 页面中,一个路由通常会被归类为:
只要页面中的某个组件读取了请求数据或者未缓存的数据,整个页面就可能需要在请求阶段动态渲染。
PPR 改变了这种以页面为单位的划分方式。
在 PPR 模式下,Next.js 会尝试在构建阶段预渲染组件树。能够提前确定的部分会被写入静态 HTML,而依赖请求或实时数据的部分会成为页面中的动态区域。
例如,一个博客详情页可以被拆分成下面几个部分:
其中:
| 页面区域 | 渲染方式 | 原因 | | ------------- | ---------- | ---------------------- | | 网站导航 | 静态预渲染 | 所有用户看到的内容相同 | | 文章标题 | 静态或缓存 | 文章不会频繁变化 | | Markdown 正文 | 静态或缓存 | 内容更新频率较低 | | 文章目录 | 静态预渲染 | 可以根据文章提前计算 | | 评论列表 | 动态渲染 | 评论可能随时增加 | | 登录状态 | 动态渲染 | 依赖当前请求的 Cookie | | 评论输入框 | 客户端交互 | 需要状态和事件处理 |
PPR 最重要的思想是:
静态和动态不再是页面级别的选择,而是组件级别的组合。
开启 PPR 后,Next.js 会在构建阶段分析页面的组件树。
对于不依赖网络数据、请求信息或者其他运行时数据的组件,Next.js 可以直接完成预渲染,并将结果加入静态 Shell。
对于无法在构建阶段完成的组件,我们需要明确告诉 Next.js 如何处理:
use cache 缓存结果,使其能够加入静态 Shell<Suspense> 将其作为动态区域,推迟到请求阶段渲染假设博客页面包含文章和评论:
export default function PostPage() {
return (
<main>
<Article />
<Comments />
</main>
)
}如果 Comments 会实时查询数据库,那么它无法在构建阶段稳定地完成预渲染。
我们可以使用 Suspense 将评论区域隔离:
import { Suspense } from 'react'
export default function PostPage() {
return (
<main>
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</main>
)
}这样,Next.js 就可以先生成文章部分的静态 HTML。
用户访问页面时,服务器可以立即返回:
<main>
<article>
<!-- 已经预渲染完成的文章 -->
</article>
<section>
<!-- 评论骨架屏 -->
</section>
</main>与此同时,服务器开始查询评论数据。
查询完成后,评论区域通过流式响应继续发送到浏览器,并替换原来的骨架屏。
整个过程可以表示为:
用户不需要等待评论查询完成,就能先看到文章主体。
这就是 PPR 带来的核心体验:
让慢数据不再阻塞快内容。
理解 PPR 最简单的方法,是将它与传统的 SSG 和 SSR 进行比较。
静态站点生成会在构建阶段完成整个页面的渲染。
它的优点是访问速度非常快。
但对于评论这样的实时数据来说,静态页面中的内容可能无法及时更新。
服务端渲染会在用户访问页面时查询数据并生成 HTML。
它能够保证数据新鲜,但用户必须等待整个页面完成渲染。
即使文章内容没有变化,也需要重新读取并渲染。
PPR 将两种模式组合起来:
文章主体立即返回,评论稍后通过流式渲染补充。
三者可以总结为:
| 渲染模式 | 文章内容 | 评论内容 | 首屏速度 | 数据实时性 | | -------- | ---------- | ---------- | ---------------- | ---------- | | SSG | 构建时生成 | 构建时生成 | 快 | 较低 | | SSR | 请求时生成 | 请求时生成 | 取决于最慢的数据 | 高 | | PPR | 提前生成 | 请求时生成 | 快 | 高 |
PPR 并不是一种完全独立的新渲染方式。
它更像是一种组合策略:
使用静态预渲染构建页面骨架,使用流式服务端渲染填充动态区域。
在 Next.js 16 中,PPR 被整合进了 Cache Components。
我们需要在 next.config.ts 中开启 cacheComponents:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig开启之后,我们不再需要单独配置:
experimental: {
ppr: true,
}cacheComponents 会统一启用与下面这些能力相关的渲染模型:
use cachecacheLifecacheTag需要注意的是,在 Cache Components 模式下,未缓存的数据访问默认发生在请求阶段。
如果某个组件读取了未缓存数据,我们应该:
<Suspense> 包裹它,将其声明为动态区域use cache 缓存它,让它参与预渲染否则,开发或构建时可能会遇到类似错误:
Uncached data was accessed outside of <Suspense>这个错误并不是在阻止我们使用动态数据。
它是在要求我们明确表达自己的渲染意图:
这部分数据究竟应该被缓存,还是应该在请求阶段加载?
接下来,我们实现一个支持 PPR 的博客详情页。
目标是:
项目结构如下:
app/
└── posts/
└── [slug]/
├── actions.ts
├── page.tsx
└── components/
├── article.tsx
├── comment-form.tsx
├── comment-list.tsx
└── comment-skeleton.tsx
server/
├── queries/
│ ├── comment.query.ts
│ └── post.query.ts
└── db.ts文章内容不会频繁变化,因此非常适合缓存。
我们可以为文章查询添加 use cache:
// server/queries/post.query.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/server/db'
export async function findPostBySlug(slug: string) {
'use cache'
cacheLife('weeks')
cacheTag(`post:${slug}`)
return db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.slug, slug),
})
}这里做了三件事情。
use cacheuse cache 表示这个函数的返回结果可以被缓存。
函数参数会参与缓存键的生成,因此:
findPostBySlug('nextjs-ppr')和:
findPostBySlug('react-server-components')会生成不同的缓存条目。
cacheLife('weeks')cacheLife 用于声明缓存生命周期。
博客文章通常不会频繁修改,因此可以使用较长的缓存时间。
cacheLife('weeks')这并不意味着文章修改后必须等待一周才能更新。
我们仍然可以通过缓存标签主动让指定文章失效。
cacheTag下面这段代码为文章缓存添加了标签:
cacheTag(`post:${slug}`)当 CMS、GitHub Webhook 或后台管理系统更新文章后,可以精准地重新验证这篇文章:
revalidateTag(`post:${slug}`, 'max')这样不会误伤其他文章的缓存。
文章组件调用已经缓存的数据查询:
// app/posts/[slug]/components/article.tsx
import { notFound } from 'next/navigation'
import { findPostBySlug } from '@/server/queries/post.query'
interface ArticleProps {
slug: string
}
export async function Article({ slug }: ArticleProps) {
const post = await findPostBySlug(slug)
if (!post) {
notFound()
}
return (
<article>
<header>
<h1>{post.title}</h1>
<p>{post.description}</p>
<time dateTime={post.createdAt.toISOString()}>
{post.createdAt.toLocaleDateString('zh-CN')}
</time>
</header>
<div>{post.content}</div>
</article>
)
}因为 findPostBySlug 使用了 use cache,文章组件可以在预渲染阶段获得数据,并成为静态 Shell 的一部分。
真实项目中,post.content 可能是:
无论文章正文采用什么格式,只要文章查询处于缓存作用域中,它就可以参与预渲染。
评论和文章不同。
评论随时可能发生变化,用户通常希望看到最新结果。
因此,在最强调实时性的方案中,我们不为评论查询添加 use cache:
// server/queries/comment.query.ts
import { db } from '@/server/db'
export async function findCommentsByPostId(postId: string) {
return db.query.comments.findMany({
where: (comments, { eq }) => eq(comments.postId, postId),
orderBy: (comments, { desc }) => [desc(comments.createdAt)],
with: {
user: true,
},
})
}这是一个未缓存的数据库查询。
在 Cache Components 模式下,它会被视为请求阶段的数据访问。
然后创建评论列表组件:
// app/posts/[slug]/components/comment-list.tsx
import { findCommentsByPostId } from '@/server/queries/comment.query'
interface CommentListProps {
postId: string
}
export async function CommentList({ postId }: CommentListProps) {
const comments = await findCommentsByPostId(postId)
if (comments.length === 0) {
return (
<div>
<h2>评论</h2>
<p>暂时还没有评论,来发表第一条评论吧。</p>
</div>
)
}
return (
<section>
<h2>评论({comments.length})</h2>
<ul>
{comments.map(comment => (
<li key={comment.id}>
<header>
<strong>{comment.user.name}</strong>
<time dateTime={comment.createdAt.toISOString()}>
{comment.createdAt.toLocaleString('zh-CN')}
</time>
</header>
<p>{comment.content}</p>
</li>
))}
</ul>
</section>
)
}由于这个组件会访问未缓存的数据,所以它不能直接放在页面的预渲染区域中。
我们需要使用 Suspense 为它创建一个动态边界。
动态评论加载期间,页面需要显示一个有意义的占位内容:
// app/posts/[slug]/components/comment-skeleton.tsx
export function CommentSkeleton() {
return (
<section aria-busy="true" aria-label="正在加载评论">
<h2>评论</h2>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-full animate-pulse rounded bg-muted" />
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</section>
)
}骨架屏本身不依赖动态数据,因此可以直接加入静态 Shell。
这意味着用户收到的初始 HTML 中已经包含:
只有真正的评论列表需要等待数据库查询。
现在可以在页面中组合这些组件:
// app/posts/[slug]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { findPostBySlug } from '@/server/queries/post.query'
import { Article } from './components/article'
import { CommentForm } from './components/comment-form'
import { CommentList } from './components/comment-list'
import { CommentSkeleton } from './components/comment-skeleton'
interface PostPageProps {
params: Promise<{
slug: string
}>
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = await findPostBySlug(slug)
if (!post) {
notFound()
}
return (
<main>
<Article slug={slug} />
<hr />
<CommentForm postId={post.id} />
<Suspense fallback={<CommentSkeleton />}>
<CommentList postId={post.id} />
</Suspense>
</main>
)
}这里最关键的是:
<Suspense fallback={<CommentSkeleton />}>
<CommentList postId={post.id} />
</Suspense>这段代码实际上是在告诉 Next.js:
评论列表不需要阻塞整个页面。先生成页面的其他部分,在收到请求之后再查询评论,并将结果流式发送给浏览器。
最终,这个页面形成了下面的渲染结构:
评论表单需要处理输入状态和用户交互,因此适合使用 Client Component。
首先创建 Server Action:
// app/posts/[slug]/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { db } from '@/server/db'
export async function createComment(formData: FormData) {
const postId = formData.get('postId')
const content = formData.get('content')
if (typeof postId !== 'string' || typeof content !== 'string') {
throw new Error('评论参数不正确')
}
const normalizedContent = content.trim()
if (!normalizedContent) {
throw new Error('评论内容不能为空')
}
if (normalizedContent.length > 1000) {
throw new Error('评论内容不能超过 1000 个字符')
}
const session = await auth.api.getSession({
headers: await import('next/headers').then(({ headers }) => headers()),
})
if (!session) {
throw new Error('请先登录后再发表评论')
}
await db.insert(comments).values({
postId,
userId: session.user.id,
content: normalizedContent,
})
revalidatePath(`/posts/${postId}`)
}然后创建评论表单:
// app/posts/[slug]/components/comment-form.tsx
'use client'
import { useActionState } from 'react'
import { createComment } from '../actions'
interface CommentFormProps {
postId: string
}
export function CommentForm({ postId }: CommentFormProps) {
const [state, action, pending] = useActionState(
async (_state: { error?: string }, formData: FormData) => {
try {
await createComment(formData)
return {}
} catch (error) {
return {
error: error instanceof Error ? error.message : '评论提交失败',
}
}
},
{}
)
return (
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<label htmlFor="comment-content">发表评论</label>
<textarea
id="comment-content"
name="content"
maxLength={1000}
placeholder="写下你的评论……"
required
/>
{state.error ? <p role="alert">{state.error}</p> : null}
<button type="submit" disabled={pending}>
{pending ? '正在提交……' : '发表评论'}
</button>
</form>
)
}评论写入数据库后,页面会重新获取动态区域的数据,从而显示最新评论。
不过,上面的示例使用 postId 生成页面路径并不一定符合真实项目的路由设计。如果你的页面路径使用 slug,更合理的方式是同时将 slug 传给 Server Action:
<input type="hidden" name="slug" value={slug} />然后执行:
revalidatePath(`/posts/${slug}`)并不是。
评论是否缓存,需要根据产品需求决定。
export async function findCommentsByPostId(postId: string) {
return db.query.comments.findMany({
where: (comments, { eq }) => eq(comments.postId, postId),
})
}优点:
缺点:
这种方案适合:
如果博客流量较大,可以为评论添加短期缓存:
// server/queries/comment.query.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/server/db'
export async function findCommentsByPostId(postId: string) {
'use cache'
cacheLife('minutes')
cacheTag(`comments:${postId}`)
return db.query.comments.findMany({
where: (comments, { eq }) => eq(comments.postId, postId),
orderBy: (comments, { desc }) => [desc(comments.createdAt)],
with: {
user: true,
},
})
}评论提交后,通过 updateTag 让缓存立即失效:
'use server'
import { updateTag } from 'next/cache'
export async function createComment(formData: FormData) {
const postId = String(formData.get('postId'))
const content = String(formData.get('content')).trim()
await db.insert(comments).values({
postId,
content,
userId: 'current-user-id',
})
updateTag(`comments:${postId}`)
}updateTag 适合评论提交这样的场景,因为用户完成写操作后,通常希望立即看到自己刚刚发布的内容。
它提供的是“读取自己的写入”语义:
用户提交评论后,下一次读取会等待新数据,而不是继续返回旧缓存。
如果评论是通过 Route Handler、Webhook 或后台审核系统更新的,可以使用:
revalidateTag(`comments:${postId}`, 'max')它会将缓存标记为过期,并采用 stale-while-revalidate 行为。
也就是说:
这种方式响应速度更稳定,但刚刚提交的评论可能不会立即出现。
因此可以这样选择:
| 场景 | 推荐方式 |
| ---------------------- | ------------------------------- |
| 评论必须绝对实时 | 不缓存评论 |
| 用户提交后必须立即看到 | updateTag |
| 可以短暂显示旧数据 | revalidateTag(tag, 'max') |
| 评论更新频率较低 | use cache + cacheLife |
| 后台审核通过后刷新 | Route Handler + revalidateTag |
看到动态区域之后,有些人可能会认为:
这不就是先生成文章,然后在浏览器里通过
useEffect请求评论吗?
两者看起来相似,但实现方式完全不同。
客户端请求通常是:
'use client'
export function Comments() {
const [comments, setComments] = useState([])
useEffect(() => {
fetch('/api/comments')
.then(response => response.json())
.then(setComments)
}, [])
return <CommentList comments={comments} />
}这种方式需要经历:
useEffect而 PPR 中的动态评论仍然可以是 Server Component:
export async function CommentList({ postId }: CommentListProps) {
const comments = await findCommentsByPostId(postId)
return <CommentItems comments={comments} />
}它不需要为了查询评论而向浏览器发送额外的业务 JavaScript。
评论 HTML 由服务器生成,并通过 React Server Components 的流式响应发送到浏览器。
两种方式的区别可以总结为:
| 特性 | 客户端请求 | PPR 动态区域 | | ------------------------- | ------------ | ------------------ | | 数据查询位置 | 浏览器 | 服务器 | | 是否需要额外 API | 通常需要 | 不一定需要 | | 是否增加客户端 JavaScript | 是 | 查询逻辑不会 | | 是否能直接访问数据库 | 否 | 可以 | | 是否支持流式 HTML | 通常不支持 | 支持 | | 是否需要等待水合后请求 | 是 | 否 | | 敏感数据是否暴露给客户端 | 需要谨慎处理 | 查询代码只在服务端 |
PPR 并不是把动态内容推给客户端处理。
它做的是:
让服务器可以分阶段返回页面。
PPR 并不是单独完成动态渲染的。
它依赖 React Suspense 来划分静态区域和动态区域。
<Suspense fallback={<CommentSkeleton />}>
<CommentList postId={postId} />
</Suspense>这里的 Suspense 同时承担了两个职责。
当评论还没有准备好时,展示:
<CommentSkeleton />它告诉 Next.js:
这个子树可以晚一点完成,不需要阻塞页面的其他部分。因此,设计 PPR 页面时,Suspense 边界的位置非常重要。
如果边界过大:
<Suspense fallback={<PageSkeleton />}>
<Article />
<Comments />
</Suspense>评论查询可能会导致文章也只能显示骨架屏。
如果边界足够精细:
<Article />
<Suspense fallback={<CommentSkeleton />}>
<Comments />
</Suspense>文章可以立即出现,只有评论区域需要等待。
这体现了一个重要原则:
Suspense 应该尽量靠近真正的动态数据。
PPR 页面不只能有一个动态区域。
博客详情页可能同时包含:
我们可以分别为它们创建 Suspense 边界:
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
return (
<main>
<Article slug={slug} />
<Suspense fallback={<ViewCountSkeleton />}>
<ViewCount slug={slug} />
</Suspense>
<Suspense fallback={<ReactionSkeleton />}>
<ReactionPanel slug={slug} />
</Suspense>
<Suspense fallback={<CommentSkeleton />}>
<CommentList slug={slug} />
</Suspense>
</main>
)
}这些动态区域可以独立加载。
如果阅读量查询很快,它可以先显示。
即使评论查询较慢,也不会阻塞阅读量和表情回应。
这不仅提升了首屏速度,也避免了不同数据源之间相互拖累。
PPR 提供了一套更灵活的渲染机制,但并不意味着开启之后页面就一定更快。
性能仍然取决于我们如何划分组件。
下面的代码会让整个页面等待评论查询:
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const comments = await findCommentsBySlug(slug)
return (
<main>
<Article slug={slug} />
<CommentList comments={comments} />
</main>
)
}虽然 CommentList 看起来是独立组件,但数据已经在页面顶部被 await。
更好的方式是让动态组件自己读取数据:
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
return (
<main>
<Article slug={slug} />
<Suspense fallback={<CommentSkeleton />}>
<CommentList slug={slug} />
</Suspense>
</main>
)
}然后在 CommentList 内部查询:
export async function CommentList({ slug }: { slug: string }) {
const comments = await findCommentsBySlug(slug)
return <CommentItems comments={comments} />
}下面的写法会让所有动态区域共用一个加载状态:
<Suspense fallback={<PageSkeleton />}>
<ViewCount />
<ReactionPanel />
<CommentList />
</Suspense>只要其中一个组件较慢,整个区域都无法显示。
更合理的方式是分别创建边界:
<Suspense fallback={<ViewCountSkeleton />}>
<ViewCount />
</Suspense>
<Suspense fallback={<ReactionSkeleton />}>
<ReactionPanel />
</Suspense>
<Suspense fallback={<CommentSkeleton />}>
<CommentList />
</Suspense>use cache 并不是越多越好。
下面这些内容通常不应该放入公共缓存:
缓存之前应该先问自己:
不同用户是否可以安全地获得同一份结果?
如果答案是否定的,就不应该直接使用公共 use cache。
PPR 和 ISR 经常同时出现,但它们解决的不是同一个问题。
ISR 的核心问题是:
已经生成的缓存内容应该在什么时候更新?
PPR 的核心问题是:
一个页面中的哪些部分应该提前生成,哪些部分应该在请求时生成?
例如,在博客页面中:
文章正文可以使用:
'use cache'
cacheLife('weeks')
cacheTag(`post:${slug}`)这部分主要体现缓存和重新验证机制。
评论组件使用:
<Suspense fallback={<CommentSkeleton />}>
<CommentList postId={postId} />
</Suspense>这部分主要体现 PPR 的动态区域。
因此,一个页面完全可以同时使用 PPR 和类似 ISR 的缓存更新策略。
两者的关系可以总结为:
| 技术 | 解决的问题 |
| --------------- | ------------------------------ |
| PPR | 一个页面如何组合静态与动态区域 |
| use cache | 哪些函数或组件结果应该缓存 |
| cacheLife | 缓存可以使用多久 |
| cacheTag | 如何标记缓存 |
| revalidateTag | 如何让缓存进入后台重新验证 |
| updateTag | 如何让用户立即看到自己的写入 |
| Suspense | 哪些组件可以延迟并流式渲染 |
PPR 关注的是页面结构。
ISR 和 Cache Components 的重新验证机制关注的是数据生命周期。
PPR 最适合页面主体相对稳定,但局部区域需要动态数据的场景。
静态部分:
动态部分:
静态或缓存部分:
动态部分:
静态部分:
动态部分:
静态或缓存部分:
动态部分:
它们都有同一个特点:
页面的大部分内容可以提前生成,只有少部分内容必须在请求阶段确定。
如果页面几乎所有内容都依赖当前用户,那么 PPR 带来的收益可能有限。
例如:
这些页面几乎不存在可复用的静态 Shell。
此时,与其强行拆分静态区域,不如直接使用动态 Server Components、客户端数据请求或者专门的实时通信方案。
PPR 不是为了消灭动态渲染。
它解决的是:
不要因为页面中存在一小块动态内容,就放弃其余内容的预渲染能力。
传统的渲染模式习惯以页面为单位思考:
PPR 则要求我们以组件树为单位思考:
以博客评论系统为例,我们可以将页面拆分为:
文章正文使用 use cache、cacheLife 和 cacheTag,获得接近静态页面的访问性能。
评论列表放入 Suspense 边界,在请求阶段查询数据库,并通过流式渲染发送给浏览器。
评论表单使用 Client Component 提供交互,通过 Server Action 写入数据库。
评论提交后,再根据一致性要求选择:
updateTagrevalidateTag(tag, 'max')最终,我们不再需要为了几条实时评论,将整篇文章变成动态页面。
这正是 PPR 最有价值的地方:
它保留了静态页面的速度,同时赋予局部区域动态应用的能力。
PPR 并不是简单地把一个页面切成“静态一半”和“动态一半”。
它真正带来的是一种新的页面设计思维:
缓存应该是局部的,动态也应该是局部的。
当静态、缓存、动态和客户端交互可以在同一个组件树中自由组合时,Next.js 页面不再需要在 SSG 和 SSR 之间做出非黑即白的选择。
文章可以保持稳定与快速,评论可以保持实时与鲜活。
这或许就是部分预渲染最准确的定义:
只在真正需要动态的地方动态渲染。