react-router 的路由匹配逻辑

本文针对的是 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 的方式有好多种, 有组件式, 有配置式的等. 不同的方式其内部的匹配逻辑稍有不同, 我们以配置式为例, 因为配置在使用上最为简单和灵活.

  1. 首先创建一个 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>
      }
    ],
  }
])
  1. 把 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 函数简单来说执行了这几步:

  1. flattenRoutes(routes) 把 routes 树打平成一个数组并赋值给变量 branches
  2. rankRouteBranches 根据分值排序
  3. 遍历数组 branches, 执行 matchRouteBranch 函数, 函数返回值赋给变量 matches, 只要 matches 有值就停止遍历.
  4. 得到 matches.

以文章开头例子中的 routes 来说:

  1. 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 字段, 它存放了从根节点到子节点的一个数组. 这对文章开头的问题的理解非常重要.

  1. rankRouteBranches 不细说.
  2. 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, 中后台系统的最佳伴侣, 用来写后台爽的要死, 安利一下!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值