本文针对的是 react-router v6
在使用 react-router 的时候遇到了一个问题, 出于好奇写下了此文.
问题是这样的, 有一份很简单的路由配置表:
[
{
path: "/",
element: <Layout />, // 全局路由
children: [
{
index: true,
element: <h2>Home</h2>,
},
{
path: 'about',
element: <h2>About</h2>
},
{
path: 'dashboard',
element: <h2>Dashboard</h2>
}
],
}
]
当路径是 / 的时候, 页面会展示 Layout + home
当路径是 /about 的时候, 页面会展示 Layout + about
那么从直觉上来说, 当路径是一个任意匹配不到的路径时, 比如 /nothing 时, 页面至少应该展示 Layout
, 然后在 Layout
的出口处展示 404
页.
但是实际的情况是整个页面都是一个 404
页.
那 react-router 的内部匹配逻辑到底是什么样的呢? 我们跟着源代码来整理一下:
首先, 使用 react-router 的方式有好多种, 有组件式, 有配置式的等. 不同的方式其内部的匹配逻辑稍有不同, 我们以配置式为例, 因为配置在使用上最为简单和灵活.
- 首先创建一个 router
const router = createBrowserRouter([
{
path: "/",
element: <Layout />, // 全局路由
children: [
{
index: true,
element: <h2>Home</h2>,
},
{
path: 'about',
element: <h2>About</h2>
},
{
path: 'dashboard',
element: <h2>Dashboard</h2>
}
],
}
])
- 把 router 传给 RouterProvider 组件
<RouterProvider router={router} />
下面是源码解释:
router 实例
通过 createBrowserRouter 函数创建出来的 router 实例本质上就是一个对象.
在初始化实例时会建立对 history 的监听.
// 仅示意, 这是 router 实例上的几个重要的属性
router = {
get basename() {
return basename;
},
get routes() {
return dataRoutes;
},
get state() {
return state;
},
}
get routes
对传入的 routes 数据进行转换, 加了 id 等数据, 方便匹配.
[
{
id: '0',
path: '/',
hasErrorBoundary: false,
children: [
{id: '0-0', index: true, element: '...', hasErrorBoundary: false },
{id: '0-1', path: 'about', element: '...', hasErrorBoundary: false}
]
},
]
get state
存储一些反映当下路径下的一些信息:
{
initialized: boolean // 初始数据是否加载
location: Location // 当前的 location 对象 {key, pathname, search, hash, state}
matches: 根据当前 location 得到的匹配信息, 下文有详说. 如果 matchers 为 null, 则会把下面 getShortCircuitMatches(dataRoutes) 函数返回的 matches 赋给它.
errors: 这是一个对象, 键值是 route 对象的 id.
// 如果 matches 没有值(匹配不到), 则会给这个 errors 对象赋值.
// 这个错误信息赋给哪个对象呢?
// let { matches, route } = getShortCircuitMatches(dataRoutes); // 关键代码
// 简单来说就是错误信息会优先赋给根路由.
// { errors: [route.id]: error }
}
所以如果要捕获这个 404 页面, 可以在根路由上加 errorElement.
matches
当前路径对应的匹配项, 这是 react-router 匹配的核心逻辑所在.
它是 matchRoutes
函数的返回值
matchRoutes
函数简单来说执行了这几步:
flattenRoutes(routes)
把 routes 树打平成一个数组并赋值给变量 branchesrankRouteBranches
根据分值排序- 遍历数组 branches, 执行
matchRouteBranch
函数, 函数返回值赋给变量 matches, 只要 matches 有值就停止遍历. - 得到 matches.
以文章开头例子中的 routes 来说:
flattenRoutes(routes)
结果为
[
{ path: '/about', score: 13, routesMeta: [{ relativePath: '/', childrenIndex: 0, route: {...}}, { relativePath: 'about', childrenIndex: 1, route: {...}}] },
{ path: 'dashboard', score: 13, routesMeta: ...},
{ path: '/', score: 6, routesMeta: [{ relativePath: '/', caseSensitive: false, childrenIndex: 0, route: {...}}, { relativePath: '', caseSensitive: false, childrenIndex: 0, route: {...}}] },
{ path: '/', score: 4, routesMeta: [{ relativePath: '/', route: {...}}] }
]
注意看这里面的 routesMeta 字段, 它存放了从根节点到子节点的一个数组. 这对文章开头的问题的理解非常重要.
rankRouteBranches
不细说.matchRouteBranch
简单来说就是对 routesMeta 数组进行遍历匹配, 返回 matches.
假设当前路径是 /about, 那么 matches 的值为:
[
{pathname: '/', pathnameBase: '/', route: {...} // route 为对应的路由配置表},
{pathname: '/about', pathnameBase: '/about', route: {...}}
]
这个方法里有一段比较重要的代码:
// 部分截取
function matchRouteBranch(branch, pathname) {
let { routesMeta } = branch;
for (let i = 0; i < routesMeta.length; ++i) {
let match = matchPath(...)
if (!match) return null; 👈
}
}
也就是说 routesMeta 里的项需要都匹配才行. 这也解释了文章开头的问题, 需要根节点到子节点都匹配才能得到 matches, 否则 matches 为 null
RouterProvider 组件
RouterProvider 接受一个 router 实例, 它的作用是根据当前的路径来渲染出正确的 UI.
在这个组件内部它又执行一遍 const matches = matchRoutes(routes), 然后把这个 matches 和 router.state 传给一个 _renderMatches
方法, 这个方法会渲染出相应的 ReactNode.
// 简化版, 仅示意
export function _renderMatches(matches, dataRouterState){
if (matches == null) {
if (dataRouterState?.errors) {
matches = dataRouterState.matches;
} else {
return null;
}
}
let renderedMatches = matches;
// reduceRight 方法, 从右往左累加
return renderedMatches.reduceRight((outlet, match, index) => {
let getChildren = () => {
let children;
if (error) {
children = errorElement;
} else if (match.route.element) {
children = match.route.element;
} else {
children = outlet;
}
return (
<RenderedRoute
match={match}
routeContext={{
outlet, // 👈 这个 outlet 跟那个 <Outlet /> 组件不是同一个东西, 这个是一个累加器;
matches,
isDataRoute: dataRouterState != null,
}}
children={children}
/>
);
}
return getChildren()
}
}
function RenderedRoute({routeContext, children}) {
return (
<RouteContext.Provider value={routeContext}> // 👈 这里是关键, 把子节点的组件放在 context 里, 这样在父节点里通过 <Outlet /> 组件就可以拿到子节点的组件.
{children}
</RouteContext.Provider>
);
}
export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}
export function useOutlet(context?: unknown): React.ReactElement | null {
let outlet = React.useContext(RouteContext).outlet;
if (outlet) {
return (
<OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
);
}
return outlet;
}
看到这里应该能明白为什么在 <Layout />
组件里一定要用 <Outlet />
才能渲染出子路由元素了.
回到开头的问题, 如果想要让任何路径都能被根路由匹配, 可以使用通配符.
写在最后
推荐一个 React 组件库 react-admin-kit, 中后台系统的最佳伴侣, 用来写后台爽的要死, 安利一下!