1. 一段险些被忽略的升级公告:unstable_noStore 悄悄转正了
1.1 我在升级 Next.js 15 时的第一手发现
我是在一个周末下午把公司项目从 Next.js 14 往 15 升的时候发现这件事的。当时跑完官方升级命令npx @next/codemod@canary next-14-to-15,随手看了一眼 git diff,发现有一处替换让我愣住了:所有从next/cache里导入的unstable_noStore都被自动改写成了noStore。
那一下我先是慌的,因为我清楚地记得,在 14 里官方还只有unstable_noStore,这名字带unstable_前缀,意味着 API 随时可能调整。结果 codemod 一上来就把我的代码改了,如果我不是对照着next build跑了一遍,可能都不敢提交这次改动。
查完官方 release notes 才确认:这不是 codemod 的锅,是 Next.js 15 真的把这个 API 转正了。unstable_noStore被正式重命名为noStore,同批被转正的还有unstable_after,它变成了after。也就是说,你现在写npx next dev跑 Next.js 15 项目,如果继续用带unstable_的老名字,代码虽然大概率还能跑,但已经不符合官方推荐的写法了。
1.2 官方升级清单里,与缓存相关的远不止改名
如果你只看这一个 API 的改名,很容易低估 Next.js 15 在缓存模型上的动作。实际上升级指南里与"缓存"相关的改动有好几条,我把它们整理成了一张表,这对我后续排查问题帮助很大:
| Next.js 14 的做法 | Next.js 15 的变化 | 影响范围 |
|---|---|---|
unstable_noStore() | 重命名为noStore() | Server Component / Server Action / Route Handler |
fetch默认走缓存 | fetch请求默认no-store | 所有直接使用 fetch 的数据请求 |
headers()/cookies()是同步 API | 变为异步 API,需要await | 依赖请求上下文的页面 |
没有connection() | 新增connection(),用于挂起并标记动态渲染 | Route Handler / Server Component |
unstable_after() | 重命名为after() | 流式响应后的回调逻辑 |
| cacheComponents 实验性阶段 | 引入use cache指令(实验性) | 组件/函数级显式缓存 |
一眼看去就明白,Next.js 团队在做的事情比"改个名"大得多。unstable_noStore→noStore只是一条明线,暗线是整个缓存心智模型的重建:从"默认缓存、按需退出"逐步转向"默认不缓存、显式声明"。理解这条暗线,你才知道noStore在这个新秩序里真正的位置。
2. 拆开 noStore 的引擎盖:它到底阻断的是哪一层缓存
2.1 先理清预渲染和 Full Route Cache 的关系
要搞懂noStore,绕不开 Next.js 的预渲染机制。Next.js 默认会在构建时对你的页面做静态优化:只要页面没有依赖动态 API(比如cookies()、headers()、searchParams),构建器会把这段组件树跑一遍,生成一份静态 HTML 存起来。用户访问时直接返回这份 HTML,不走服务器端渲染逻辑,这就是常说的"预渲染"。
这份预渲染 HTML 不是只存在构建机里,它还会进入 Full Route Cache,也就是整条路由级别的产物缓存。部署到 Vercel 或自建 Node 服务器后,CDN 可以直接缓存并响应这些静态页面,路径匹配时甚至不会触达你的 serverless function。配合客户端的 Router Cache,用户在站点内导航时可以做到近乎瞬时的切换。
听起来很好,但问题也出在这。假如你的页面渲染结果依赖用户会话、实时库存、当前时间,或者这一次请求里某个新写入的数据,那这份静态 HTML 就毫无用处,甚至会变成事故源。你需要的不是一份构建期就跑好的 HTML,而是"每次请求现场生成"的动态页面。
行业里常说的"静态预渲染 + 增量静态再生成 + 动态渲染"三件套,在 Next.js 里的对应物分别就是 SSG、ISR 和 SSR。noStore干的事,就是强制让某个渲染过程落到第三种:按请求动态执行。
这里可以打个生活化的比方:静态预渲染相当于一个工厂按预测提前备货入库,订单来了直接从仓库发货;noStore()相当于在某个工位贴了一张"本批次成品禁止入仓,客户下单后现做"的标签。仓库里的货再快,也快不过"货不对版"带来的用户投诉。
2.2 noStore 与 force-dynamic、fetch no-store 各自的分工
在 Next.js 14 时代,想让一个页面动态化,最常见的手段是在页面顶部写:
export const dynamic = 'force-dynamic';这种方式有效,但它只能放在 Page、Layout、Route 这些路由配置文件里,而且一次生效就是整条路由。你不能在一个深层的数据函数里说"这个数据要动态",你必须回到页面顶层声明。
noStore()恰恰补上了这个空缺。它可以在 Server Component、Server Action、Route Handler 甚至任意一个会被服务端渲染阶段调用的普通函数里执行。只要某个请求的渲染调用链上碰到了noStore(),这个请求就会被标记为动态渲染,整条路由退出预渲染。
那它和 fetch 的cache: 'no-store'又是什么关系?很多人容易在这里混淆:
fetch(url, { cache: 'no-store' })只管这一次网络请求是否复用一个跨请求缓存,它不保证页面一定动态渲染。Next.js 完全可以在构建期静默执行这次 fetch,把结果拼进 HTML,然后把页面存成静态。noStore()管的是整个请求的渲染过程,无论你是否使用 fetch,它都会让页面退出静态预渲染,保证每次访问都执行页面里的代码。export const dynamic = 'force-dynamic'更像是一个静态约束,无法在运行时由某条函数调用链动态触发,灵活性不如noStore()。
我在实际项目里最喜欢的组合是:数据访问层用noStore(),HTTP 请求层如果涉及外部服务再接cache: 'no-store'。两者并不冲突,前者保证页面动态,后者保证单次数据请求不落缓存。
2.3 一个小实验:用 next build 输出验证它确实生效
前面全是原理,但做后端的人都知道,原理说得再漂亮,不如next build输出里的一个符号来得实在。Next.js 构建完成后会打印每个路由的类型:
○:静态预渲染,构建时生成 HTMLƒ:动态渲染,每次请求时执行●:预渲染 + 客户端导航动态(ISR 相关)
我写了个测试页面,代码非常简单:
// app/dashboard/page.tsx import { noStore } from 'next/cache'; export default async function DashboardPage() { noStore(); return <div>{Date.now()}</div>; }第一次我不加noStore(),直接构建,输出是这样的:
┌ ○ /dashboard加上noStore()后再跑next build,输出变成了:
┌ ƒ /dashboard一个字母之差,含义天壤之别:○表示用户拿到的可能是构建时生成的老页面,ƒ表示每次访问都会重新渲染。用这个办法,你可以在升级后快速排查每个页面有没有被意外改造成动态模式,也可以反过来确认noStore()有没有真的生效。
3. 实战中的正确姿势:从页面到数据层的三种用法
3.1 页面级:最经典的 force-dynamic 替代写法
先看最常见的场景:一个每天要展示大量实时数据的后台页面。在 Next.js 15 里,最干净的写法是直接在页面组件的顶层调用。
// app/analytics/page.tsx import { noStore } from 'next/cache'; export default async function AnalyticsPage() { noStore(); const metrics = await fetchMetrics(); return <MetricsView data={metrics} />; }注意一点,noStore()要在组件进入渲染逻辑后尽早调用,最好放在任何数据请求之前。虽然它并不依赖具体某次数据请求的结果,但越早调用,整条渲染链路的动态语义就越清晰,后面读代码的人不会在数据请求函数里突然撞到一个隐式依赖。
和老的export const dynamic = 'force-dynamic'相比,这种写法的差异不只是位置不同,还有一个容易被忽略的点:force-dynamic是页面导出的一个常量,最终会被打包到 Route Config 里;而noStore()是运行时调用,它可以在条件分支里使用,理论上你可以写:
if (isPreviewMode) { noStore(); }这在老的静态导出方式下是做不到的。虽然大部分项目用不上这种骚操作,但背后体现了同一个逻辑:缓存控制从一个编译期配置变成了运行时指令。
3.2 数据访问层:让 noStore 成为"数据守卫"
真正让我离不开noStore()的场景是把多个数据请求封装成自定义函数。比如我们项目里有个lib/dashboard-data.ts,所有页面都从这里拿数据:
// lib/dashboard-data.ts import { noStore } from 'next/cache'; import { query } from './db'; export async function getDashboardData(userId: string) { noStore(); const [overview, recentOrders, activity] = await Promise.all([ query.overview(userId), query.recentOrders(userId), query.activity(userId), ]); return { overview, recentOrders, activity }; }这样做的好处是,调用方不用记得"这个数据是动态的,所以页面要加 force-dynamic",数据函数自己就声明了它的缓存偏好。团队里任何一个新人都能直接调用getDashboardData,而不需要理解 Next.js 完整的缓存体系。
但这里也有一个反向的教训。noStore()一旦放进公共数据函数,所有间接或直接调用它的页面都会被标记为动态渲染,无一例外。我有一次把一个数据函数无意识地嵌入了某个营销页的查询链路,结果那个本应静态预渲染的页面在构建输出里悄悄变成了ƒ,页面性能数据掉了不少。排查了很久才发现是公共函数里那行noStore()在作祟。
所以我的建议是:把noStore()放进数据访问层时,一定要评估它的调用面。如果这个函数会被多个页面共享,最好先在代码仓库里全局搜一下引用位置;否则就在页面级调用noStore(),把动态范围控制得尽量小。
3.3 注意:这些地方不能用,用了也没效果
我用noStore()的过程中踩过几个坑,挑重要的说:
客户端组件里调用没有意义。客户端组件运行在浏览器环境,它不存在 Full Route Cache 参与构建期静态化的过程。就算你在客户端组件里引入next/cache并调用noStore(),它也影响不到服务器端的缓存决策。实际上更常见的是编译器直接对你抛出报错,因为它不是合法客户端 API。
middleware 里调用不会起效。中间件运行在 Edge Runtime,主要处理请求转发、重写、鉴权这些事,它在路由渲染之前执行,跟 Full Route Cache 的构建期决策不在一个阶段。我最初看到文档里写"可以在中间件里调用"时也试过,结果next build输出没有任何变化,后来咨询了社区才确认,正确的退出缓存方式是在 Server Component 或 Server Action 里处理。
配合静态导出要慎重。如果项目配置了output: 'export',你的目标是输出纯静态文件,整个部署环境根本没有服务器运行时。此时调用noStore()不仅没有意义,还可能因为某些构建器行为导致构建失败或产物异常。遇到这种配置的项目,请优先考虑是否真的需要动态渲染。
和revalidate配置的优先级问题。Next.js 官方文档明确:noStore()优先级高于revalidate。也就是说,如果同一段渲染里既有export const revalidate = 60又有noStore(),最终表现是按动态渲染走,ISR 不会触发。这个坑我在旧项目迁移时踩过,看起来配置了 60 秒增量更新,实际却是每次请求全量渲染,缓存形同虚设。
还有一种情况很多人问:在 Route Handler 里能不能用?能用,而且我很推荐。例如一个返回实时 JSON 的 API 路由:
// app/api/now/route.ts import { noStore } from 'next/cache'; export async function GET() { noStore(); return Response.json({ now: Date.now() }); }这样写可以确保这个 API 不会被 Next.js 意外做静态优化,返回的时间永远是请求时刻的时间,而不是构建时刻。
4. Next 15 的缓存新秩序:connection() 和 use cache 正在改写 noStore 的未来
4.1 从退缓存到等待连接:connection() 的语义转变
Next.js 15 里新增了一个 API:connection(),它从next/server导出,调用方式很简单:
// app/user/page.tsx import { connection } from 'next/server'; export default async function UserPage({ searchParams }) { await connection(); const device = await getUserDevice(); return <DeviceInfo device={device} />; }调用connection()会迫使渲染过程等待请求连接上下文准备就绪,这会隐式地标记路由为动态渲染。也就是说,它达到了和noStore()类似的效果,但语义完全不同:
noStore()表达的是"我不希望这段渲染被缓存"。connection()表达的是"这段渲染依赖请求连接参数,所以必须先建立连接再继续工作"。
乍一看只是措辞差异,但对未来的影响很大。在 Next.js 的长期演进路线里,团队更希望开发者通过"依赖动态 API"来自然触发动态渲染,而不是靠着一个个显式的"退出缓存"指令。后者是打补丁,前者才是自我声明。以后当你读到一个组件里有await connection(),你会很自然地想:这里要读请求头或连接信息了;而看到noStore()时,你还得回溯到底是谁在缓存、为什么缓存。
4.2 use cache:从"默认缓存"到"显式缓存"的范式转折
如果说connection()是动态机制在收敛,那use cache指令就是缓存侧的大变革。Next.js 15 把原本实验性的 cacheComponents 继续向前推,推出了use cache指令,它可以直接把一个组件或者函数标记为可缓存:
'use cache'; async function fetchArticle(id: string) { return await db.article.find({ id }); }看到没有,这种写法的思路跟noStore()正好相反。noStore()是在默认缓存的世界里喊"这里别缓存",use cache是在默认自由的世界里圈出"这里可以缓存"。
Next.js 团队在若干次社区吐槽里其实已经承认:以前的默认缓存模型让太多人困惑,各种各样的"为什么我的页面不更新""为什么我的数据是旧的"都跟隐式缓存有关。所以接下来的演进方向大概率是:缩小隐式缓存范围,把缓存变成一个开发者主动选择的能力。
在这个新模型里,noStore()的存在感会越来越弱。因为如果默认就是不缓存,你自然不再需要到处调用"不要缓存";你需要做的反而是挑选那些真正可以被use cache标记的函数和组件。
4.3 它还能活多久:几个版本内不会消失,但用法会变
我自己的判断是:noStore这个名字至少会在 Next.js 15、16 里继续保持可用,因为大量现网项目已经依赖它。但它的角色会逐渐从"主流动态渲染方案"退位为"特殊场景开关"。
一个更有意思的信号是 React 团队这边。React 19 开始把cache、use等 API 的定位写得越来越明确,组件缓存和请求缓存的边界在各主流框架里都在重新划分。Next.js 作为 React 的上层框架,不可能长期保留一套跟 React 缓存理念不完全一致的 API 体系。unstable_noStore转正成noStore,可能并不是终点,而是过渡期的形态。
如果你现在要写一个全新的 Next.js 15 项目,我不会劝你完全抛弃noStore(),至少短期内它是线上项目里风险最低的动态化手段。但我会建议你在设计数据访问层时留出抽象层,不要所有地方硬编码noStore(),这样未来切到connection()或use cache时不需要大面积改代码。
5. 升级迁移与我的最终建议
5.1 现网项目安全迁移 noStore 的操作步骤
如果你正维护一个从 Next.js 14 升级上来的项目,下面这条路径是我实测过、相对稳妥的迁移顺序:
确认运行环境版本。Next.js 15 要求 Node.js 18.18 及以上版本,同时依赖 React 19。如果你的项目还在 React 18,先做 React 版本升级,否则直接升 Next.js 15 会遇到依赖冲突。
跑官方 codemod。
npx @next/codemod@canary next-14-to-15会在项目里自动把unstable_noStore替换成noStore,同时还会处理一批其他变更。跑完以后强烈建议看一遍 diff,不要无脑提交,因为 codemod 偶尔会误伤一些被注释掉的代码或字符串场景。检查 fetch 默认缓存变化带来的副作用。Next.js 15 里 fetch 默认变成了
no-store,这会让很多原来依赖 Next.js 数据缓存的请求直接绕过缓存,相当于请求量变大了。如果你的页面接口依赖高频 fetch,建议明确加上next: { revalidate: 60 }之类的策略,重新控制缓存粒度。对比构建输出。
npm run build之后逐个检查路由符号:原本应该是ƒ的页面仍然保持ƒ;原本是○的静态页面不要因为多了一个共享数据函数的noStore()而意外变成ƒ。这一步最好在 CI 里也留一份输出,方便版本间对比。预发布环境验证缓存头。动态页面在生产环境下会返回相应的响应头,比如
x-nextjs-cache: MISS或no-store相关标记。如果你的 CDN 层做了缓存覆盖,要确认这些动态响应不会被 CDN 二次缓存。
5.2 我踩过的几个坑和个人体会
迁移过程中我最大的教训是:不要把noStore()当作性能优化失败的万能钥匙。
有段时间我们项目接口响应很慢,团队第一反应是"给所有页面加上 noStore 绕过缓存",结果动态页面数量暴增,数据库连接数直接被打满,问题反而更严重。后来冷静下来分析才发现,慢的根源是某个嵌套查询没有走索引,跟缓存模型一点关系都没有。所以遇到业务响应慢,先分清楚是"数据查询慢"还是"缓存策略导致拿到旧数据",再决定要不要上动态化。
另一个体会是关于代码可读性的。noStore()这个函数名起得确实直白:"不要存储"。但它没有参数、没有返回值,不给调用者任何解释。如果你光在页面里看到一行noStore(),你根本不知道它是因为要读用户 Cookie,还是要保证实时库存,还是单纯为了绕开某个已经废弃的缓存。所以我现在的习惯是在调用处写上注释,比如:
// 该页面展示当前用户余额,余额变化必须实时可见 noStore();一行注释的价值,在三个月后的自己身上体现得最明显。
最后,关于"值不值得升级到 Next.js 15"我的看法是:如果你的项目里已经大量使用了unstable_noStore,升到 15 的收益除了拿到正式名称之外,更重要的是跟上新的缓存语义演进方向。你不必立即使用connection()或use cache,但至少要让自己知道,未来两年这条演进线会往哪走,否则等 Next.js 16/17 真的大改时,你的技术债就不是一天两天能还完的了。
如果让我给一个最简化的行动建议,那就是:升级当天顺手把代码里所有unstable_noStore统一改成noStore,然后在next build输出里看一眼每个路由的符号,再决定要不要顺手把force-dynamic的页面改成noStore()。这半小时花完,你会对 Next.js 15 的缓存体系有个全新的把握。