React Router 数据模式路由指南:从路由对象配置到匹配原理
【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router
本文基于当前仓库 docs/start/data/routing.md 展开。React Router 提供三种使用模式(Data、Framework、Declarative),本指南属于Data 模式路由教程,讲解如何用"路由对象(Route Object)"声明式地配置路由:路由的基本结构、嵌套与布局路由、索引路由、路径前缀、动态段、可选段与通配段(Splat)。读完你不仅能在
createBrowserRouter中写出完整、可运行的路由配置,还能理解每条路径背后的路由匹配与评分排序源码逻辑,以及仓库 packages/react-router/lib/router/utils.ts 中路由匹配器的真实实现。
前置背景:Data 模式下的路由入口
在 Data 模式(参见 modes.md)中,路由配置是一等公民:路由即数据,你通过createBrowserRouter把一个"路由对象数组"传入,从而获得完整的 data 能力——loader、action、错误边界与RouterProvider数据渲染等。安装与基础启动方式见 installation.md。
Data 模式下最核心的入口是createBrowserRouter,其完整 API 见 createBrowserRouter.md。本文约定的示例代码默认都运行在数据路由器之上,创建出的router实例再交由RouterProvider(RouterProvider.md)挂载渲染。
配置路由:createBrowserRouter 的第一参数
路由被配置为createBrowserRouter的第一个参数。一个最基础的路由至少需要path与Component两项:
import { createBrowserRouter } from "react-router"; function Root() { return <h1>Hello world</h1>; } const router = createBrowserRouter([ { path: "/", Component: Root }, ]);下面是一份更大的路由配置样例,它基本覆盖了本文所有要讨论的路由形态——嵌套、索引路由、动态段、布局路由:
createBrowserRouter([ { path: "/", Component: Root, children: [ { index: true, Component: Home }, { path: "about", Component: About }, { path: "auth", Component: AuthLayout, children: [ { path: "login", Component: Login }, { path: "register", Component: Register }, ], }, { path: "concerts", children: [ { index: true, Component: ConcertsHome }, { path: ":city", Component: ConcertsCity }, { path: "trending", Component: ConcertsTrending }, ], }, ], }, ]);从源码看"Component 路由对象"
在仓库中,"Component"形式的组件会在配置被消费时统一转换为底层的element形式。见 utils.ts 的defaultMapRouteProperties:
- 如果路由上存在
Component,则通过React.createElement(route.Component)生成element,并把Component置为undefined; - 如果同时写了
Component与element,开发模式下会输出告警("You should not include bothComponentandelementon your route"),并优先使用Component; ErrorBoundary/HydrateFallback组件同样会被转换为对应的errorElement/hydrateFallbackElement字段。
也就是说,路由对象数组并非直接被匹配引擎消费,而是先经过convertRoutesToDataRoutes(utils.ts)被"摊平并规范化"为DataRouteObject[]数据路由,每个路由会被生成一个全局唯一id。这一步还会做合法性校验——例如对索引路由追加children会直接抛出异常(详见下文"索引路由"小节)。
Route Object:不止是 path + Component
路由对象还负责定义路由除路径与组件之外的行为,例如数据加载(loader)与动作(action)。这里先用一个loader示例快速体验,更完整的讲解见 Route Object guide:
import { createBrowserRouter, useLoaderData, } from "react-router"; createBrowserRouter([ { path: "/teams/:teamId", loader: async ({ params }) => { let team = await fetchTeam(params.teamId); return { name: team.name }; }, Component: Team, }, ]); function Team() { let data = useLoaderData(); return <h1>{data.name}</h1>; }要点:
loader运行在路由匹配成功之后、组件渲染之前,返回值通过useLoaderData(见 useLoaderData.md)在组件中获取;loader接收的params中已经包含从 URL 解析出的动态段;action(表单提交处理)遵循同样的参数传递约定,可参考 actions.md。
这份路由对象的完整类型定义(RouteObject/IndexRouteObject/NonIndexRouteObject)以及RouteMatch、Params等类型都集中在 packages/react-router/lib/router/utils.ts,是理解"路径模式 → 匹配结果"的关键枢纽。
嵌套路由(Nested Routes)
通过children,路由可以层层嵌套。父路由的路径会被自动合并进子路由:
createBrowserRouter([ { path: "/dashboard", Component: Dashboard, children: [ { index: true, Component: Home }, { path: "settings", Component: Settings }, ], }, ]);上面的配置会同时产生"/dashboard"与"/dashboard/settings"两个 URL。注意子路由的path是相对父路由拼接的,因此无需(也不应)重复写/dashboard。
子路由的渲染通过父路由中的<Outlet/>完成——父组件负责"布局",Outlet 负责"当前匹配到哪个子路由就渲染谁":
import { Outlet } from "react-router"; export default function Dashboard() { return ( <div> <h1>Dashboard</h1> {/* 这里将渲染 <Home> 或 <Settings> */} <Outlet /> </div> ); }Outlet是 React Router 内置组件,其完整语义(嵌套上下文、位置等)见 Outlet.md。嵌套匹配的层级关系在matchRoutes返回的RouteMatch[]数组中可以直观看到——数组下标从根路由一路排到叶子路由(见matchRoutes的实现与 matchRoutes.md 文档)。
布局路由(Layout Routes)
省略路由对象中的path,就得到一个"布局路由":它为子路由提供一个新的嵌套层级,但不向 URL 添加任何路径段。
createBrowserRouter([ { // 父路由没有 path,只提供组件 Component: MarketingLayout, children: [ { index: true, Component: Home }, { path: "contact", Component: Contact }, ], }, { path: "projects", children: [ { index: true, Component: ProjectsHome }, { // 这里同样没有 path,仅作为布局组件 Component: ProjectLayout, children: [ { path: ":pid", Component: Project }, { path: ":pid/edit", Component: EditProject }, ], }, ], }, ]);需要注意:
Home与Contact会被渲染进MarketingLayout的 Outlet 中;Project与EditProject会被渲染进ProjectLayout的 Outlet,而ProjectsHome不会进入ProjectLayout——因为它是path: "projects"这一层父路由的直接索引路由。
从源码看,无 path 的路由在flattenRoutes阶段其relativePath会被视为空字符串(route.path || "",见 utils.ts),因此不会参与 URL 拼接,只参与组件嵌套层级。仓库中 layout-routes-test.tsx 对这类场景做了系统性覆盖。
索引路由(Index Routes)
在路由对象上设置index: true(且不提供 path),即为索引路由:
{ index: true, Component: Home }索引路由渲染进父级路由的 Outlet,并且出现在父路由的 URL上——它像一个"默认子路由",当 URL 精确等于父级路径时被渲染。
import { createBrowserRouter } from "react-router"; createBrowserRouter([ // 渲染于 "/" { index: true, Component: Home }, { Component: Dashboard, path: "/dashboard", children: [ // 渲染于 "/dashboard" { index: true, Component: DashboardHome }, { path: "settings", Component: DashboardSettings }, ], }, ]);一个经常被忽略的约束是:索引路由不能有子路由(children)。这条规则在源码中有强校验——convertRoutesToDataRoutes中直接抛出不变量断言:
Cannot specify children on an index route对应实现见 utils.ts。此外,路由从index: true判别索引路由(isIndexRoute,见 utils.ts),匹配成功后索引路由会被计入评分(indexRouteValue),相关测试可参考 index-routes-test.tsx。
前缀路由(Prefix Route)
当一个路由只有 path、没有 Component时,它并不会引入任何布局组件,而只是为它的子路由提供一段路径前缀:
createBrowserRouter([ { // 没有组件,仅提供路径 path: "/projects", children: [ { index: true, Component: ProjectsHome }, { path: ":pid", Component: Project }, { path: ":pid/edit", Component: EditProject }, ], }, ]);这会生成/projects、/projects/:pid与/projects/:pid/edit三个 URL,且无需任何中间布局组件。前缀路由与"布局路由"正好互补:
- 布局路由 = 有组件 + 无 path → 贡献组件层级,不贡献 URL;
- 前缀路由 = 无组件 + 有 path → 贡献 URL,不贡献组件层级。
两者都可以继续组合使用,在大型应用中这两种写法几乎会同时出现。
动态段(Dynamic Segments)
如果路径中的某一段以:开头,它就成为一个dynamic segment(动态段)。路由匹配 URL 时,该段会从 URL 中被解析出来,以params形式提供给其它路由器 API(loader / action 的参数等):
{ path: "teams/:teamId", loader: async ({ params }) => { // loader/action 中可直接使用 params let team = await fetchTeam(params.teamId); return { name: team.name }; }, Component: Team, }在组件里,通过useParams(见 useParams.md)读取:
import { useParams } from "react-router"; function Team() { // 组件中通过 useParams 读取 params let params = useParams(); // ... }一条路由路径中可以同时有多个动态段:
{ path: "c/:categoryId/p/:productId"; }实现细节:匹配引擎使用
paramRe = /^:[\w-]+$/来识别"整段动态参数",因此段名允许包含字母、数字、下划线(_)与连字符(-),见 utils.ts。URL 中带编码字符的动态参数匹配场景可参考 params-decode-test.tsx。
为什么 "trending" 不会被子路由 "…/:city" 抢占?
在上文的大样例里,concerts下同时存在:city与静态段trending。访问/concerts/trending时,两条分支都能匹配,如何确定谁胜出?答案是评分排序(ranked matching)。
匹配器会对全部路由分支做展平并打分排序:静态段得分(10)远高于动态段(3),动态段又高于其它形态,而 splat 段会被罚分(−2)。评分常量与computeScore的实现位于 utils.ts:
| 路由形态 | 得分常量 | 备注 |
|---|---|---|
| 静态段(static segment) | staticSegmentValue = 10 | 命中即加 10 |
| 部分动态段前缀 | partialDynamicSegmentValue = 3.5 | 段以:开头的部分匹配 |
| 整段动态参数 | dynamicSegmentValue = 3 | 满足paramRe |
| 索引路由 | indexRouteValue = 2 | 额外加分 |
| 空段 | emptySegmentValue = 1 | 如结尾/产生的空串 |
| splat 段 | splatPenalty = -2 | 对包含 splat 的分支整体罚分 |
因此/concerts/trending中,静态分支concerts/trending会比动态分支concerts/:city获得更高分数而优先匹配。rankRouteBranches以分数降序尝试(utils.ts),同分时按兄弟索引顺序排序。这个"静态优先于动态、具体优先于通配"的机制正是 greedy-matching-test.tsx 与 matchRoutes-test.tsx 所验证的行为。
可选段(Optional Segments)
在段结尾追加?,可以让该路由段变为可选:
{ path: ":lang?/categories"; }上面的配置同时匹配/categories与/en/categories。你也可以让静态段可选:
{ path: "users/:userId/edit?"; }此时/users/123与/users/123/edit都合法。
实现细节:可选段并不是靠正则回溯实现的,而是由匹配引擎在
flattenRoutes阶段通过explodeOptionalSegments"拆解"成多条等价分支(见 utils.ts)。例如:lang?/categories会被拆成"省略该段"与"包含该段"两种分支;这一设计也意味着可选段与父级可选段的组合会产生数量更多的候选分支,源码中对此有专门的注释说明其展开顺序。正因为存在这种展开机制,若子路由路径以/开头且父路由含可选段,部分分支按设计不会匹配,此时引擎会静默丢弃而非抛错(见 utils.ts)。
Splats(通配段)
Splat(也叫 catchall / star segment)是形如/*结尾的路径模式。只要路径模式以/*结尾,它就能匹配/之后的任意字符——包括其它/在内:
{ path: "files/*"; loader: async ({ params }) => { params["*"]; // 将包含 files/ 之后剩余的 URL }; }因此它常用于"兜底路由"(404 页)、多级嵌套的深层路径等场景。
由于*不是合法的标识符,你可以通过解构给它赋予新名字,常见的习惯命名为splat:
const { "*": splat } = params;实现细节:splat 必须是路径的最后一个段——匹配实现中仅在 splat 位于末尾时才应用通配捕获(见 utils.ts),并在计算
pathnameBase时用原始 splat 值反推前缀(utils.ts)。由于 splat 分支会获得splatPenalty罚分,在评分上它排在静态段与动态段之后,只有其它更具体的分支都匹配失败时才会命中——这正是"catchall 兜底"语义的底层保障。相关行为可查看 descendant-routes-splat-matching-test.tsx。
路径解析速查表
将本文讨论的各种路径形态汇总如下(均基于/dashboard一类父路径拼接示例):
| 写法 | 含义 | 匹配示例 |
|---|---|---|
"/dashboard" | 静态段 | /dashboard |
"teams/:teamId" | 动态段(params.teamId) | /teams/42 |
":lang?/categories" | 可选动态段 | /categories、/en/categories |
"users/:userId/edit?" | 可选静态段 | /users/1、/users/1/edit |
"files/*" | splat 通配段(params["*"]) | /files/a/b/c.txt |
{ index: true }(无 path) | 索引路由(父 URL 的默认渲染) | 父级 URL |
仅Component、无 path | 布局路由(不加 URL 段) | 继承子路由 URL |
仅path、无 Component | 前缀路由(只贡献 URL 前缀) | 子路由 URL |
小结与下一步
回顾本文的核心结论:
- Data 模式的路由是声明式路由对象数组,最小单元是
path + Component,可扩展loader、action等能力; - 通过
children实现 URL 拼接与组件嵌套,<Outlet/>决定子路由渲染位置; - 无 path(布局路由)与无组件(前缀路由)是两种互补的"透明中间层";
- 索引路由是父级 URL 上的默认子路由,且不能带 children;
- 路径语法覆盖
:dynamic、可选段?与兜底 splat*,动态段在 loader/action/组件中分别经params与useParams读取; - 底层匹配引擎用"静态段 > 动态段 > 索引路由加分 > splat 罚分"的评分机制保证最具体路由优先命中,实现集中在 packages/react-router/lib/router/utils.ts。
掌握了路径语法后,可以继续阅读:
- 路由对象能力全解:Route Object guide
- 数据加载:
loader与并发策略,见 contenteditable="false">【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考