迁移到 Cache Components

启用 Cache Components 后,dynamic、revalidate 和 fetchCache 等路由段配置将由 use cache 和 cacheLife 取代。

迁移以即时导航验证为依据。启用 Cache Components 后,Next.js 会在开发期间验证导航到各个路由时能否立即渲染,并通过错误或提示指出会阻塞渲染的代码。

next-cache-components-adoption 技能会借助编程智能体推动迁移,每次处理一项功能,并在每项功能的边界处确认进度。它支持两种模式:

  • 增量模式。先创建一个统一进行机械修改的 PR,让所有路由退出验证,再通过后续 PR 逐项迁移功能。这会自动执行下文的增量采用流程。
  • 直接模式。在一个分支上就地迁移所有路由。

安装该技能:

Terminal · 终端

npx skills add vercel/next.js --skill next-cache-components-adoption

然后向智能体提供以下提示词:

提示词

Adopt Cache Components in this project using the next-cache-components-adoption skill.

或者手动迁移

手动迁移现有应用时,按顺序执行以下步骤:

  1. 在 next.config.ts 中启用 Cache Components。
  1. 确定迁移方式:现在转换所有路由,或者增量采用,先让路由退出验证,再逐个转换。
  1. 根据出现的验证错误和提示进行处理,将每个路由段配置替换为对应的 Cache Components 实现,并用 use cache 缓存尚未缓存的数据,或用 <Suspense> 包裹运行时数据。下文会分别说明每项配置和 API。

现有的 fetch 和 unstable_cache 缓存会继续作为独立的一层运行,因此请根据提示和错误判断需要修改的内容。

某些部分有各自的处理步骤:

以下各节介绍每项配置和 API,以及启用 Cache Components 后应如何处理它们。

启用 Cache Components

Cache Components 要求使用 Next.js 16。如果你使用 Next.js 15 或更早版本,请先按照版本 16 升级指南升级。从更旧版本迁移时,请依次遵循升级指南,升级至 16 后再继续。

然后在 next.config.ts 中启用 cacheComponents 标志:

next.config.ts · TypeScript

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

提示:如果你曾使用 experimental.dynamicIO 或 experimental.useCache,现在由 cacheComponents 替代它们。参见版本 16 升级指南。

启用此标志后,仍然导出 dynamic、revalidate 或 fetchCache 的路由段会报错。请先替换这些配置。以下各节分别解释其处理方式。

退出验证

验证提示或错误表示某个路由无法立即渲染。请按照建议解决:用 use cache 缓存数据,或用 <Suspense> 包裹它。如果暂时不准备处理,可在触发问题的路由段(布局、页面或并行插槽)上将 instant 配置设为 false,稍后再处理。

app/dashboard/layout.tsx · TypeScript

export const instant = false

提示:instant = false 将路由段标记为允许阻塞。它不会强制将路由变为动态路由,因此真正能够预渲染的路由仍会提供静态外壳。它也不会消除同步 IO 导致的构建错误:new Date()、Math.random() 和 crypto.randomUUID() 等调用仍会使预渲染失败。参见增量采用。

增量采用

你不必一次迁移所有路由。instant = false 允许先让整个应用正常构建和运行,再逐个转换路由:

  1. 启用标志并迁移每项路由段配置。按照下文与 dynamic = "force-dynamic"、dynamic = "force-static"、revalidate 和 fetchCache 对应的章节操作。对于带有动态参数的路由,还需遵循 generateStaticParams 的指导。仍能立即渲染的路由不需要进一步处理。
  1. 让尚未准备好的路由退出验证。出现提示或错误时,在触发它的路由段上设置 instant = false。要一次性处理整个应用,可以运行 cache-components-instant-false codemod,它会为所有尚未声明 instant 的 page、layout 和 default 添加退出验证配置:

Terminal · 终端

npx @next/codemod@canary cache-components-instant-false ./app

提示:对于使用 src/ 目录的项目,应传入 ./src/app。路径错误时会报告 0 ok,而不会报错,因此请检查文件数量。

应用会继续构建并提供服务,而已退出验证的路由会延后进行验证。

  1. 修复同步 IO,不能推迟处理。在预渲染期间调用 new Date()、Date.now()、Math.random() 和 crypto.randomUUID() 等方法,会抛出 instant = false 无法消除的构建错误。因此,无论是否退出验证,使用这些调用的路由在问题解决之前都无法构建。把调用移出预渲染外壳,使其在请求时运行:用 <Suspense> 包裹需要调用的部分,并在调用之前调用 connection(),或者将其移入客户端组件。
  1. 每次转换一个路由。移除路由的 instant = false,然后通过 use cache 缓存数据,或通过 <Suspense> 包裹运行时数据,解决相关提示。重复操作,直到没有路由退出验证。

根据验证结果处理

Cache Components 会在开发期间验证路由,并在开发浮层中显示错误和提示。其中一些如下所示,会指出受影响的组件及修复方法:

每张卡片都可以点击,打开包含处理模式、代码示例和权衡说明的页面。逐一解决提示和错误,直到全部消失。完整的验证流程、开发工具和 CI 测试说明,请参见即时导航指南。

提示不会出现在 HTTP 响应中。开发期间,有问题的路由仍会返回 200 和渲染后的 HTML。提示只出现在开发浮层、开发服务器日志或 MCP get_errors 工具中。要查看提示,请阅读浮层内容或查询 MCP。

dynamic = "force-dynamic"

不再需要。默认情况下,所有页面都是动态的。

app/page.tsx · TypeScript

// Before - No longer needed
export const dynamic = 'force-dynamic'

export default function Page() {
  return <div>...</div>
}

app/page.js · JavaScript

// Before - No longer needed
export const dynamic = 'force-dynamic'

export default function Page() {
  return <div>...</div>
}

app/page.tsx · TypeScript

// After - Just remove it
export default function Page() {
  return <div>...</div>
}

app/page.js · JavaScript

// After - Just remove it
export default function Page() {
  return <div>...</div>
}

dynamic = "force-static"

首先将它移除。如果在开发和构建期间检测到尚未处理的未缓存数据访问或运行时数据访问,Next.js 会报错。否则,预渲染步骤会自动提取静态 HTML 外壳。

对于未缓存的数据访问,在尽可能靠近数据访问的位置添加 use cache,并使用较长的 cacheLife,例如 'max',以保留缓存行为。如有需要,也可以将它添加到页面或布局顶部。

对于运行时数据访问(cookies()、headers() 等),错误会指引你用 <Suspense> 包裹它。由于你原先使用的是 force-static,因此必须移除运行时数据访问,避免任何请求时工作。

app/page.tsx · TypeScript

// Before
export const dynamic = 'force-static'

export default async function Page() {
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}

app/page.js · JavaScript

// Before
export const dynamic = 'force-static'

export default async function Page() {
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}

app/page.tsx · TypeScript

import { cacheLife } from 'next/cache'

// After - Use 'use cache' instead
export default async function Page() {
  'use cache'
  cacheLife('max')
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}

app/page.js · JavaScript

import { cacheLife } from 'next/cache'

// After - Use 'use cache' instead
export default async function Page() {
  'use cache'
  cacheLife('max')
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}

revalidate

替换为 cacheLife。使用 cacheLife 函数定义缓存时长,取代路由段配置。

app/page.tsx · TypeScript

// Before
export const revalidate = 3600 // 1 hour

export default async function Page() {
  return <div>...</div>
}

app/page.js · JavaScript

// Before
export const revalidate = 3600 // 1 hour

export default async function Page() {
  return <div>...</div>
}

app/page.tsx · TypeScript

// After - Use cacheLife
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')
  return <div>...</div>
}

app/page.js · JavaScript

// After - Use cacheLife
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')
  return <div>...</div>
}

提示:如果 revalidate 值与内置 cacheLife 配置档('seconds'、'minutes'、'hours'、'days'、'weeks'、'max')不匹配,可选择最接近的一个,或定义符合自身约定的自定义配置档。如果内置配置档的时间设置不符合应用的缓存需求,也可以重新定义内置配置档,包括 default。

fetchCache

不再需要。使用 use cache 后,缓存作用域内的所有数据获取都会自动缓存,因此不再需要 fetchCache。

app/page.tsx · TypeScript

// Before
export const fetchCache = 'force-cache'

app/page.js · JavaScript

// Before
export const fetchCache = 'force-cache'

app/page.tsx · TypeScript

// After - Use 'use cache' to control caching behavior
export default async function Page() {
  'use cache'
  // All fetches here are cached
  return <div>...</div>
}

app/page.js · JavaScript

// After - Use 'use cache' to control caching behavior
export default async function Page() {
  'use cache'
  // All fetches here are cached
  return <div>...</div>
}

fetch 缓存选项

将 cache 和 next 选项迁移到 use cache。

未启用 Cache Components 时,可通过 cache: 'force-cache' 缓存请求,并通过 next: { revalidate, tags } 调整其行为。

启用 Cache Components 后,用带有 use cache 的函数包裹 fetch。该作用域内的 fetch 会自动缓存,revalidate 和 tags 则分别改用 cacheLife 和 cacheTag。

app/page.tsx · TypeScript

// Before
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache',
    next: { revalidate: 3600, tags: ['data'] },
  })
  const data = await res.json()
  return <div>...</div>
}

app/page.js · JavaScript

// Before
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache',
    next: { revalidate: 3600, tags: ['data'] },
  })
  const data = await res.json()
  return <div>...</div>
}

app/page.tsx · TypeScript

// After
import { cacheLife, cacheTag } from 'next/cache'

async function getData() {
  'use cache'
  cacheLife('hours')
  cacheTag('data')
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  return <div>...</div>
}

app/page.js · JavaScript

// After
import { cacheLife, cacheTag } from 'next/cache'

async function getData() {
  'use cache'
  cacheLife('hours')
  cacheTag('data')
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  return <div>...</div>
}

注意持久化方面的区别。fetch 数据缓存会跨部署、跨无服务器实例保留已缓存的响应。

use cache 默认使用内存存储,因此其缓存条目会在无服务器实例销毁时被丢弃,并且仅限于一次部署。要在实例销毁后仍保留存储内容,请使用 use cache: remote 或缓存处理器。即使使用持久化存储,也应预期缓存值会在新部署后重新计算。

unstable_cache

替换为 use cache。

unstable_cache 由 use cache 指令取代。

将被包裹的函数改为带有 'use cache' 指令的函数。缓存键会自动根据参数推导,因此不再需要 key-parts 数组;options 对象则对应 cacheLife 和 cacheTag。

app/lib/data.ts · TypeScript

// Before
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getUser = unstable_cache(
  async (id: string) => {
    return db.query.users.findFirst({ where: eq(users.id, id) })
  },
  ['user'], // cache key prefix
  { tags: ['users'], revalidate: 3600 }
)

app/lib/data.js · JavaScript

// Before
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getUser = unstable_cache(
  async (id) => {
    return db.query.users.findFirst({ where: eq(users.id, id) })
  },
  ['user'], // cache key prefix
  { tags: ['users'], revalidate: 3600 }
)

app/lib/data.ts · TypeScript

// After
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'

export async function getUser(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('users')
  return db.query.users.findFirst({ where: eq(users.id, id) })
}

app/lib/data.js · JavaScript

// After
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'

export async function getUser(id) {
  'use cache'
  cacheLife('hours')
  cacheTag('users')
  return db.query.users.findFirst({ where: eq(users.id, id) })
}

与 fetch 数据缓存一样,unstable_cache 会跨部署和无服务器实例保留缓存值,而 use cache 不会。有关存储的详细说明,请参见上文的 fetch 缓存选项。

React.cache

通常无需修改。React.cache 仍会在一次 React 渲染期间对匹配的调用去重。

但每个缓存函数都有独立的 React 缓存作用域。不同缓存函数中的调用不会共享 React.cache 结果,例如一个缓存函数预加载数据,另一个缓存函数使用该数据的情况。

当辅助函数读取请求数据,并且匹配的调用需要跨缓存函数作用域共享工作时,请将 React 包裹函数替换为 'use cache: private'。如果函数只需要请求作用域内的去重,请使用 cacheLife({ stale: Infinity }),以免降低路由的 stale 时间。私有缓存函数的结果不会在生产环境的不同请求之间存储于服务器缓存中。

按需重新验证(revalidateTag、revalidatePath、updateTag)

按需失效仍通过为缓存数据添加标签,并在事件发生后使其过期来工作。在 use cache 函数内部用 cacheTag 标记数据,取代 fetch 的 next.tags 选项,再根据需要的行为选择失效 API:

  • updateTag:适用于用户必须立即看到结果的数据变更(读己之写)。从服务器操作中调用时,它会使标签过期,让下一次请求等待新数据,而不是提供旧内容。
  • revalidateTag:适用于 stale-while-revalidate。第二个参数必须提供缓存配置档;例如使用 'max',可在后台刷新期间继续提供缓存数据。它可用于服务器操作和路由处理程序。

updateTag 并非 Cache Components 专用,它也适用于之前的缓存模型,不过迁移是采用它的好时机。服务器操作修改数据后,如果用户应立即看到自己做出的更改,就应使用它。

app/actions.ts · TypeScript

'use server'
import { updateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  // Create the post, then show it immediately on the next request
  updateTag('posts')
}

app/actions.js · JavaScript

'use server'
import { updateTag } from 'next/cache'

export async function createPost(formData) {
  // Create the post, then show it immediately on the next request
  updateTag('posts')
}

提示:updateTag 只能在服务器操作中调用;在其他位置调用会抛出异常。对于路由处理程序或 webhook,请改用带有缓存配置档的 revalidateTag。

将 'max' 作为第二个参数传给 revalidateTag,以 stale-while-revalidate 语义使标签过期:

app/api/webhook/route.ts · TypeScript

// Before
import { revalidateTag } from 'next/cache'

export async function POST() {
  revalidateTag('posts')
  return Response.json({ ok: true })
}

app/api/webhook/route.js · JavaScript

// Before
import { revalidateTag } from 'next/cache'

export async function POST() {
  revalidateTag('posts')
  return Response.json({ ok: true })
}

app/api/webhook/route.ts · TypeScript

// After - Pass a cache profile
import { revalidateTag } from 'next/cache'

export async function POST() {
  revalidateTag('posts', 'max')
  return Response.json({ ok: true })
}

app/api/webhook/route.js · JavaScript

// After - Pass a cache profile
import { revalidateTag } from 'next/cache'

export async function POST() {
  revalidateTag('posts', 'max')
  return Response.json({ ok: true })
}

unstable_noStore

不再需要。unstable_noStore(noStore())会让组件退出缓存。启用 Cache Components 后,除非添加 use cache,否则不会缓存任何内容,因此可以移除它。如果组件必须在请求时运行,请在开始工作前调用 connection(),并用 <Suspense> 包裹该组件。

app/page.tsx · TypeScript

// Before
import { unstable_noStore as noStore } from 'next/cache'

export default async function Page() {
  noStore()
  const data = await db.query('...')
  return <div>...</div>
}

app/page.js · JavaScript

// Before
import { unstable_noStore as noStore } from 'next/cache'

export default async function Page() {
  noStore()
  const data = await db.query('...')
  return <div>...</div>
}

app/page.tsx · TypeScript

// After - uncached by default, just remove noStore()
export default async function Page() {
  const data = await db.query('...')
  return <div>...</div>
}

app/page.js · JavaScript

// After - uncached by default, just remove noStore()
export default async function Page() {
  const data = await db.query('...')
  return <div>...</div>
}

generateStaticParams 和 dynamicParams

Cache Components 改变了动态路由处理参数的方式。

generateStaticParams 必须至少返回一个参数

现在返回空数组会报错。未启用 Cache Components 时,返回 [] 会将所有路径推迟到运行时首次访问时处理。启用 Cache Components 后,generateStaticParams 必须至少返回一个参数,以便 Next.js 预渲染路由,并验证它能生成非空的静态外壳。返回空数组会触发 empty-generate-static-params。

保留 generateStaticParams,并至少返回一个真实参数。移除该导出会让路由退出 ISR,这样即使路由通过 use cache 缓存数据,Next.js 也会在每次请求时渲染它。请阅读使用 Cache Components 的 ISR,了解 generateStaticParams 如何预渲染动态路由,以及如何在首次访问后升级未列出的路径。

app/blog/[slug]/page.tsx · TypeScript

// Before - defer all paths to runtime
export async function generateStaticParams() {
  return []
}

app/blog/[slug]/page.js · JavaScript

// Before - defer all paths to runtime
export async function generateStaticParams() {
  return []
}

app/blog/[slug]/page.tsx · TypeScript

// After - return at least one param to prerender
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())
  return posts.slice(0, 1).map((post) => ({ slug: post.slug }))
}

app/blog/[slug]/page.js · JavaScript

// After - return at least one param to prerender
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())
  return posts.slice(0, 1).map((post) => ({ slug: post.slug }))
}

未返回的路径仍可访问。Next.js 会为未知参数预渲染静态外壳,并在请求时流式传输其余部分。有关仅预渲染部分路径的完整流程,请参见使用 Cache Components 的 ISR。

不支持 dynamicParams

删除该导出。启用 Cache Components 后,导出 dynamicParams 会使构建失败,并显示以下错误消息:

路由段配置 "dynamicParams" 与 nextConfig.cacheComponents 不兼容。

未由 generateStaticParams 返回的参数会在请求时渲染。如果你曾使用 dynamicParams: false 拒绝这些参数,请在参数无法解析为真实数据时,在页面中调用 notFound()。

在 <Suspense> 内等待 params

要生成静态外壳,请将 params promise 传入 <Suspense> 边界,而不是在组件顶部等待它,这样即使参数未知也能进行预渲染。

app/blog/[slug]/page.tsx · TypeScript

// Before - awaiting params at the top blocks the shell
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <Post slug={slug} />
}

app/blog/[slug]/page.js · JavaScript

// Before - awaiting params at the top blocks the shell
export default async function Page({ params }) {
  const { slug } = await params
  return <Post slug={slug} />
}

app/blog/[slug]/page.tsx · TypeScript

import { Suspense } from 'react'

// After - await inside Suspense so the shell can prerender
export default function Page({ params }: PageProps<'/blog/[slug]'>) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Post params={params} />
    </Suspense>
  )
}

async function Post({ params }: Pick<PageProps<'/blog/[slug]'>, 'params'>) {
  const { slug } = await params
  // ...
}

app/blog/[slug]/page.js · JavaScript

import { Suspense } from 'react'

// After - await inside Suspense so the shell can prerender
export default function Page({ params }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Post params={params} />
    </Suspense>
  )
}

async function Post({ params }) {
  const { slug } = await params
  // ...
}

读取路由的客户端 Hook 同样如此。当路由路径名完全已知时,它们会在预渲染期间解析,不需要边界。当路径名依赖尚未知晓的动态参数时,无论组件位于何处,这些 Hook 都会挂起。例如,当 Next.js 为共享布局下任意带有动态参数的路由生成静态外壳时,该布局中的导航或面包屑就会挂起。请用 <Suspense> 包裹读取 Hook 的组件,并将读取操作下移到尽可能小的叶子组件中,让其余部分继续预渲染,否则构建会失败:

useSearchParams Hook 始终需要 <Suspense> 边界,因为搜索参数只有在请求时才能确定。有关修复方式,请参见 Next.js 在 Suspense 外的客户端组件中遇到了 URL 数据。

cookies、headers 和 searchParams

用 <Suspense> 包裹运行时数据访问。未启用 Cache Components 时,读取 cookies()、headers() 或 searchParams 会让整个路由采用动态渲染。启用 Cache Components 后,在 <Suspense> 边界外访问它们会触发 blocking-prerender-runtime 提示。请将访问操作移入由 <Suspense> 包裹的组件,让页面其余部分预渲染为静态外壳,动态部分则在请求时以流式方式传入。

app/page.tsx · TypeScript

import { cookies } from 'next/headers'

// Before - reading cookies at the top makes the whole route dynamic
export default async function Page() {
  const theme = (await cookies()).get('theme')?.value
  return <Dashboard theme={theme} />
}

app/page.js · JavaScript

import { cookies } from 'next/headers'

// Before - reading cookies at the top makes the whole route dynamic
export default async function Page() {
  const theme = (await cookies()).get('theme')?.value
  return <Dashboard theme={theme} />
}

app/page.tsx · TypeScript

import { cookies } from 'next/headers'
import { Suspense } from 'react'

// After - the page prerenders; only Dashboard streams at request time
export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Dashboard />
    </Suspense>
  )
}

async function Dashboard() {
  const theme = (await cookies()).get('theme')?.value
  // ...
}

app/page.js · JavaScript

import { cookies } from 'next/headers'
import { Suspense } from 'react'

// After - the page prerenders; only Dashboard streams at request time
export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Dashboard />
    </Suspense>
  )
}

async function Dashboard() {
  const theme = (await cookies()).get('theme')?.value
  // ...
}

页面会通过 props 接收 params 和 searchParams,两者都是 promise。采用相同模式:将 promise 直接作为 prop 传给由 <Suspense> 包裹的组件,并在该组件内部 await 它,而不是在页面顶部等待。也可以在原地通过 .then() 解包 promise,再向下传递普通值;类似模式请参见流式传输。

app/page.tsx · TypeScript

import { Suspense } from 'react'

export default function Page({ searchParams }: PageProps<'/'>) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Results searchParams={searchParams} />
    </Suspense>
  )
}

async function Results({ searchParams }: Pick<PageProps<'/'>, 'searchParams'>) {
  const { query } = await searchParams
  // ...
}

app/page.js · JavaScript

import { Suspense } from 'react'

export default function Page({ searchParams }) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Results searchParams={searchParams} />
    </Suspense>
  )
}

async function Results({ searchParams }) {
  const { query } = await searchParams
  // ...
}

提示:当 cookie 或请求头的值用于设置根布局中 <html> 元素的属性(lang、dir、data-theme 等)时,在服务器上读取它会使整个子树依赖请求,因此没有可用 <Suspense> 包裹的子组件。在 <head> 中通过内联 <script> 于绘制前设置属性,可以让外壳保持静态;具体模式请参见防止水合前闪烁。

路由处理程序(GET)

将 dynamic = 'force-static' 替换为 use cache。

未启用 Cache Components 时,GET 路由处理程序是动态的,除非通过 export const dynamic = 'force-static' 启用缓存。启用 Cache Components 后,GET 处理程序遵循与页面相同的模型:不访问未缓存数据或运行时数据时,它们会预渲染;未缓存的数据则可通过 use cache 缓存。移除 dynamic 配置,并将数据访问移入标记为 use cache 的独立函数。不能将该指令直接用于 GET 导出本身,因此处理程序需要调用一个被缓存的辅助函数。

app/api/products/route.ts · TypeScript

// Before
export const dynamic = 'force-static'

export async function GET() {
  const products = await db.query('SELECT * FROM products')
  return Response.json(products)
}

app/api/products/route.js · JavaScript

// Before
export const dynamic = 'force-static'

export async function GET() {
  const products = await db.query('SELECT * FROM products')
  return Response.json(products)
}

app/api/products/route.ts · TypeScript

// After
import { cacheLife } from 'next/cache'

export async function GET() {
  const products = await getProducts()
  return Response.json(products)
}

async function getProducts() {
  'use cache'
  cacheLife('hours')
  return db.query('SELECT * FROM products')
}

app/api/products/route.js · JavaScript

// After
import { cacheLife } from 'next/cache'

export async function GET() {
  const products = await getProducts()
  return Response.json(products)
}

async function getProducts() {
  'use cache'
  cacheLife('hours')
  return db.query('SELECT * FROM products')
}

提示:在 GET 处理程序中读取未缓存数据或运行时数据,会通过抛出异常退出预渲染。其他操作外面已有的 try/catch 会捕获这次退出。如果 catch 块记录错误,就会为构建输出增加噪声。设置 experimental.hideLogsAfterAbort: true 可以隐藏退出后产生的日志。

generateMetadata 和 generateViewport

用 use cache 缓存外部数据,或标记有意采用动态行为的页面。启用 Cache Components 后,generateMetadata 和 generateViewport 遵循与组件相同的规则。如果它们读取运行时数据(cookies()、headers()、params、searchParams)或获取未缓存的数据,而页面其余部分原本可以预渲染,Next.js 就会报错,以便明确这一选择。如果元数据依赖外部数据,但不依赖运行时数据,请添加 use cache。

app/page.tsx · TypeScript

// Before
export async function generateMetadata() {
  const { title, description } = await db.query('site-metadata')
  return { title, description }
}

app/page.tsx · TypeScript

// After - cache external data
export async function generateMetadata() {
  'use cache'
  const { title, description } = await db.query('site-metadata')
  return { title, description }
}

如果元数据确实需要运行时数据,不能用 <Suspense> 包裹 generateMetadata。应改为在页面中添加动态标记组件,让静态内容继续预渲染,元数据则以流式方式传入。

app/page.tsx · TypeScript

import { Suspense } from 'react'
import { connection } from 'next/server'

export async function generateMetadata() {
  // reads runtime data
  return { title: 'Personalized Title' }
}

async function DynamicMarker() {
  return (
    <Suspense>
      <Connection />
    </Suspense>
  )
}

async function Connection() {
  await connection()
  return null
}

export default function Page() {
  return (
    <>
      <article>Static content</article>
      <DynamicMarker />
    </>
  )
}

完整的修复选项及其权衡,请参见结合 Cache Components 使用 generateMetadata 和结合 Cache Components 使用 generateViewport。

runtime = 'edge'

不支持。Cache Components 要求使用 Node.js 运行时。移除已弃用的 runtime = 'edge' 导出,即可切换至默认的 Node.js 运行时。如果某些路由需要边缘行为,请改用 Proxy。

experimental_ppr

已移除,请改为启用 cacheComponents。Next.js 16 移除了实验性的部分预渲染标志(experimental.ppr)和 experimental_ppr 路由段配置。部分预渲染现已成为 Cache Components 的组成部分,因此请从 next.config 中移除 experimental.ppr,并从路由段中移除 experimental_ppr。可使用 codemod 自动移除路由段配置。

app/page.tsx · TypeScript

// Before - no longer needed
export const experimental_ppr = true

export default function Page() {
  return <div>...</div>
}

app/page.js · JavaScript

// Before - no longer needed
export const experimental_ppr = true

export default function Page() {
  return <div>...</div>
}

app/page.tsx · TypeScript

// After - remove it; cacheComponents enables Partial Prerendering
export default function Page() {
  return <div>...</div>
}

app/page.js · JavaScript

// After - remove it; cacheComponents enables Partial Prerendering
export default function Page() {
  return <div>...</div>
}

UI 状态保留

组件状态现在会跨导航保留。启用 Cache Components 后,Next.js 使用 React <Activity> 组件的 "hidden" 模式保留路由,而不是卸载它们。Effect 仍会正常清理和重新运行,但导航离开再返回时,useState 值、表单输入和滚动位置不再重置。

如果代码依赖卸载来清除状态,可能需要添加显式重置逻辑:

  • 下拉菜单和弹出层:导航返回时仍保持打开。请在 useLayoutEffect 的清理函数中关闭它们。
  • 带有初始化逻辑的对话框:如果状态被保留,依赖对话框状态的 Effect(例如聚焦输入框)就不会再次触发。请改为根据 URL 推导对话框状态。
  • 提交后的表单:返回时,输入值和 useActionState 结果(成功或错误消息)仍会保留。尽可能在提交处理程序或用户操作中重置,否则使用清理 Effect。

每种模式的详细示例,请参见跨导航保留 UI 状态。

后续步骤

了解启用 Cache Components 后的其他行为变化。

  • 了解如何组织应用,预取和预渲染更多内容,从而实现即时页面加载和客户端导航。
  • 了解如何在 Next.js 中缓存数据和 UI。
  • 了解 React 的 Activity 组件如何在 Next.js 中跨导航保留 UI 状态,以及如何控制需要重置的内容。
  • 了解如何预渲染部分动态路由,为其余路由提供应用外壳,并在首次访问后升级它们。
  • 了解启用 Cache Components 后如何读取用户会话,在不拖慢页面的情况下显示已验证身份的 UI,并缓存由会话派生的数据。
  • generateStaticParams 函数的 API 参考。
  • 了解如何在 Next.js 中启用 cacheComponents 标志。

原作者:Next.js 文档团队。原文:Migrating to Cache Components。官网版本 16.3.8,页面更新日期 2026-09-07,取得日期 2026-10-03。本译文据该版本翻译。许可:MIT;Copyright (c) 2025 Vercel, Inc.。

The MIT License (MIT)

Copyright (c) 2025 Vercel, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容