TanStack Router 路径参数(Path Params)完全指南:动态段、可选参数、Splat 通配与类型安全导航
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
导读
路径参数(Path Params)是 TanStack Router 将 URL 中的动态片段捕获为具名变量的核心机制,以路径中的$前缀声明,覆盖动态段、可选段、前缀/后缀模式、Splat 全捕获等多种形态。本文以仓库中 path-params/SKILL.md 为骨架,结合 path.ts、new-process-route-tree.ts 等底层源码,系统讲解参数声明语法、useParams的类型安全读取、导航时的params传参方式、编码规则与常见错误,帮助你写出在 React / Solid / Vue 三大框架下完全类型安全、可维护的路由参数代码。
CRITICAL:永远不要把参数直接插值进
to字符串,必须使用paramsprop——这是 Agent 与开发者处理路径参数时最高频的错误。CRITICAL:参数类型完全由框架推断,永远不要手动为
useParams()的返回值添加类型注解。
动态段(Dynamic Segments)
以$前缀修饰的路径段会捕获从当前位置到下一个/之间的所有文本,并在组件、loader、beforeLoad 中以具名变量的形式出现。
// src/routes/posts.$postId.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { // params.postId 是 string —— 完全推断,不要手动注解 return fetchPost(params.postId) }, component: PostComponent, }) function PostComponent() { const { postId } = Route.useParams() const data = Route.useLoaderData() return ( <h1> Post {postId}: {data.title} </h1> ) }多个动态段可以跨路径层级同时存在,例如/teams/$teamId/members/$memberId:
// src/routes/teams.$teamId.members.$memberId.tsx export const Route = createFileRoute('/teams/$teamId/members/$memberId')({ component: MemberComponent, }) function MemberComponent() { const { teamId, memberId } = Route.useParams() return ( <div> Team {teamId}, Member {memberId} </div> ) }源码视角:段如何被解析与匹配
从源码结构看,路由路径在初始化时会被解析成一棵 segment 前缀树(trie)。new-process-route-tree.ts 中的parseSegment定义了四种段类型:
| 段类型 | 常量值 | 含义 | 示例 |
|---|---|---|---|
SEGMENT_TYPE_PATHNAME | 0 | 静态路径段 | posts |
SEGMENT_TYPE_PARAM | 1 | 动态参数段 | $postId、post-{$postId} |
SEGMENT_TYPE_WILDCARD | 2 | 通配捕获段 | $、{$} |
SEGMENT_TYPE_OPTIONAL_PARAM | 3 | 可选参数段 | {-$category} |
parseSegment对每个段先做快速路径检查(不含$即静态段),再依次识别裸$通配、$param动态段,最后才尝试解析花括号内的{-$...}可选参数与{$...}/{prefix{$x}suffix}前缀后缀模式。每个节点还会记录prefix(前缀文本)与suffix(后缀文本)的位置,供匹配与参数提取使用。匹配时 extractParams 会根据节点的前缀/后缀长度从 URL 段中切出参数原始值,并通过decodeURIComponent解码后写入rawParams。
Splat / Catch-All 路由
以裸$结尾的路由会捕获其后剩余的全部内容(包括/),捕获值通过_splat键读取。注意 TanStack Router 使用$(而非 React Router 风格的*)作为通配符。
// src/routes/files.$.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/files/$')({ component: FileViewer, }) function FileViewer() { const { _splat } = Route.useParams() // URL: /files/documents/report.pdf → _splat = "documents/report.pdf" return <div>File path: {_splat}</div> }源码视角:_splat的编码特殊性
在 path.ts 的encodeParam中,_splat有专门的处理分支:它不会像普通参数那样对整个值做一次encodeURIComponent(那样会把/编码为%2F),而是按/切分后对每个段分别编码再拼接,从而保证文件路径类场景中多级目录结构得以保留。代码中还保留了对旧语法*的兼容写入(usedParams['*'] = splat),并标注了 "TODO: Deprecate *",说明*键将在 v2 中移除。
在匹配侧,extractParams 同样会把通配捕获同时写入rawParams['*']与rawParams._splat。仓库测试 router.test.tsx 验证了/files/$这类路由捕获_splat = "tanner",并支持🚀这类需要编码的 Unicode 字符。
可选参数(Optional Params)
可选参数使用{-$paramName}语法。该段可以存在也可以不存在;不存在时值为undefined。
// src/routes/posts.{-$category}.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/{-$category}')({ component: PostsComponent, }) function PostsComponent() { const { category } = Route.useParams() // URL: /posts → category is undefined // URL: /posts/tech → category is "tech" return <div>{category ? `Posts in ${category}` : 'All Posts'}</div> }多个可选参数可以连续出现,匹配任意组合:
// 匹配:/posts、/posts/tech、/posts/tech/hello-world export const Route = createFileRoute('/posts/{-$category}/{-$slug}')({ component: PostComponent, })源码视角:可选段的跳过机制
在 new-process-route-tree.ts 中,MatchStackFrame使用了一个skipped位掩码来记录哪些可选段被跳过("beyond 32 segments we can't track skipped optionals",注释明确说明这是为了性能而采用的高效布尔数组替代方案)。参数提取时,被跳过的可选段会回退partIndex与pathIndex,让后续段对齐到正确的 URL 片段;只有段内有实际值时才会写入rawParams(见 extractParams)。
i18n 场景:可选 locale 前缀
可选参数最常见的实战场景是国际化语言前缀。下面的路由同时匹配/about、/en/about、/fr/about:
// src/routes/{-$locale}/about.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/{-$locale}/about')({ component: AboutComponent, }) function AboutComponent() { const { locale } = Route.useParams() const currentLocale = locale || 'en' return <h1>{currentLocale === 'fr' ? 'À Propos' : 'About Us'}</h1> } // 匹配:/about、/en/about、/fr/about前缀与后缀模式(Prefix and Suffix Patterns)
当$paramName被花括号{}包裹时,可以在同一段内、动态部分的前后附带静态文本。这正是花括号语法唯一合理的两种用途:前缀/后缀模式与可选参数。
前缀
// src/routes/posts/post-{$postId}.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/posts/post-{$postId}')({ component: PostComponent, }) function PostComponent() { const { postId } = Route.useParams() // URL: /posts/post-123 → postId = "123" return <div>Post ID: {postId}</div> }后缀
// src/routes/files/{$fileName}[.]txt.tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/files/{$fileName}.txt')({ component: FileComponent, }) function FileComponent() { const { fileName } = Route.useParams() // URL: /files/readme.txt → fileName = "readme" return <div>File: {fileName}.txt</div> }前缀 + 后缀组合
// URL: /users/user-456.json → userId = "456" export const Route = createFileRoute('/users/user-{$userId}.json')({ component: UserComponent, }) function UserComponent() { const { userId } = Route.useParams() return <div>User: {userId}</div> }源码视角:带前缀/后缀的动态段如何匹配与排序
在 parseSegment 中,花括号形式会被解析为带prefix/suffix的PARAM段;extractParams 提取参数时会先按node.prefix.length切掉前缀、再按node.suffix.length切掉后缀。匹配器对动态兄弟节点按特异性排序,规则见 sortDynamic:带params.parse的节点优先,前缀/后缀更长、更具体的节点优先,caseSensitive优先,特异性相同时保持声明顺序(稳定排序)。因此post-{$postId}会比裸$slug更早被尝试匹配。
带路径参数的导航(Navigating with Path Params)
对象形式
import { Link } from '@tanstack/react-router' function PostLink({ postId }: { postId: string }) { return ( <Link to="/posts/$postId" params={{ postId }}> View Post </Link> ) }函数形式(保留其他参数)
当需要基于当前参数派生新目标时,params可以是接收prev并返回新参数对象的函数,从而保留既有参数:
function PostLink({ postId }: { postId: string }) { return ( <Link to="/posts/$postId" params={(prev) => ({ ...prev, postId })}> View Post </Link> ) }编程式导航
import { useNavigate } from '@tanstack/react-router' function GoToPost({ postId }: { postId: string }) { const navigate = useNavigate() return ( <button onClick={() => { navigate({ to: '/posts/$postId', params: { postId } }) }} > Go to Post </button> ) }带可选参数的导航
可选参数既可以显式传入,也可以传undefined将其省略:
// 包含可选参数 <Link to="/posts/{-$category}" params={{ category: 'tech' }}> Tech Posts </Link> // 省略可选参数(渲染为 /posts) <Link to="/posts/{-$category}" params={{ category: undefined }}> All Posts </Link>源码视角:interpolatePath的插值编码流程
所有导航最终都会把参数对象插值进路径模板。核心实现位于 path.ts 的interpolatePath:
- 服务端(
isServer)走快速路径:仅处理$id与裸$通配,跳过/间的空段; - 客户端走通用解析路径:通过
parseSegment逐段识别类型,分别处理 PATHNAME / WILDCARD / PARAM / OPTIONAL_PARAM; - 通配段缺失(
!splat)时,若存在前缀/后缀则拼接前后缀、否则整段省略; - 可选段值为
null/undefined时直接跳过该段(见 path.ts); - 所有值经
encodeURIComponent编码,缺失的必填参数会被记录为isMissingParams。
在路由组件之外读取参数
useParams+from
在非路由组件(如布局、头部组件)中,可以用from指定要读取参数的已匹配路由路径:
import { useParams } from '@tanstack/react-router' function PostHeader() { const { postId } = useParams({ from: '/posts/$postId' }) return <h2>Post {postId}</h2> }useParams+strict: false
若希望放宽类型约束,读取"当前匹配到的任意路由"的参数,可用strict: false。此时返回值是所有可能路由参数的联合类型,需要做空值兜底:
function GenericBreadcrumb() { const params = useParams({ strict: false }) // params 是所有可能路由参数的联合类型 return <span>{params.postId ?? 'Home'}</span> }源码视角:useParams的实现本质
以 React 实现为例,useParams.tsx 内部基于useMatch实现:select回调中,strict: false时读取match.params,否则读取match._strictParams,并支持select投影与structuralSharing以获得稳定的引用、避免多余重渲染。在 Solid 与 Vue 版本中,solid-router/src/useParams.tsx 与 vue-router/src/useParams.tsx 提供同样的 API 语义。类型层面,router-core/src/useParams.ts 中的ResolveUseParams在strict: false时解析为AllParams<TRouter['routeTree']>(全路由参数并集),在严格模式下则精确解析为TFrom对应路由的allParams——这正是"类型完全推断、勿手动注解"的底层原因。
Loader 与beforeLoad中的参数
参数在数据加载与权限校验阶段同样可用。beforeLoad适合做基于参数的鉴权,loader适合基于参数拉取数据:
export const Route = createFileRoute('/posts/$postId')({ beforeLoad: async ({ params }) => { // 此处可读取 params.postId const canView = await checkPermission(params.postId) if (!canView) throw redirect({ to: '/unauthorized' }) }, loader: async ({ params }) => { return fetchPost(params.postId) }, })允许的字符(Allowed Characters)与编码
默认情况下,参数值使用encodeURIComponent编码。若某些 URL 语义字符(如邮箱中的@、查询分隔符+)不希望被编码,可在创建 router 时通过pathParamsAllowedCharacters放行:
import { createRouter } from '@tanstack/react-router' const router = createRouter({ routeTree, pathParamsAllowedCharacters: ['@', '+'], })可放行的完整字符集合为:;、:、@、&、=、+、$、,。
源码视角:允许字符如何生效
在 router.ts 中,pathParamsAllowedCharacters的类型被限定为上述 8 个字符的联合类型;router 初始化或update时,compileDecodeCharMap 会把每个允许字符的encodeURIComponent编码形式构建成一张字符映射表,并编译为一次性的全局正则——用于参数解码时把%40还原为@。编码侧仍走encodeURIComponent,随后用 decoder 恢复允许字符(见encodePathParam)。由于该正则只编译一次,性能开销可控。
仓库测试 router.test.tsx 逐一验证了放行字符后的行为:在pathParamsAllowedCharacters: [character]下导航params: { slug: '${character}jane%' },路径名中的该字符不会被编码(而/、?、\、%、#始终被排除在外)。
常见错误(Common Mistakes)
错误 1(严重,跨技能通用):把路径参数插值进to字符串
直接拼接会破坏类型安全与参数编码(特殊字符无法被正确编码/解码):
// 错误 —— 破坏类型安全与参数编码 <Link to={`/posts/${postId}`}>Post</Link> // 正确 —— 使用 params prop <Link to="/posts/$postId" params={{ postId }}>Post</Link>错误 2(中等):用*而不是$声明 Splat 路由
TanStack Router 用$表示通配段,捕获值位于_splat键而非*:
// 错误(React Router 等其他框架的写法) // <Route path="/files/*" /> // 正确(TanStack Router) // 文件:src/routes/files.$.tsx export const Route = createFileRoute('/files/$')({ component: () => { const { _splat } = Route.useParams() return <div>{_splat}</div> }, })注意:
*在 v1 中仅为向后兼容而保留,将在 v2 中移除,请始终使用_splat。(源码中的 TODO 注释亦证实了这一点,见 new-process-route-tree.ts)
错误 3(中等):对基本动态段滥用花括号
花括号仅用于前缀/后缀模式与可选参数。基本动态段使用裸$:
// 错误 —— 基本参数不需要花括号 createFileRoute('/posts/{$postId}') // 正确 —— 基本动态段用裸 $ createFileRoute('/posts/$postId') // 正确 —— 前缀模式用花括号 createFileRoute('/posts/post-{$postId}') // 正确 —— 可选参数用花括号 createFileRoute('/posts/{-$category}')错误 4:忘记"路径参数永远是字符串"
路径参数一律以字符串解析。需要数值时,应在 loader 或组件中自行转换,并校验合法性:
export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const id = parseInt(params.postId, 10) if (isNaN(id)) throw notFound() return fetchPost(id) }, })进阶:params.parse与params.stringify双向转换
如果不想在 loader 中手动parseInt,可以在路由上声明params.parse/params.stringify,实现参数值在 URL 字符串与运行时类型之间的双向映射。声明后,loader 中拿到的params.postId直接就是number:
export const Route = createFileRoute('/posts/$postId')({ params: { parse: (raw) => ({ postId: parseInt(raw.postId, 10) }), stringify: (parsed) => ({ postId: String(parsed.postId) }), }, loader: async ({ params }) => { // params.postId 现在是 number return fetchPost(params.postId) }, })源码视角:params.parse如何参与匹配
在 new-process-route-tree.ts 中,params.parse(或旧的parseParams)会被挂在段树节点上(node.parse);匹配过程中,validateParseParams 会先校验解析结果,解析失败则放弃该候选分支并尝试其他更具体的匹配。sortDynamic中"带 parse 的节点排在不带 parse 的节点之前"正是为了优先尝试更精确的参数约束,同时路由级params.priority可进一步控制同层动态段的匹配顺序。
小结
路径参数是 TanStack Router 类型安全体系的基石:$paramName提供动态段、$提供 Splat 全捕获、{-$paramName}提供可选段、{prefix{$x}suffix}提供段内前后缀,配合useParams(含Route.useParams、from、strict: false)与params.parse/stringify,可以在 React、Solid、Vue 三个框架中实现端到端推断、零手写类型声明的 URL 数据绑定。核心要点始终是:导航一律通过paramsprop 传递参数,参数类型交给框架推断,需要数值转换时使用params.parse。想深入理解匹配与编码的实现细节,可以继续阅读 path.ts、new-process-route-tree.ts 与各框架的 router.test.tsx 测试用例。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考