将动态读取下沉到预渲染静态壳:Next.js 16.3+ Cache Components 即时导航的 10 种重构模式
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
本文围绕 skills/next-cache-components-optimizer/reference/patterns.md 展开。开启
cacheComponents后,Next.js 会为每个路由预渲染一个「静态壳(static shell)」:真正按请求(per-request)的数据则被推迟到<Suspense>边界内流式送达。只要某个动态读取发生在壳的上层,导航就会由「即时」退化为「阻塞」。这套重构模式的统一心法是:壳里尽量多留预渲染内容,只把真正属于每次请求的工作用紧凑的<Suspense>包起来,或上提进use cache。读完本文,你将掌握 10 种「before → after」的代码级改造方案,能把动态读取逐层下沉到静态壳之外,覆盖页面数据、Cookie/Header、未缓存 IO、动态参数、searchParams、非确定性取值、metadata/viewport 以及客户端导航下的边界摆放,最终让路由实现 Instant Navigation。
在 Next.js 16.3+(cacheComponents: true)下,一条路由有两种到达用户的方式,二者都必须「即时」:**初次加载(硬导航)**提交路由预渲染出的静态壳,延迟部分在其加载骨架后流入;**客户端导航(软导航)**提交的是目标路由预取到的 App Shell。上面的 patterns 文档把每一种会让路由掉出静态壳的「阻塞形态」都抽象成了一条重构法则:凡是同时出现在 fallback 与最终渲染树里的元素,就把它上提到边界之外(hoist above the boundary);凡是真正逐请求的数据,才让它在低处的边界内流式抵达。
下文按 patterns 文档的原始编号逐条展开。每种模式都给出可复制的示例,并补充仓库中的实现证据与构建期排查命令,方便你在真实应用中直接对照改造。
1. 顶层await→ 把 await 挪进 Suspense 子组件
这是最常见的阻塞形态:在页面 / layout 顶部直接await请求期数据,会让其下所有内容全部变动态。
// ❌ before —— 顶层 await 一个非静态参数 + 未缓存数据 export default async function Page(props: PageProps<'/store/[slug]'>) { const { slug } = await props.params const product = await db.products.findBySlug(slug) return ( <article> <h1>{product.name}</h1> </article> ) }// ✅ after —— 把 params promise 向下传递,在 Suspense 包裹的子组件内部 await import { Suspense } from 'react' export default function Page(props: PageProps<'/store/[slug]'>) { return ( <Suspense fallback={<p>Loading product…</p>}> <Product params={props.params} /> </Suspense> ) } async function Product({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const product = await db.products.findBySlug(slug) return ( <article> <h1>{product.name}</h1> </article> ) }当不想为此拆出独立组件时,可以用行内(inline)变体:在顶层不await,而是用params.then(...)在 Suspense 内部解包 promise:
export default function Page(props: PageProps<'/store/[category]'>) { return ( <Suspense fallback={<Grid.Skeleton />}> {props.params.then(({ category }) => ( <ProductGrid category={category} /> ))} </Suspense> ) }要点:在预渲染期间读取运行时数据会触发blocking-prerender-runtime这一 insight,见仓库中对应的错误文档 errors/blocking-prerender-runtime.mdx。
2. layout 中的cookies()/headers()→ 只发起、不 await,向下传 promise
layout 一旦 await 请求期数据,会同时阻塞这个 layout及其下的每一个页面。因此要把读取「发起」与「等待」分离:layout 只调用cookies()得到 promise(尚未 await,不会挂起),再把它作为 prop 传给 Suspense 内的子组件去 await。
// ❌ before —— 整个 layout(连同全部 children)变动态 export default async function Layout({ children }) { const cookieStore = await cookies() const theme = cookieStore.get('theme')?.value return <body>// ✅ after —— 发起读取但不 await,把 promise 传给 Suspense 子组件 import { Suspense } from 'react' import { cookies } from 'next/headers' export default function Layout({ children }: { children: React.ReactNode }) { const cookieStore = cookies() // 未 await → 不阻塞壳 return ( <body> <nav> <Suspense fallback={<UserMenu.Skeleton />}> <UserMenu cookiePromise={cookieStore} /> </Suspense> </nav> {children} </body> ) } async function UserMenu({ cookiePromise, }: { cookiePromise: ReturnType<typeof cookies> }) { const theme = (await cookiePromise).get('theme')?.value return <div>// ❌ before —— 两者都阻塞壳 const product = await db.products.findBySlug(slug) // 极少变化 const inventory = await db.inventory.findBySlug(slug) // 必须新鲜// ✅ after —— 缓存稳定数据(进壳),延迟新鲜数据(流式) async function getProduct(slug: string) { 'use cache' // → 预渲染时解析,进入静态壳 return db.products.findBySlug(slug) } ;<Suspense fallback={<p>Checking availability…</p>}> <Inventory params={params} /> {/* 未缓存读取留在这里,流式进入 */} </Suspense>两个易错点需要特别留意:
- 裸
'use cache'会套用default这个cacheLife配置档。与其默默带着默认存活期上线,不如用cacheLife('<profile>')显式选择新鲜度。仓库约定的可用 profile 依次为:default/seconds/minutes/hours/days/weeks/max。 - 服务端无状态场景:
use cache是内存级缓存,跨实例不持久。需要跨实例共享的持久壳时,应改用'use cache: remote'(见仓库中关于该指令的约定与 errors 文档中的说明)。
对应 insight 为「预渲染期间遇到未缓存数据」,见 errors/blocking-prerender-dynamic.mdx。它的处理路径与运行时数据不同:fetch()、数据库调用、await connection()等异步 IO 在 Suspense 外执行时触发的是这一条;文档与源码中对两类读取(运行时数据 vs 未缓存数据)给出的修复建议也不同。
4. 动态参数 →generateStaticParams(进壳)或<Suspense>(流式)
如果参数集合是可枚举的,就预渲染它们,让await params在壳内直接解析;否则把参数当作请求期数据,把消费者包进<Suspense>。
// ✅ 方案 A —— 枚举参数 → params 在壳内解析,无需为 params 加 Suspense export function generateStaticParams() { return [{ slug: 'shoes' }, { slug: 'hats' }] } export default async function Page({ params }: PageProps<'/store/[slug]'>) { const { slug } = await params // 构建期已知 → 壳内安全 // ... }// ✅ 方案 B —— 不可枚举 → params 属于请求期,在边界内 await(即模式 #1)关于根参数(root params):根 layout 所处的动态段(例如app/[lang]/layout.tsx中的[lang])本可通过next/root-params在任何 Server Component 中直接读取、免去 props 逐层透传——仓库中该模块的实现在 packages/next/src/server/request/root-params.ts(配套类型工具见 packages/next/src/server/lib/router-utils/root-params-type-utils.ts)。但在 Cache Components 下,根参数若要进入静态壳,同样必须由generateStaticParams枚举(每个根参数至少一个取值),这一点与普通动态参数没有差别。对应 insight 仍见 errors/blocking-prerender-runtime.mdx。
5.searchParams→ 始终放在<Suspense>之后(页面加载路径)
searchParams 在构建期永远不可知,因此在页面加载时await它(或用useSearchParams())必然挂起。把消费方隔离到边界内,其余页面内容留在壳中:
// ✅ 静态内容留在壳中;依赖搜索参数的部分流式渲染 export default function Page(props: PageProps<'/search'>) { return ( <> <h1>Search</h1> {/* shell */} <Suspense fallback={<Results.Skeleton />}> <Results searchParams={props.searchParams} /> </Suspense> </> ) } async function Results({ searchParams, }: { searchParams: Promise<{ q?: string }> }) { const { q } = await searchParams return <ResultList query={q} /> }需要注意路径差异:在客户端导航中,router 已经持有 URL,此时useSearchParams()消费方可同步解析,因而可以出现在预取的壳里;但页面加载路径仍然需要这个边界。也就是说,两种到达方式共享同一套修复模式,测试差异只在于「如何驱动导航」——软导航点真实<Link>,硬导航用page.goto()。对应 insight:运行时数据见 errors/blocking-prerender-runtime.mdx;若是在 Client Component 内经useSearchParams()读取 URL 数据,则见 errors/blocking-prerender-client-hook.mdx。
6. 非确定性取值 →connection()+<Suspense>,或直接缓存
Math.random()、Date.now()、crypto.randomUUID()每次运行输出都不同,因此 Cache Components 会强制你做出选择:逐请求(延迟)或固定(缓存)。
// ✅ 逐请求取值:先 gate 在 connection() 上,再用 Suspense 包裹 import { connection } from 'next/server' async function RequestId() { await connection() return <span>{crypto.randomUUID()}</span> } // <Suspense fallback={null}><RequestId /></Suspense>// ✅ 对所有人相同:缓存它,使其加入静态壳 async function buildId() { 'use cache' return Date.now() }await connection()的语义在源码中有精确对应:见 packages/next/src/server/request/connection.ts,其 JSDoc 明确写着「During prerendering it will never resolve and during rendering it resolves immediately」——预渲染阶段返回永不 resolve 的挂起 promise(makeDynamicHangingPromise),真实请求阶段立即 resolve。这就是「用connection()门控逐请求工作」得以成立的底层机制,也是官方错误文档建议的 per-request 修复入口。相关 insight 分别为 errors/blocking-prerender-current-time.mdx(Date.now())、errors/blocking-prerender-random.mdx(Math.random())、errors/blocking-prerender-crypto.mdx(crypto)。它们的 Client Component 变体(*-client)各有独立文档,客户端在预渲染期间调用这些 API 时触发。
7. 动态generateMetadata→ 静态导出、use cache、或动态标记组件
三种选型,按 metadata 的真实依赖决定:
// ❌ before —— 读取请求期数据,阻塞路由的 metadata export async function generateMetadata() { const c = await cookies() return { title: c.get('title')?.value } }// ✅ 方案 A —— 静态 export const metadata = { title: 'Store' } // ✅ 方案 B —— 缓存 metadata(依赖外部数据而非运行时数据) export async function generateMetadata() { 'use cache' return { title: await getTitle() } }// ✅ 方案 C —— metadata 确实需要运行时数据(cookies/headers): // 保持 generateMetadata 动态,同时在页面中加入动态标记组件, // 让页面其余部分仍然预渲染进静态壳。 import { Suspense } from 'react' import { connection } from 'next/server' import { cookies } from 'next/headers' export async function generateMetadata() { const token = (await cookies()).get('token')?.value return { title: token ? 'Personalized' : 'Store' } } async function DynamicMarker() { await connection() // 声明这是刻意的动态内容 return null } export default function Page() { return ( <> <article>{/* 静态内容 —— 留在壳中 */}</article> <Suspense> <DynamicMarker /> </Suspense> </> ) }generateViewport的处理方式相同,唯一差别是动态 viewport 会阻塞整页。真正意义上的即时修复只有两种:静态导出viewport,或用use cache。其余两者属于「接受动态」的退出选项,不能当作通向 GREEN 的手段:export const instant = false只是让该 segment 跳过校验,导航依然阻塞;而在<body>之上套一层<Suspense>则会让整个路由变动态。patterns 文档对这条边界有明确警告:不要用 opt-out 的方式把红灯变绿。对应 insight 为「generateMetadata()中的运行时数据」,见 errors/blocking-prerender-metadata-runtime.mdx;与之并列的未缓存变体可参考仓库 errors 目录下的blocking-prerender-metadata-dynamic.mdx、blocking-prerender-viewport-runtime.mdx、blocking-prerender-viewport-dynamic.mdx。
8. 把 LCP 元素留在壳里
不要让主标题(LCP 元素)被埋进某个边界内部——边界未 resolve 前它无法绘制。标题/标题所在区块如果依赖数据,先把该数据缓存进壳(或用壳内可得的已知值),把会拖慢绘制的部分(如评论区)隔离在边界之后:
// ✅ LCP 在边界之外 → 随壳立即绘制 <h1>{product.name}</h1> {/* shell(必要时缓存 name) */} <Suspense fallback={<Reviews.Skeleton />}> <Reviews productId={id} /> {/* 流式 */} </Suspense>这条法则本质上是在应用一个更通用的原则:边界越低越好,但要保证 fallback 有意义。边界位置决定了用户导航期间看到什么——高边界(包住整页)只有一个整体 loading,设置成本低但用户失去「我正要去哪」的上下文;低边界(包住读取运行时 API 的具体组件)让周围内容保持可见,只在逐请求部分显示 fallback。缓存内容位于边界之上时,它们会随导航成为静态壳的一部分。
9. 共享 layout 之下的粒度(客户端导航正确性)
在根layout 放一个边界可以通过页面加载校验,却会让同级的客户端导航仍然阻塞。原因在于软导航只重新渲染共享 layout 之下的变化 segment:根 layout 的边界不在软导航的渲染范围内,因而覆盖不到兄弟路由之间的导航。把边界放到共享 layout 之下:
// app/store/layout.tsx —— 边界放在 /store 共享 layout 之下,覆盖 // 诸如 /store/shoes → /store/hats 的客户端导航(根边界做不到) export default function StoreLayout({ children, }: { children: React.ReactNode }) { return ( <section> <StoreNav /> {/* shell */} <Suspense fallback={<Page.Skeleton />}>{children}</Suspense> </section> ) }patterns 文档的优先级建议是:页面内部尽量用按组件的独立边界(模式 #1–#5),而不是用一整个大的 layout 边界——前者能保留更多真实内容在壳中,且各区域独立流式到达。
关于边界摆放,还有一个在真实应用(parallel routes 等)中反复出现的问题:共享 layout 之上的父级 layout 如果await props.params而该段没有generateStaticParams,参数在初次加载时会挂起、其整棵子树掉出壳——但软导航不会重渲染这个父级、且已持有参数,于是出现「点<Link>后可见的元素,goto后缺失」的症状。SKILL 的配套文档对这类「初始加载壳 ≠ 软导航壳」的情形有专门剖析,见 skills/next-cache-components-optimizer/reference/real-app-patterns.md。
当边界放得太高时,insight 会在客户端导航上以自己的方式浮出水面——关于边界摆放位置的详细说明见 errors/blocking-prerender-dynamic.mdx 中的 "Choosing where to place the boundary"。
10. 无法移动的 URL 数据 → 按链接预取(per-link prefetch)
模式 #1–#9 的共同手法是:把动态读取移到边界之后,从而长出一个静态壳。但 URL 数据不一样:params、searchParams、完整 URL 属于某一条具体的链接,而 App Shell 被指向该路由的所有链接共享。当整条路由都依赖 URL 数据时,把读取往下推可能留不下任何有意义的共享壳——这就是优化器的停止点,而不是再一次壳重构。
按链接预取是这条软导航在点击前提交 URL 特定内容的唯一途径,它有三个硬性前提:
// 1. 目标路由已采用 Partial Prefetching: // 全应用开启 partialPrefetching: true,或按路由使用 prefetch = 'partial'。 // 2. 导航请求完整预取 —— 通常是 <Link prefetch={true}>。 // 默认/auto 预取只会预热静态壳。 <Link href={href} prefetch={true}> … </Link> // 3. URL 相关的内容位于 `use cache` 之后,并以解析后的 // params/searchParams/完整 URL 值作为缓存键。在instant()度量下,真正提交的是运行时条目(runtime entry),因此在锁内看到的是真实内容而非骨架。
这一模式有五个各自耗费过真实调试时间的坑,逐个说明:
- 完整预取是强制的。启用 App Shells 后,auto/PPR 预取会在 runtime 派生前就退出(源码逻辑对应
subtreeHasSpeculativePrefetch);普通链接请用<Link prefetch={true}>,若应用已有手动完整预取的抽象则继续沿用。如果缓存了 URL 相关内容后路由仍然 RED,导航很可能仍在执行 auto 预取。 - 目标路由必须先采用 Partial Prefetching。按链接预取走的是 Partial Prefetching 路径。若缓存后仍 RED,请检查链接是否仍在做 auto 预取,或目标路由根本没有采用 Partial Prefetching。
- 预取规范 URL。href 发生 307 重定向的链接(例如 canonicalize 到
/的/foo)无法被预取——预取收到的是重定向而非路由树。请让链接与预取都指向最终 URL。 - 不要全量铺开完整预取。它会拉取目标全部动态数据;给每个可见链接都开启是浪费。应把
prefetch={true}限定在真正需要按链接预取的目标上,并在可见链接较多时结合 trade-off 与 hover 触发预取的策略做取舍。 - 标记必须是已提交节点,而不是 RSC 字节。这类内容通常是客户端组件,其文本并不在预取响应里——请断言客户端子树提交后渲染出的
data-testid,而不是断言流中的某个子串。
总的原则:只要 URL 数据读取能下移,就优先用静态壳(模式 #1–#9)——它比按链接预取更廉价,且同时覆盖硬加载。按链接预取只服务于两类场景:URL 数据读取确实无法下移,或路由的有用内容全部是 URL 特定的。对应 insight 见 errors/instant-link-prefetch-partial.mdx(预取期间的动态数据)。
排查命令与整套工作流的位置
patterns 文档属于 skills/next-cache-components-optimizer 技能包的一部分。该技能把上述模式放进一个测试驱动的优化循环里运行:用@next/playwright的instant()把「壳在锁下仍能提交」编码成失败的红测(RED),逐个修复到绿(GREEN),再把测试作为回归护栏。前提是Next.js 16.3+ 且next.config.ts中开启cacheComponents: true,并在next.config.ts中按构建环境暴露测试 API:
export default { cacheComponents: true } // … experimental: { // 本地:显式 opt-in;通用 CI:DEPLOY_ENV === 'staging';Vercel:VERCEL_ENV === 'preview' exposeTestingApiInProductionBuild: process.env.EXPOSE_TESTING_API === '1', }构建期排查建议与 SKILL 文档一致:
- 默认
next build输出往往被精简、可能没有可用堆栈;追加--debug-prerender可拿到完整失败帧,并报告第一个之外的所有阻塞点; - 用
next build --debug-build-paths "app/<route>/**"把构建范围限定在当前路由,避免整应用重建; - 绝不在
next dev上度量:dev 不做预取、锁对阻塞路由不可靠,dev 下的instant()结果既不能当 RED 也不能当 GREEN。
每个阻塞形态被命中时,构建都会打印一条https://nextjs.org/docs/messages/<slug>链接;仓库的 errors 目录即这些错误页的源文件,上文已逐条映射。改造时的配套细则——loading UI 复用(优先该路由的loading.tsx、组件旁*Skeleton、组件自带 fallback,而非手工镜像页面骨架)、壳在桌面与移动端断点下必须与真实渲染一致、以及 parallel routes / auth gate 等真实形态——分别见 skills/next-cache-components-optimizer/SKILL.md 与其 reference/real-app-patterns.md。
一张表记住全部决策
| 阻塞形态 | 判断依据 | 处理 |
|---|---|---|
顶层await(params / 数据) | 数据是否只被页面局部消费 | 下移读取 +<Suspense>(#1) |
layout 里cookies()/headers() | 是否影响整个子树 | 不 await、传 promise 给 Suspense 子组件(#2) |
| 未缓存 fetch / DB | 变化频率 | 稳定→use cache(配cacheLife);新鲜→边界后流式(#3) |
| 动态 params | 是否可枚举 | 可枚举→generateStaticParams;否则边界内 await(#4) |
searchParams | 页面加载 vs 客户端导航 | 页面加载路径一律边界隔离(#5) |
| 非确定性取值 | 逐请求 or 全局一致 | 逐请求→connection()门控;一致→缓存(#6) |
| 动态 metadata / viewport | 依赖运行时数据? | 静态 /use cache/ 动态标记组件;viewport 动态会阻塞整页(#7) |
| LCP 元素 | 是否依赖流式数据 | 上提到壳,数据缓存或分离(#8) |
| 客户端导航阻塞 | 边界是否低于共享 layout | 边界放到源与目标共享的 layout 之下(#9) |
| URL 数据无法下移 | 是否留得下共享壳 | 停止壳重构,评估按链接预取三项前提(#10) |
记住这条贯穿始终的判据:导航是否阻塞,取决于动态读取是否落在静态壳之外;修复是否合格,取决于锁住动态数据后壳是否仍然提交。按此逐条对照上面的 10 个模式去改造,即可把一条「非即时」路由稳定推进到「即时」。
【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考