前端每日知识点:Next.js 渲染与缓存:从 Pages Router 到 Cache Components 的范式迁移

一句话说清这条演进主线

如果把 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')

三个高频坑:

  1. next dev 下 ISR 不生效,必须 next build && next start 验证。
  2. CDN 叠加缓存导致改了还是旧的,CDN TTL 要和 revalidate 对齐。
  3. 多实例部署时节点间不一致,revalidateTag 默认只失效本实例,需要靠 cacheHandlerupdateTags 写 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 === false
  • export const dynamic = 'force-dynamic'
  • export const revalidate = 0
  • fetchCache = 'default-no-store'

B 类:局部动态信号(只影响当前 Suspense 边界)

  • await paramssearchParams
  • usePathname
  • fetch(url, { cache: 'no-store' })
  • cacheLife('seconds')revalidate < 5 分钟
  • new Date()Math.random()crypto.randomUUID()

C 类:隐蔽信号(最容易漏)

  • props 来自动态父级
  • children 来自外部
  • Context / 全局 store 由上层提供
  • 非公开环境变量在构建期求值
  • i18n 按 Accept-Language 切换
  • A/B 实验、灰度标记

三条高频误判

  1. 'use client' ≠ 动态。Client Component 会被打包进 bundle 并注水,但只要不接收动态 props、不调用动态 API,初始 HTML 完全可以在构建期生成并进壳。静态/动态与 Server/Client 是两条正交的轴。

  2. 静态性不会向上传染。静态性只向下传染,动态性只向上冒泡到最近的 Suspense 边界。

    tsx 复制代码
    <Header />        {/* 静态 */}
    <Cart />          {/* 动态,但只影响自己这个 Suspense 边界 */}
    <Footer />        {/* 静态 */}
  3. 没写 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 侧的务实逼近方案

按性价比排序:

  1. 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>

    适合登录后仪表盘、后台、个性化区块。

  2. Nuxt Islands

    重的、纯服务端的片段抽成 Island,服务端单独渲染,客户端按需拉取。适合搜索建议、评论列表、推荐位。

  3. 并行化数据获取

    很多 Vue 项目吃亏不在"没有 PPR",而是自己把请求写成了串行。修好这一条往往比纠结框架收益大。

  4. 分层策略:关键内容 SSR,次要内容 CSR

    SEO 强依赖内容放页面级 useAsyncData(默认 suspense,保证进 HTML);价格、库存、推荐、评论做成 lazy: true 或 Island。

  5. 何时认输换栈

    如果硬性指标是 LCP < 1.5s、TTFB < 200ms、百万级 PV、结构复杂且有明显动静分区,Vue 替代方案有天花板,认真评估 Next.js 或 Astro + React Island。

    平均接口延迟 100ms 以内、PV 量级一般 → 上 PPR 的收益会被架构复杂度吃掉,Nuxt 4 的开发体验和部署自由度反而是净赚。

落地动作与避坑清单

最小落地动作(五步)

  1. 画出目标页面的组件树,在每个节点旁标数据来源(文案 / ISR / params / cookies / 实时接口 + 耗时)。
  2. 按 A/B/C 三类动态信号清单逐节点过一遍,标红动态节点。
  3. 把所有连续的静态区域圈出来 → 这些就是你的壳;被动态节点切断的地方就是洞。
  4. 算壳的字节占比和最慢洞的耗时,套收益公式决定值不值得上 PPR。
  5. next build 看有没有 (PPR) 标记,再用关 JS 的方式做一次肉眼验收。

收益公式(定性)

复制代码
壳的收益 ∝ 壳内内容字节占比 × 被省掉的最慢接口耗时 × 流量规模

壳占 80%、最慢接口 400ms、日均百万 PV → 收益巨大。

页面小、接口都在 50ms 内 → 提升可能不到 10%。

避坑九条

  1. 别在生产直接拨开关cacheComponents 一次性改变全局默认,原本依赖隐式缓存的地方会全部变动态,可能引发源站雪崩。
  2. 每个缓存条目必须有失效路径 。加 'use cache' 同时必须写 cacheTag,并确认至少有一个 Server Action / Route Handler 会调 revalidateTag / updateTag
  3. 区分 revalidateTagupdateTag。前者最终一致(目录类),后者强一致(写后立读)。
  4. 多实例必须做分布式失效广播 。靠 cacheHandlerupdateTags 钩子把失效事件写 Redis。
  5. 对齐 CDN TTL 。CDN 缓存长于 expire 会导致失效策略完全失效。
  6. 私有数据绝不进缓存。用户级数据走默认动态 + Suspense。
  7. revalidate 别设太短。低于业务能承受频率会打穿数据库;公共内容分钟级起步,攻略/文档按天计。
  8. 升级必看变更日志。13→16 每轮都有缓存默认值和 fetch 行为改动。
  9. 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 的收益会被架构复杂度吃掉;先把请求并行化、把首屏外数据懒加载、把骨架屏尺寸对齐真实内容做好,这些与框架无关的基本功,收益往往大于框架差异本身。

相关推荐
名字还没想好☜3 天前
React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
前端·javascript·react.js·ecmascript·react·next.js
Linguwen3 天前
AI外贸建站04|三个账号第一次联动:让Codex写一个网页,从生成到上线走完整条流水线
next.js·外贸独立站·企业官网·外贸建站·ai建站
Linguwen3 天前
AI外贸建站03|建站三件套:GitHub、Next.js、Codex各干什么,为什么别再用模板建站
next.js·外贸独立站·企业官网·外贸建站·ai建站
古夕5 天前
my-first-ai-web_学习记录05——NextAuth Adapter 存储用户信息
typescript·全栈·next.js
10年前端老司机5 天前
别卷CRUD了!前端用Next.js+LangChain.js,低成本冲进AI高薪赛道
前端·langchain·next.js
为你学会写情书7 天前
用 Redis 当数据库?Next.js 笔记系统的数据层设计
next.js
为你学会写情书7 天前
Next.js 16 全栈实战:从零搭一个 Markdown 笔记系统
next.js
Asize9 天前
框架的说明书是写给 AI 看的:我用 Next.js 搭了个博客
人工智能·代码规范·next.js
触底反弹12 天前
🚀 Next.js 全栈项目数据库连接实战:Drizzle ORM + Supabase 从零到跑通
postgresql·orm·next.js