跳到主要内容

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

这样虽然也能流式,但收益通常很有限。

2. 动态数据没放进 Suspense,也没缓存

当前模型下,这会直接引出开发或构建错误。

3. fallback 写得和最终布局差太远

结果就是页面抖动、结构跳变明显。

4. 把历史版本的 PPR 文章直接当成当前默认行为

现在更稳的方式是按 cacheComponents 主线理解,不要停留在 14 那一代的实验说明上。

一套比较稳的实践顺序

  1. 先按业务块拆页面
  2. 判断每块是静态、缓存还是运行时
  3. 运行时块放进 Suspense
  4. 可缓存块尽量用 use cache
  5. 再观察 fallback 是否自然、边界是否过粗或过碎

推荐继续往下看

  1. 渲染与数据获取
  2. 缓存与重验证
  3. 水合(Hydration)

参考资料