
顺着一次真实请求讲清 next/root-params 的装包与取包:页面管道可读、route.ts 空对象、Server Action 拦截,并附迷你多语言商店示例与源码链接。
如何用一句话解释 Next.js 16.3 的 next/root-params,最准确的说法是:
当一个页面请求进来时,Next.js 会把「根布局(Root Layout)上面的动态段」单独摘出来,塞进当前请求的隐式背包(AsyncLocalStorage)里;于是你在任何深层组件或工具函数里,不用传参也能直接 await 把它掏出来。
下面我们直接顺着一次真实请求的数据流,用最直白的大白话把这个「装包 → 取包」的过程彻底拆开,并附带对应的 Next.js 源码链接。
看这个最常见的多语言目录:
当浏览器访问 GET /zh-CN/products/42 时,URL 里有两个参数,但地位完全不同:
以前 Next.js 只有 params 一条路,深层工具函数想拿 locale,要么层层传参(prop drilling),要么靠三方库(如 next-intl 的 setRequestLocale)自己打补丁。next/root-params 就是把这件事收编进框架内核。
假设你在一个深层工具函数里写了:
你没有传参,它能拿到值,靠的是编译期和运行期的四步接力(以请求 GET /zh-CN/products/42 为例):
node_modules/next/root-params.js 平时只是个空占位。在 next dev / next build 时,加载器 next-root-params-loader.ts 从 app/ 向下扫描,一旦在某条分支遇到第一个 layout.tsx 就停止深入,把沿途的动态段名生成薄包装函数:
所以文件夹叫 [locale],你就 import locale;而 [id] 在 Root Layout 下方,根本不会被扫描生成。
请求 /zh-CN/products/42 命中页面路由后,入口 renderAppPage 会调用 getRootParams 遍历路由树 loaderTree:
走到 app/[locale]/layout.tsx 时命中 layout !== undefined 当场返回,于是 rootParams 只留下 { locale: "zh-CN" },后面的 products/42 被直接忽略。
顺手看穿一个坑:如果你在顶层留了一个什么都不做的 app/layout.tsx,递归在第 0 层就遇到 layout 并返回 {},下面的 [locale] 就废了。
拿到 rootParams 后,Next 调用 createRequestStoreForRender,把 headers、cookies 和 rootParams 一起打包进本次请求的 requestStore,再用 Node.js 原生的 AsyncLocalStorage(内部叫 workUnitAsyncStorage)包住整棵组件树的渲染:
AsyncLocalStorage 就像跟着当前异步调用链走的隐式背包:无论调用栈深入多少层 await,只要还在这次页面渲染里,随时都能取出同一个 requestStore,且不同并发请求之间绝对隔离。
当深层函数执行 await locale() 时,底层 getRootParam 做的事极其简单——打开背包,按名字取值,并顺手登记缓存依赖:
这是最容易卡住的地方:请求打到 route.ts 时,Next 不是也创建了 requestStore 吗(毕竟 await headers() 能用)?为什么偏偏 rootParams 不能读?
因为 Next.js 内部针对「页面」和「API」走的是两条完全不同的处理管道:
直接看 [request-store.ts 里的真实源码](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/async-storage/request-store.ts):
所以原因非常实在:
我们用一个最小目录,把上面讲的业务逻辑落到代码上:
因为 [locale] 在最顶层,任何未知路径(比如浏览器自动探测 /.well-known/...)都会先把第一段塞进 locale。业务逻辑第一步永远是白名单校验:
写国际化、价格格式化或租户查询函数时,不再需要让调用方把 locale 传进来:
若该函数也可能被 (marketing) 分支调用,那里的 requestStore.rootParams 是 {},await locale() 会返回 undefined,用 ?? "en" 即可平滑兜底。
| 场景 / 问题 | 正确做法 | 对应源码依据 |
|---|---|---|
| 整站共享的根级动态段([locale]、[tenant]) | 用 import { locale } from "next/root-params" | next-root-params-loader.ts |
| 根动态段能不能带连字符(如 [tenant-id]、[site-locale] |
| 不能,必须用合法 JS 函数名(小驼峰 [tenantId]、[siteLocale]),否则编译期直接报错 |
| 加载器直接拼接 export function ${paramName}(),带 - 会变成语法错误 function tenant-id() |
| 页面私有的局部动态段([id]、[slug]) | 用页面 props 的 await params | getRootParams 遇第一个 layout 即停 |
| 顶层能不能留空的 app/layout.tsx? | 不能,它会提前触发截断,导致 rootParams 变成 {} | 同上,typeof layout !== 'undefined' |
| route.ts / Server Action 怎么拿语言? | 由调用方显式传参(query / body / header),或从 route.ts 自己的 { params } 拿 | createRequestStoreForAPI 写死了 rootParams: {} |
| 静态探测路径(/.well-known/、**/images/)** | 放在 app/[locale] 之外独立建 route.ts,别让它们误进 [locale] 触发多语言渲染 | 路由优先级:静态段优先于动态段 [locale] |