开启Next.js 16.3缓存组件后构建失败的排查与修复
原文:https://dev.to/shubhradev/i-turned-on-cache-components-in-nextjs-163-it-refused-to-build-my-simplest-page-3ak0(作者 @shubhradev)
我不想再写一篇"Next.js 16.3 有什么新功能"式的文章了——这类内容已经够多。我更想知道的是,在一个真实项目上把这些开关打开之后到底会发生什么。于是我脚手架新建了一个应用,开启 cacheComponents 和 partialPrefetching,然后尝试构建一个尽可能简单的动态页面。
构建失败了。
测试配置
没什么花哨的。全新的 create-next-app,Next.js 16.3.1,next.config.ts 里就两个开关:
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
然后是一个商品页。没有数据库,没有外部 API,甚至连真正的 fetch 都没有,只有一个假延迟和路由自身的 params:
async function getProduct(id: string) {
await new Promise((resolve) => setTimeout(resolve, 300));
return { id, name: `Product ${id}`, price: 42 };
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<main className="p-8">
<h1 className="text-2xl font-bold">{product.name}</h1>
<p>${product.price}</p>
</main>
);
}
一个动态路由大概也只能朴素到这个程度了。我原以为 next build 会不以为意、直接放行。
实际发生了什么
Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route

构建彻底失败,退出码 1。而这个页面唯一的"动态"行为,不过是读取一个明摆着会存在的参数。
我的第一反应是:这也太激进了。重读一遍之后的第二反应是:这正是这个版本的核心意图,而我因为只扫了一眼 changelog 把它错过了。16.3 的 Cache Components 并不会把一次没有保护的动态访问当成警告,而是把它当成一个必须显式做出的决定——不做决定,构建就过不去。要么流式传输,要么缓存,要么有意标记为阻塞。没有那种默默的第四选项:一切"照常工作",然后三个月后你才发现它从来就不是即时的。
修复方案,以及它实际换来了什么
我选了错误信息排在第一位的方案:把 await params 的那部分用 <Suspense> 包起来。
import { Suspense } from "react";
async function getProduct(id: string) {
await new Promise((resolve) => setTimeout(resolve, 300));
return { id, name: `Product ${id}`, price: 42 };
}
async function ProductDetails({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<>
<h1 className="text-2xl font-bold">{product.name}</h1>
<p>${product.price}</p>
</>
);
}
export default function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<main className="p-8">
<Suspense fallback={<p>Loading product...</p>}>
<ProductDetails params={params} />
</Suspense>
</main>
);
}
再次运行构建:
Route (app) ┌ ○ / ├ ○ /_not-found └ /products/[id] └ ◐ /products/[id] ○ (Static) prerendered as static content ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content

商品路由旁边的那个 ◐ 才是真正的特性所在。Next.js 把 Suspense 边界之外的所有内容——也就是页面外壳(shell)——抽出来做了预渲染,而真正取决于你点了哪个商品的那部分内容,则在后面以流式传入。这就是 Cache Components 的 Partial Prerendering(部分预渲染)在做它该做的事:出现在真实构建输出里的真实路由,而不是发布说明里的一段文字描述。而且,16.3 在此之上新增的客户端导航特性 Partial Prefetching(部分预取),依赖的也正是这套外壳机制先存在。

changelog 没有告诉我的事
光看 Partial Prefetching 的介绍——"每个路由一个可复用的、缓存在客户端的外壳"——它听起来像是个等有空了再上的锦上添花。但实际撞上这个构建错误之后我明白了:在一个开着 Cache Components 的 16.3 应用里,根本不存在"等有空再说"这个选项。开关一打开,Next.js 在预渲染过程中发现的每一处未缓存或运行时访问,都必须立刻给出答案:流式、缓存,或者阻塞。它不是渐进式的,而是一道构建闸门。
这比"往配置里加两行"听上去要高昂得多的采用成本。我想这也正是 Next.js 如今把 next-cache-components-adoption 作为第一方 agent Skill 一并发布的原因——而不是让你在一个真实应用里手动排查每一处没加保护的 params 和 cookies() 调用。在一个五个文件的测试项目上,我花了两分钟;可如果换成真实生产环境的路由树,要么有那个工具,要么对应用里每一条 Suspense 边界的位置都心中有数,否则我不会想动手。
我真正建议的入手方式
如果你是在为真实应用评估这个特性:不要在整个项目上打开开关,然后看哪里先着火。挑一条路由——最好是你最简单的那条动态路由——看看构建会告诉你它需要什么。它会明确告诉你下一步该做什么。至少在这一点上,错误信息是做对了的。
我写了一篇关于完整 16.3 版本的更长分析,包括哪些功能在零配置下自动升级、即时导航和部分预取到底改变了什么,以及为 AI 代理发布了什么内容,在我的网站上,如果你想在决定是否开启这些开关前了解全貌的话。
有没有人已经在真实的生产路由树上跑过这次迁移了?我很想知道在一个真实应用上,第一遍下来是什么样子。
原文:https://dev.to/shubhradev/i-turned-on-cache-components-in-nextjs-163-it-refused-to-build-my-simplest-page-3ak0(作者 @shubhradev)