Chapter 03
渲染:静态、动态与流式
上一章建立了边界——组件默认在服务端渲染、'use client' 划出客户端 island。但"在服务端渲染"本身还分两种时机:构建时算好一次,还是每次请求重新算。这一章讲这条分界,以及如何用流式把"快的静态外壳"和"慢的动态内容"缝在同一个页面里。
本章你将建立的 schema
- 静态渲染(构建 / 重新验证时渲染一次,结果可复用)vs 动态渲染(每次请求渲染)
- 一个路由默认尽量静态;一旦读取"请求时才知道的数据"(
cookies()/headers()/searchParams),它(及其子树)转为动态 <Suspense>是流式的单元:先发不依赖慢数据的外壳,慢的部分边算边流式补上;loading.tsx就是自动的 Suspense 边界- Partial Prerendering(PPR)/ Cache Components:让同一个路由里"静态外壳 + 动态洞"共存
3.1两种渲染时机:静态 vs 动态
静态渲染在构建时(或重新验证时)发生一次,所有访问者拿同一份结果;动态渲染在每次请求时发生,可因人因时而不同。
静态结果是请求到来之前就算好的一份产物,因此能被 CDN / 缓存层原样复用——命中时几乎不碰应用服务器,延迟接近读一个静态文件。动态渲染则在每次请求时现算:能根据这次请求的身份、参数、时间产出不同结果,代价是每个请求都要占用一次服务端计算与等待。
App Router 默认把路由往静态那一侧推,除非代码用到了只有请求时才存在的信息。这条默认值的依据是:大多数页面对所有人是一样的,没理由为每个访问者重算一遍。
这条分界对一个 React 开发者是新的。纯客户端的 React(SPA)里不存在"渲染时机"这回事——一切都在浏览器运行时发生,服务端只发一个空壳加一包 JS。Pages Router 时代有了选择,但它是页面级二选一:要么整页 getStaticProps(构建时),要么整页 getServerSideProps(请求时),由你在文件里显式选定。App Router 取消了这个显式开关,把选择下沉到一个行为信号——这个路由有没有碰请求时才有的数据。碰了就动态,没碰就静态,由渲染器推断,而非手动声明。
3.2什么把路由"转成"动态
调用动态 API(cookies()、headers()、读 searchParams)等于告诉 Next"这里需要请求时的信息",于是从该点往下的子树被标成动态渲染。
这些 API 取的是只有请求真正到达时才存在的值——这次请求带的 cookie、这次请求的 header、这次 URL 上的查询参数。构建时这些值根本不存在。所以一旦渲染过程中调用了它们,渲染器立刻知道这棵子树无法在构建时算出,于是把从该点起的整条渲染路径标为请求时渲染。
这件事是会传染的:渲染时机沿组件树往外扩散到包含它的路由段。在 layout 顶层读一次 cookie,就足以把这个 layout 覆盖下的整页拖成动态。
注意它和 02 章那条 'use client' 边界是两条不同的轴,容易混在一起。'use client' 决定的是"这段代码在哪台机器上运行"(服务端还是浏览器);动态 API 决定的是"这段服务端渲染什么时候发生"(构建时还是请求时)。一个组件可以是 Server Component(在服务端跑)同时是静态渲染(构建时跑);也可以是 Server Component 但因为读了 cookie 而变成动态渲染。机器在哪、时机在何时,互不蕴含。
// 仍是 Server Component(没有 'use client')
import { cookies } from 'next/headers'
export async function Greeting() {
// cookies() 在 16 起是 async,必须 await
// 一旦调用它,这棵子树就无法在构建时算出 → 转为动态渲染
const store = await cookies()
const name = store.get('username')?.value ?? '访客'
return <p>欢迎回来,{name}</p> {/* 因人而异,只能请求时算 */}
}
// page 的 searchParams 也是请求时信息,16 起是 Promise
export default async function SearchPage(
{ searchParams }: { searchParams: Promise<{ q?: string }> }
) {
const { q } = await searchParams // 读它 → 本路由转为动态渲染
const results = await search(q)
return <ResultList items={results} />
}
greeting.tsxcookies()(以及 headers())来自 next/headers,16 起一律是 async。await 它就等于向渲染器声明"这里依赖请求时的值",渲染器据此把该子树标为动态。
search/page.tsxsearchParams 是 page 的一个 async prop。它的值来自 URL 的查询串,同样只有请求时才确定,因此读它会把整个 /search 路由转为动态渲染。
在高层(尤其是 root layout.tsx)不经意调用 headers() / cookies()——比如为了读一个主题色或语言偏好——会把这个 layout 覆盖下的每一个页面都拖成动态渲染,连本可完全静态的营销首页也不例外。症状是构建时本该预渲染的页面全变成请求时现算,CDN 命中率塌掉。根因不是某个页面写错,而是动态信号被放在了树的太靠上的位置。
一个营销首页的顶层 layout.tsx 里加了一句 const theme = (await cookies()).get('theme')。这个首页(以及该 layout 下的所有页面)还能保持静态渲染吗?
3.3流式渲染与 Suspense
用 <Suspense fallback={…}> 包住依赖慢数据的部分,Next 先把外壳和 fallback 发给浏览器,慢部分在服务端算好后通过分块传输乱序补到对应占位。
没有流式时,服务端渲染是"全有或全无":整页所有数据都 ready 了才能把 HTML 发出去,于是最慢的那一次查询决定了整页的首字节时间。一段需要 800ms 的评论查询,会让本来 50ms 就能出来的标题和价格一起卡 800ms。
流式把这个"一起等"拆开:渲染遇到 <Suspense> 边界时,先把边界外的内容和边界内的 fallback 占位立即输出,不为被包住的子树阻塞。被包住的子树在服务端 ready 后,它的 HTML / RSC payload 作为后续 chunk 通过 chunked transfer encoding(分块传输编码)继续发出——这些 chunk 可以乱序到达,浏览器收到后按标记把每块替换进对应占位。所以"慢的一块"不再卡住"快的整页"。
这正是 01 章那个 loading.tsx 的底层:Next 会自动用一个 <Suspense> 把对应的 page 包起来,而 loading.tsx 的内容就是这个 Suspense 的 fallback。换句话说,loading.tsx 是"整页级"的自动 Suspense 边界;手写 <Suspense> 则让你在页面内部更细地划分哪块先到、哪块后到。
import { Suspense } from 'react'
async function Reviews({ id }: { id: string }) {
const reviews = await db.reviews.byProduct(id) // 慢:假设 ~800ms
return <ReviewList items={reviews} />
}
export default async function ProductPage(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const product = await db.product.find(id) // 快:标题 / 价格
return (
<main>
<h1>{product.name}</h1> {/* 立即可见,不等 Reviews */}
<Price value={product.price} />
{/* Suspense 从外层包住慢组件;fallback 先占位 */}
<Suspense fallback={<Skeleton />}>
<Reviews id={id} /> {/* ready 后作为 chunk 流式补上 */}
</Suspense>
</main>
)
}
h1 / Price不依赖慢数据,处于 Suspense 边界之外,因此随外壳第一时间发出、立即可见。
Suspense边界要从外层把慢组件包起来——是父组件渲染 <Suspense> 并把 <Reviews> 放进去。把 <Suspense> 写进 Reviews 自己内部是无效的:组件一旦开始 await,它整体就是那个要被挂起的单元,无法自己挂起自己(呼应 02 章 challenge 里"洞由外层留"的同一条规律)。
<Reviews> 在服务端 ready,其 HTML 作为后续 chunk 通过分块传输到达,替换掉骨架占位。注意:这些 chunk 走的是同一个 HTTP 响应、可乱序到达——慢块不再阻塞外壳,是流式让"快的整页"不被"慢的一块"拖住的全部机制。3.4Partial Prerendering 与 Cache Components
PPR 让一个路由在构建时预渲染出静态外壳(把 Suspense 边界当作"洞"),请求时在同一个响应里先发外壳、再流式填充动态洞——静态的快与动态的灵活合并到一条路由里。
§3.1 的模型逼你按整条路由二选一:一个动态信号(比如顶层一次 cookies())就把整页拽到动态那侧,哪怕页面 95% 的内容对所有人都一样。结果是"为了一小块个性化,整页放弃了静态的速度"。
PPR 打破这个二选一。它在构建时渲染出静态外壳,把每个 <Suspense> 边界留成一个"洞"(洞里是动态部分,构建时不渲染、只放 fallback);请求到达时,服务端把这棵渲染到一半的树"恢复"过来,立即发出已经算好的静态外壳,再用 §3.3 的流式机制把各个洞填上。这要求渲染器能暂停再恢复一次渲染——普通 SSG(只会全量构建时渲染)和普通 SSR(只会全量请求时渲染)都做不到,所以 PPR 依赖一个新的渲染能力,而不只是配置开关。
实验旗标 experimental.ppr 已被移除;PPR 不再是独立开关,而是并入了 Cache Components 模型——在 next.config 里开 cacheComponents: true,并在此模型下默认启用这种"静态外壳 + 动态洞"的渲染形态。早期教程里写的 export const experimental_ppr = true 已经过时,照抄会报无效配置。
这套模型下"哪些部分被缓存成静态、缓存多久"是通过 'use cache' 等指令显式声明的——这部分缓存语义留到 05 章细讲。本节只关注渲染形态:一个路由里静态外壳与动态洞如何共存。
<Suspense> 边界所在——PPR 把 §3.1 的"整页二选一"细化为"一页之内静态与动态按 Suspense 边界共存",外壳秒出、洞随后补。§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 一个页面没有读任何
cookies()/headers()/searchParams,也没有不可缓存的数据。它默认是静态还是动态渲染? - 在 root
layout.tsx顶层调用await cookies()会对整站页面的渲染时机产生什么影响?为什么? <Suspense>为什么能让"慢的一块"不拖慢整页?底层靠的是什么传输机制?- (设计层)一个电商商品页:商品标题 / 描述基本不变,价格和库存随时变,"猜你喜欢"因人而异。用本章哪些机制组合,能让首屏尽快可见、又保证价格库存是新鲜的?
答案(先做完再展开)
- 静态。App Router 默认把路由往静态那侧推;没有任何动态信号(动态 API、不可缓存数据)时,这个路由在构建时(或重新验证时)渲染一次,产物被所有请求复用。
- 会把该 layout 覆盖下的每一个页面都拖成动态渲染。因为
cookies()取的是请求时才有的值,调用它即声明"这棵子树要请求时算";而它位于 root layout 顶层,这个动态信号沿组件树向外传染到整个子树,连本可静态的页面也一并转动态、不再构建时预渲染。 - 因为
<Suspense>让渲染遇到边界时先输出边界外内容和边界内的 fallback 占位、不为慢子树阻塞;慢子树在服务端 ready 后,其 HTML / payload 作为后续 chunk 通过 chunked transfer encoding(分块传输编码)继续发出(可乱序),浏览器收到后替换进对应占位。最慢的一块因此不再决定整页的首字节时间。 - 标题 / 描述基本不变 → 让它们留在静态外壳(构建时渲染、可被 CDN 复用);价格 / 库存要新鲜、"猜你喜欢"因人而异 → 各自包进
<Suspense>成为动态洞,请求时流式填充。整体由 PPR / Cache Components 串起来:构建时发出静态外壳保证首屏秒出,动态洞随后通过流式补上保证数据新鲜——价格库存的"多新鲜"再用 05 章的缓存指令调。
一小块个性化,绑住了一整页静态文章
一个页面顶部是"个性化问候"(需要读 cookie 拿用户名),下面是一大段几乎不变的静态文章。当前实现是在页面顶层直接 await cookies() 拿名字,于是整页(包括那段文章)都变成了动态渲染,CDN 完全缓存不了。怎么改造,才能让文章部分保持静态(可被 CDN 缓存)、只有问候那一小块是动态的?
提示(卡住再展开)
问题的根在于动态信号的位置——cookies() 被放在了页面顶层,于是传染到整页。把读 cookie 的逻辑下沉到一个独立的 <Greeting> 组件里,再用 <Suspense fallback={…}> 把它单独包起来;页面顶层不再碰任何动态 API,那段文章于是保持静态、可被 CDN 缓存,只有 <Greeting> 这个洞在请求时动态填充。
这正是 §3.4 PPR / Cache Components 描述的成品形态:"外壳静态 + 洞动态"——静态文章是外壳,问候是那个请求时流式填充的洞。本章给你这种结构的手动版(下沉 + Suspense),PPR 是把它做成默认渲染形态。