一句话说清这条演进主线
如果把 Next.js 这几年的渲染演进浓缩成一句话,我会说:渲染的控制粒度越来越细,缓存的行为越来越显式。
- Pages Router :页面级,靠
getStaticProps/getServerSideProps这种 API 名字决定。粗是粗了点,但好预测。 - App Router 早期 :页面级,靠
fetch参数决定,默认隐式缓存。黑盒、难推断,13/14/15 的默认值还反复横跳。 - PPR:组件级,靠 Suspense 划界,静态壳 + 动态洞。粒度问题解决了,可预测性还是差点意思。
- Cache Components:组件/函数级,由代码指令决定,默认动态、显式缓存。粒度和可预测性一起解决,代价是一次性迁移成本。
理解这一切的钥匙其实就一句话:Rendering strategy is caching strategy(渲染策略即缓存策略)。判断逻辑因此变得很简单:这段数据能不能复用?能 → 静态;不能 → 动态;能复用多久 → ISR。
四种基础模式:CSR / SSG / SSR / ISR
这四种模式不是四选一,而是同一套光谱上的不同取值。先快速过一遍:
| 模式 | HTML 生成时机 | 新鲜度 | 服务端压力 | SEO | 典型场景 |
|---|---|---|---|---|---|
| CSR | 客户端运行时 | 实时 | 极低 | 差 | 后台、工具 |
| SSG | 构建时 | 不变 | 近乎零 | 极好 | 文档、官网 |
| SSR | 每次请求 | 实时 | 高 | 好 | 个性化、交易 |
| ISR | 构建时 + 后台再生 | 准实时 | 低 | 极好 | 电商列表、资讯 |
CSR:客户端渲染
服务器只送空壳 + JS bundle,浏览器执行后再拉数据拼 DOM。
tsx
'use client'
import { useEffect, useState } from 'react'
export function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser)
}, [])
if (!user) return <div>加载中...</div>
return <div>{user.name}</div>
}
代价 :FCP/LCP 慢、SEO 差;收益:服务器压力最低、交互最自由。适合登录后后台、强交互工具。
SSG:静态生成
构建时跑一次数据,生成一份 HTML,所有用户共享,CDN 直接命中。
tsx
// app/about/page.tsx
export default async function AboutPage() {
const data = await fetch('https://cms.example.com/about') // 默认 force-cache
const about = await data.json()
return <h1>{about.title}</h1>
}
代价 :改内容必须重构建(或等 revalidate);收益:极快极便宜。
SSR:动态渲染
每次请求实时跑一遍,HTML 带本次请求的数据。
tsx
export default async function CartPage() {
const data = await fetch('https://api.example.com/cart', {
cache: 'no-store',
})
return <CartList data={await data.json()} />
}
关键点 :一旦路由里调用任何动态函数(cookies()、headers()、searchParams 等),整条路由默认整体降级为动态,也就是"全有或全无"。
ISR:增量静态再生
心智模型是 SSG + 过期策略 ,不是定时任务。它是 stale-while-revalidate:过期后当前请求仍返回旧内容并触发后台再生,下一个请求才拿到新版。
tsx
// 方式一
const data = await fetch('https://cms.example.com/products', {
next: { revalidate: 3600 },
})
// 方式二
export const revalidate = 3600
按需失效:
tsx
revalidatePath('/products')
revalidateTag('products')
三个高频坑:
next dev下 ISR 不生效,必须next build && next start验证。- CDN 叠加缓存导致改了还是旧的,CDN TTL 要和
revalidate对齐。 - 多实例部署时节点间不一致,
revalidateTag默认只失效本实例,需要靠cacheHandler的updateTags写 Redis 做分布式广播。
PPR 与静态 Shell:把页面拆成"壳"和"洞"
PPR(Partial Prerendering)的核心产出是静态 Shell。它是在构建期预渲染出来的那份"不含任何动态数据的 HTML",包含布局、导航、文案、图片等对所有用户都一样的部分,以及每个动态区域对应的 fallback 占位标记。
请求到来时,服务器不用计算就能直接吐出,TTFB ≈ 网络延迟。动态部分在同一连接里稍后流式补进来。
请求期的两条流水线
text
t0 请求到达 → CDN/源站立刻吐出静态 shell
t0 同时 → 源站用 postponedState 恢复渲染,并行发起动态数据请求
t1 shell 到达浏览器 → FCP 瞬间完成
t2 各动态块就绪 → 按 Suspense 边界逐个流式注入、逐个注水
注意这个"同时":不是等 shell 传完才开始算动态部分。
产物形态
构建期会产出两样东西,必须原子性地一起存、一起更新:
- 静态 HTML shell(真 HTML,关 JS 也能看到布局/导航/文案)
postponedState(二进制恢复信息,告诉 React 哪些部分被推迟了、从哪里恢复)
用新 shell 配旧 postponedState(或反过来),动态内容会渲染错乱或 hydration 报错。自托管自写 cacheHandler 时这是最高频事故点。
和普通 SSR 流式的区别
普通 SSR 流式第一个字节也要等最慢的未被 Suspense 包裹的数据;PPR 第一个字节是预渲染好的静态 HTML,不经过计算。
收益集中在弱网 + 高延迟源站 + 页面结构复杂的场景。如果页面小、接口都在 50ms 内返回,提升可能不到 10%,不值得加复杂度。
如何判断一段代码是纯静态还是动态
判断原则只有一条:这个组件及其所有子孙的执行结果,是否依赖"这一次请求"的信息。
- 不依赖 → 可在构建期算好并缓存 → 纯静态(能进壳)
- 依赖 → 必须每次请求重新算 → 动态(只能是洞)
A 类:强动态信号(整条路由降级)
一旦被调用,调用点之上的整个祖先链全部变动态:
cookies()headers()draftMode()connection().prerender === falseexport const dynamic = 'force-dynamic'export const revalidate = 0fetchCache = 'default-no-store'
B 类:局部动态信号(只影响当前 Suspense 边界)
await params、searchParamsusePathnamefetch(url, { cache: 'no-store' })cacheLife('seconds')、revalidate < 5 分钟new Date()、Math.random()、crypto.randomUUID()
C 类:隐蔽信号(最容易漏)
- props 来自动态父级
- children 来自外部
- Context / 全局 store 由上层提供
- 非公开环境变量在构建期求值
- i18n 按
Accept-Language切换 - A/B 实验、灰度标记
三条高频误判
-
'use client' ≠ 动态。Client Component 会被打包进 bundle 并注水,但只要不接收动态 props、不调用动态 API,初始 HTML 完全可以在构建期生成并进壳。静态/动态与 Server/Client 是两条正交的轴。 -
静态性不会向上传染。静态性只向下传染,动态性只向上冒泡到最近的 Suspense 边界。
tsx<Header /> {/* 静态 */} <Cart /> {/* 动态,但只影响自己这个 Suspense 边界 */} <Footer /> {/* 静态 */} -
没写 fetch ≠ 静态 。
cookies()/headers()/params/searchParams都是动态信号,跟有没有发网络请求无关。反之,发了fetch也不一定是动态,fetch(url)默认命中缓存,构建期就执行完了。
决策速查
| 内容 | 处理方式 |
|---|---|
| 纯文案 / Logo / 导航 / 本地字体 | 纯静态,直接进壳 |
| CMS 营销文案、商品标题图文 | 静态(ISR),cacheLife('days') + cacheTag,进壳 |
| 价格 / 库存 | 动态(短命),放 Suspense 里做洞 |
| 购物车 / 已登录头像 | 动态(cookies),洞,或 CSR 加载 |
| 个性化推荐 | 洞;非个性化"热销榜"可提出来进壳 |
| i18n | 用路径前缀(静态),别用 header 推导(动态) |
Footer 里的 new Date().getFullYear() |
动态!会静默冻住构建时的年份,写死或用客户端组件 |
Cache Components:Next.js 16 的范式翻转
为什么改
Vercel 工程师 Aurora Scharff 在《Our Journey with Caching》里吐槽:以前的缓存默认开启,而且在代码里基本看不见,开发者大量时间追查"为什么我没要求缓存却被缓存了"。
原因是 fetch 默认 force-cache,叠加 Full Route Cache / Data Cache / Router Cache 三层,行为要靠记住一堆默认值推断,而且 13→14→15 默认值反复改。
范式翻转:默认动态,显式缓存
ts
// next.config.ts
import { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
开启后:
- 所有数据访问在你缓存之前都是动态的,每次请求实时执行
- 用
'use cache'主动声明哪些页面/组件/函数可以缓存 - PPR 成为 App Router 默认渲染模型
- 页面级 route segment config(
export const revalidate = 60)将被禁用并逐步废弃
官方也诚实提醒:cacheComponents 能保证运行时拿到新鲜数据,但相比提供预渲染内容可能引入额外延迟。它是"正确性优先"的选项,不是无脑加速键。
'use cache' 三个层级
tsx
// 文件级:整个文件所有导出都被缓存
'use cache'
export default async function AboutPage() {
return <h1>About</h1>
}
tsx
// 组件级:只缓存这一块输出
export default function ProductPage() {
return (
<div>
<Header />
<ProductList /> {/* 组件内部声明 'use cache' */}
</div>
)
}
tsx
// 函数级:最细粒度,缓存数据获取本身
import { cacheTag } from 'next/cache'
async function getProducts() {
'use cache'
cacheTag('products')
const res = await fetch('https://api.example.com/products')
return res.json()
}
缓存键由编译器自动生成(基于函数参数、组件 props、闭包变量),同组件不同 props 得到不同缓存条目------这比页面级 revalidate 精细得多。
cacheLife 三段式生命周期
tsx
'use cache'
import { cacheLife } from 'next/cache'
export default async function BlogList() {
cacheLife({
stale: 3600, // 1 小时内直接命中,完全新鲜
revalidate: 7200, // 1~2 小时返回旧内容 + 后台重新验证
expire: 86400, // 超过 24 小时彻底过期,阻塞等待重新生成
})
// ...
}
内置 profile:seconds / minutes / hours / days / weeks / max,也可传自定义对象。
注意:stale 是"被认为不新鲜"的时点(开始后台刷新),expire 才是"必须重新算"的时点。
按需失效四个 API
| API | 语义 | 适用场景 |
|---|---|---|
revalidateTag(tag, cacheLifeProfile) |
按标签失效,SWR:立即返回旧内容后台重算 | 博客、商品目录等允许最终一致 |
updateTag(tag) |
立即刷新,保证读写一致性 | 订单、余额、权限 |
refresh() |
绕过缓存取实时数据 | 单次强一致读取 |
revalidatePath(path) |
按路径失效 | 精确到单个 URL |
注意:16 里 revalidateTag 新增第二个 cacheLife 参数,老代码单参写法需补上。
迁移映射表
| 旧写法 | 新写法 |
|---|---|
fetch(url, { cache: 'force-cache' }) |
'use cache' + cacheLife() |
fetch(url, { cache: 'no-store' }) |
什么都不加(默认就是动态) |
fetch(url, { next: { revalidate: 60 } }) |
'use cache' + cacheLife({ revalidate: 60 }) |
export const revalidate = 60 |
移到组件/函数内的 cacheLife |
experimental.ppr: true |
删掉,cacheComponents: true 已内置 PPR |
res.revalidate() (Pages) |
仍支持,新项目别再用 |
电商首页四层拆分示例
tsx
export default async function HomePage() {
return (
<main>
<Header /> {/* 全站通用,纯静态 */}
<Products /> {/* 'use cache' + cacheLife hours + cacheTag('products') */}
<Recommendations /> {/* 读 userId,默认动态 */}
<Cart /> {/* 读 cookies,默认动态 */}
</main>
)
}
效果:首屏 TTFB 由 Header 静态壳决定(毫秒级、可边缘分发),价格和推荐并行流式注入,购物车最后到位,LCP 不再被最慢接口拖累。
React Suspense vs Vue Suspense:不是同名那么简单
两个框架都叫 <Suspense>,但实现的是两件不同的事。
一句话结论
React 的 Suspense 是一个"中断点":与并发协调器绑定,能在渲染中途抛出 Promise、暂停这棵子树、继续渲染别的部分,服务端分块吐 HTML,客户端挑着注水。它是 PPR、流式 SSR、选择性注水、代码分割的共同地基。
Vue 的 Suspense 是一个"开关" :等 setup() 的 Promise settle 了,就把 #fallback 槽换成 #default 槽。语义清晰好用,但不改变渲染执行模型:服务端要么整体等齐再吐,客户端注水仍是整树一次走完。
所以"Nuxt 目前做不到"的本质不是 Vue 少了个组件,而是 Vue 没有一个可中断的并发渲染器。
对照表
| 维度 | React | Vue |
|---|---|---|
| 触发方式 | 渲染中 throw 一个 Promise | setup() 顶层 await,或 defineAsyncComponent |
| 内部机制 | 协调器捕获 throw,挂起子树,记重试队列 | 内部 pending 计数器,归零后切换插槽 |
| 是否中断 | 是,可被更高优先级更新打断 | 否,setup 一旦开始就跑到结束 |
| 嵌套 | 任意嵌套,每个边界独立决议 | 可嵌套,但父等子,实际是串行等待链 |
| 服务端 | renderToPipeableStream,每边界一个 chunk |
renderToString 默认全量等待;流式能力有限 |
| 注水 | 选择性注水 | 整树一次注水 |
为什么这条差距短期内补不上
React 渲染器是 Fiber + 并发(可中断、优先级队列),Suspense 是渲染器的一等公民特性,与流式渲染、注水调度共用一套基础设施。
Vue 渲染器是单次递归遍历(不可中断),Suspense 是上层语法糖,底层渲染器无感知。要补齐等价于重写半个运行时,而不是加个 API。
Vue / Nuxt 侧的务实逼近方案
按性价比排序:
-
lazy: true + 模板内骨架(性价比最高)vue<script setup> const { data: products, pending } = useAsyncData('products', () => $fetch('/api/products'), { lazy: true, }) </script> <template> <div v-if="pending">骨架屏</div> <ProductList v-else :products="products" /> </template>适合登录后仪表盘、后台、个性化区块。
-
Nuxt Islands
重的、纯服务端的片段抽成 Island,服务端单独渲染,客户端按需拉取。适合搜索建议、评论列表、推荐位。
-
并行化数据获取
很多 Vue 项目吃亏不在"没有 PPR",而是自己把请求写成了串行。修好这一条往往比纠结框架收益大。
-
分层策略:关键内容 SSR,次要内容 CSR
SEO 强依赖内容放页面级
useAsyncData(默认 suspense,保证进 HTML);价格、库存、推荐、评论做成lazy: true或 Island。 -
何时认输换栈
如果硬性指标是 LCP < 1.5s、TTFB < 200ms、百万级 PV、结构复杂且有明显动静分区,Vue 替代方案有天花板,认真评估 Next.js 或 Astro + React Island。
平均接口延迟 100ms 以内、PV 量级一般 → 上 PPR 的收益会被架构复杂度吃掉,Nuxt 4 的开发体验和部署自由度反而是净赚。
落地动作与避坑清单
最小落地动作(五步)
- 画出目标页面的组件树,在每个节点旁标数据来源(文案 / ISR / params / cookies / 实时接口 + 耗时)。
- 按 A/B/C 三类动态信号清单逐节点过一遍,标红动态节点。
- 把所有连续的静态区域圈出来 → 这些就是你的壳;被动态节点切断的地方就是洞。
- 算壳的字节占比和最慢洞的耗时,套收益公式决定值不值得上 PPR。
next build看有没有(PPR)标记,再用关 JS 的方式做一次肉眼验收。
收益公式(定性)
壳的收益 ∝ 壳内内容字节占比 × 被省掉的最慢接口耗时 × 流量规模
壳占 80%、最慢接口 400ms、日均百万 PV → 收益巨大。
页面小、接口都在 50ms 内 → 提升可能不到 10%。
避坑九条
- 别在生产直接拨开关 。
cacheComponents一次性改变全局默认,原本依赖隐式缓存的地方会全部变动态,可能引发源站雪崩。 - 每个缓存条目必须有失效路径 。加
'use cache'同时必须写cacheTag,并确认至少有一个 Server Action / Route Handler 会调revalidateTag/updateTag。 - 区分
revalidateTag和updateTag。前者最终一致(目录类),后者强一致(写后立读)。 - 多实例必须做分布式失效广播 。靠
cacheHandler的updateTags钩子把失效事件写 Redis。 - 对齐 CDN TTL 。CDN 缓存长于
expire会导致失效策略完全失效。 - 私有数据绝不进缓存。用户级数据走默认动态 + Suspense。
revalidate别设太短。低于业务能承受频率会打穿数据库;公共内容分钟级起步,攻略/文档按天计。- 升级必看变更日志。13→16 每轮都有缓存默认值和 fetch 行为改动。
- Pages Router 已在维护模式,新项目一律 App Router;老项目迁移要接受心智成本上升。
写在最后
从 Pages Router 到 Next.js 16,本质是三件事的递进:
- 能力层:SSR / SSG / ISR / CSR 不再是四选一,四种模式可以共存;
- 组合层:PPR 用"静态壳 + Suspense 流式填充"把 SSG 的首字节速度和 SSR 的动态能力合并到同一个 URL 内;
- 控制层:Cache Components 把"哪部分静态、哪部分动态、缓存多久"的决定权从配置文件交还给代码本身。
而 React 与 Vue 在这条路上的分野,根源不在生态、不在工具链,而在渲染器本身。React 有可中断的并发协调器,Suspense 因此能成为"按边界并行决议 + 选择性注水"的地基;Vue 是单次递归遍历,Suspense 只是"等齐了再切换"的语法糖。
不过最后还是要公平地补一句:绝大多数业务场景根本不需要组件级流式。如果页面平均数据延迟在 100ms 以内,PPR 的收益会被架构复杂度吃掉;先把请求并行化、把首屏外数据懒加载、把骨架屏尺寸对齐真实内容做好,这些与框架无关的基本功,收益往往大于框架差异本身。