Next.js Streaming、Suspense 与 Partial Prerendering
这一块最能看出 App Router 和老式 SSR 的差别。
如果还按过去那种整页 SSR 的习惯去想,页面大概会被理解成:
- 先等所有数据准备好
- 再一次性返回完整 HTML
但现在的 Next 不是这个节奏。页面不一定非得等“所有东西都准备好”才返回,静态部分、缓存部分和运行时部分可以拆开处理。
先分别看三个概念
Streaming
指的是:服务端把已经准备好的 UI 片段先发出来,慢的部分后补。
Suspense
指的是:给一段可能会慢下来的 UI 设一个加载边界。
Partial Prerendering
指的是:先生成一个静态壳层,再把动态部分以流式方式补进来。
在当前官方文档里,这条线已经更多通过 cacheComponents 来组织。也就是说,现在更推荐的理解方式是:
cacheComponents是能力入口Suspense是边界工具- Partial Prerendering 是最终形成的渲染效果
为什么这块重要
因为它决定了页面在用户眼里会是什么节奏:
- 是整页一直白着,最后一下子出来
- 还是外壳先到,慢数据区域逐步填充
- 是整页全动态
- 还是静态壳和动态块混合
这直接影响:
- 首屏观感
- 感知速度
- 可交互体验
- 组件结构设计
最简单的 Streaming 例子
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<RevenueChart />
</Suspense>
</div>
);
}
这里的关键点是:
h1和外层结构可以先返回RevenueChart如果慢,就先展示 fallback- 数据准备好后再流式替换掉 fallback
loading.tsx 和 Suspense 的关系
loading.tsx 更像路由段 级的默认 loading UI。
app/dashboard/loading.tsx
它适合:
- 整个路由段切换时的加载状态
- 页级骨架屏
而 Suspense 更适合:
- 页面内部局部加载边界
- 某个慢组件单独延迟
一个比较实用的理解是:
loading.tsx管路由层Suspense管组件层
为什么 Suspense 边界值得认真设计
边界放得太大,会变成:
- 只要一个慢请求,整大块 UI 一起 loading
边界放得太碎,则会变成:
- 页面到处闪动
- 结构感被打散
比较稳的做法通常是:
- 先按真实业务区块划边界
- 让 fallback 尽量和最终 UI 轮廓接近
- 把慢数据隔离出去,而不是把整个页面包成一个大 fallback
现在怎么理解 Partial Prerendering
历史上 PPR 是一个实验特性,经常被单独讨论。
当前官方文档的表达已经更进一步:在启用 cacheComponents 后,Next 会把:
- 可预渲染的部分
- 可缓存的部分
- 必须请求时执行的部分
组合成一个静态壳层加动态流式更新的模型。官方文档直接说,这种渲染方式就叫 Partial Prerendering。
所以现在更稳的理解是:
- PPR 不只是“一个老实验名词”
- 它已经和
cacheComponents的渲染模型合并理解
cacheComponents 在做什么
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
官方文档里 强调了几件事:
- 它是 opt-in
- 页面会在 build 阶段尝试提取静态壳
- 运行时数据要么包进
Suspense,要么显式缓存 - 如果访问了未缓存的动态数据又没放进
Suspense,开发和构建时会直接报错
这件事很关键,因为它把“是不是该缓存、是不是该流式、是不是该请求时执行”都变成了显式决策。
use cache 和 Suspense 怎么配合
当前模型里,大致可以这样分:
可以缓存的数据
如果数据不依赖请求时上下文,而且允许一段时间内复用,可以用 use cache。
import { cacheLife } from 'next/cache';
async function ProductList() {
'use cache';
cacheLife('hours');
const products = await db.product.findMany();
return <div>{products.length}</div>;
}
这类结果可以直接进入静态壳。
必须请求时执行的数据
如果逻辑依赖:
cookies()headers()searchParams- 真实请求环境
那就应该明确放进 Suspense 边界。
import { Suspense } from 'react';
export default function Page() {
return (
<Suspense fallback={<div>Loading profile...</div>}>
<RuntimeProfile />
</Suspense>
);
}
connection() 在这条线里的作用
官方 cacheComponents 文档提到,如果一段逻辑需要显式延迟到请求时执行,可以用 connection()。
这尤其适合:
- 非确定性操作
- 必须请求时才应该生成的内容
例如:
import { connection } from 'next/server';
async function RuntimeWidget() {
await connection();
const now = Date.now();
return <div>{now}</div>;
}
然后它仍然应该放进 Suspense。
非确定性内容为什么和这块强相关
像这些内容:
Date.now()Math.random()crypto.randomUUID()
如果没有明确地放到请求时边界里,就很容易和预渲染、缓存、水合一起打架。
这也是为什么水合文档和 cacheComponents 文档都会点到这些操作。
一个比较实用的页面拆分思路
例如商品页:
- 顶部导航:静态壳
- 商品 基础信息:缓存数据
- 库存与购物车:请求时动态块
- 个性化推荐:动态块或短时缓存块
这时页面就不应该只被理解成“静态页”或“动态页”,而是一组不同渲染策略拼起来的页面。
和传统整页 SSR 的差异
传统整页 SSR 更像:
- 所有东西一起等
- 一次性返回
- 慢点拖全页
Streaming + Suspense + PPR 更像:
- 能先出的先出
- 慢块单独占位
- 静态壳优先到达
- 页面节奏更细
最容易踩的坑
1. 把整个页面包进一个大 Suspense
这样虽然也能流式,但收益通常很有限。