深入解析React-router6的实现原理

409 阅读12分钟

一、前言

自从 react-router6 发布到现在已经好几个月了,用过 v6 版本的同学就会发现 v6v5 之间的差别是比较大的,不管是基本的 API 的使用,还是底层的路由匹配算法都是有着比较大的更新,本篇文章将从以下几个方面去详细的介绍 react-router6

  • react-router6 API 的基本使用。
  • RouterRoutesRoute 的原理实现。
  • Outlet 组件的原理。
  • 路由匹配算法的实现原理以及与 v5 的差别。

二、基本的使用

1、路由配置

import {BrowserRouter, Route, Routes, Link, useLocation} from 'react-router-dom';

function App() {
    console.log('router')
  return (
    <div>
      <BrowserRouter>
        <Menu/>
        <Routes>
          <Route element={<Home/>} path={'/home'}></Route>
          <Route element={<Login/>} path={'/login'}></Route>
          <Route element={<List/>} path={'/list'}>
            <Route element={<Detail/>} path={'/list/detail'}>
              <Route element={<Singer/>} path={'/list/detail/:singerId'}></Route>
              <Route element={<Music/>} path={'/list/detail/music'}></Route>
              <Route element={<Comment/>} path={'/list/detail/comment'}></Route>
            </Route>
            <Route element={<Create/>} path={'/list/create'}></Route>
          </Route>
        </Routes>
      </BrowserRouter>
    </div>
  );
}

我们在书写 v6 版路由的时候最直观的感受是可以写嵌套路由,在 v5 中我们的子路由需要写在父路由对应的组件中,从上面我们可以总结一下 v6 在使用中的注意点:

  1. Router 组件还是需要放在最顶层,它为子组件提供了 history 对象、location对象等信息。
  2. v6 中使用 Routes 替代了之前的 Switch 组件,但是这两个组件却不能直接划等号,因为 Routes 组件承载了从路径的匹配到组件的渲染这一系列流程,具体实现下面会介绍,而 Switch 组件只是根据当前的路径去匹配唯一一个 Route 组件,同时 Switch 组件可是可以被抛弃的。
  3. Route 组件只能在 Routes 组件中使用,原因是 Route 组件不能被当作普通的组件去执行,一旦执行就会报错,至于为什么这样设计接下来在原理介绍中会讲。
  4. v6 中我们可以直接使用嵌套路由,之前我们的子路由需要写在父路由对应的组件中书写,那现在就会有疑问了,那我们配置的子路由要怎么渲染出来呢?我们来看下 Detail 组件中是怎么写的:
import {Outlet} from 'react-router';
const Detail = () => {
    return (
        <div>
          <div>Detail</div>
           <Outlet/>
        </div>
    )
}
export default Detail;

在 Detail 组件中我们只写了一个 OutLet 组件,也就是说最终需要渲染的子路由中的组件都是通过 Outlet 组件渲染出来的,这种使用方式使得业务组件并不需要关注子路由中的组件是怎么渲染的。

2、新增API

  1. useNavigate 为了实现路由的跳转 v6 提供了 useNavigate 这个 hook,具体用法如下:
function App (){ 
    const navigate = useNavigate() 
    return (
        <button 
            onClick={() => navigate('/home',{ state: {name: 'xxx'}, replace: true}) }
        >
            跳转 
        </button> 
    )
}

navigate 的第一个参数是路径,第二个参数是一些配置,比如 state 表示我们需要带过去的参数,replace 表示是够使用 history.replace 方法进行路由的切换,默认是 history.push 方法。

  1. useSearchParams useSearchParams 是用来获取和修改当前路由的查询字符串,基本使用如下:
function App (){ 
   const [ getParams ,setParam] = useSearchParams() 
    return (
        <button 
            onClick={() => setParam({name: 'xilin')}
        >
            修改dearch
        </button> 
    )
}
  1. useRoutes useRoutes 允许我们通过参数的方式进行路由的配置,具体使用如下:
const Config = [
  {
    element: <Home/>,
    path: '/home'
  },
  {
    element: <Login/>,
    path: '/login'
  },
  {
    element: <List/>,
    path: '/list',
    children: [
      {
        element: <Detail/>,
        path: '/list/detail',
        children:[
          {
            element: <Singer/>,
            path: '/list/detail/:singerId'
          },
          {
            element: <Music/>,
            path: '/list/detail/music'
          },
          {
            element: <Comment/>,
            path: '/list/detail/comment'
          }
        ]
      },
      {
        element: <Create/>,
        path: '/list/create'
      }
    ]
  }
]

const Index = () => {
  const element = useRoutes(Config);
      return <div>{element}</div>
}

function App() {
  return (
    <div>
      <BrowserRouter>
        <Index/>
      </BrowserRouter>
    </div>
  );
}

在之前的版本如果想实现这个功能就得借助 react-router-config 这个库。

  1. useOutlet useOutlet 会返回当前当前组件中所匹配到的子路由对应的 element,在 Outlet 组件中就使用了这个 hook。

  2. 其他的一些在 react-router 内部用到的 hook 可以去官网上查看

三、Router

Router 作为最外层的容器,通过 Context.Provider 的方式将路由的信息传递给所管理的组件,下面以 BrowserRouter 为例看下里面具体做了什么事。

function BrowserRouter({
  basename,
  children,
  window
}) {
  // 使用useRef保存创建的history对象
  let historyRef = useRef();
  // 通过history这个第三方库提供的createBrowserHistory创建history对象
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({
      window
    });
  }

  let history = historyRef.current;
  // 使用useState保存路由信息,当路由发生变化时调用setState触发组件的更新。
  let [state, setState] = useState({
    action: history.action,
    location: history.location
  });
  // 使用history.listen监听路由的变化,当路由发生改变时就会调用setState方法,此时   就会导致重新执行BrowserRouter组件。
  useLayoutEffect(() => history.listen(setState), [history]);
  // 将路由信息全部作为Router组件的props传进去。
  return (
      <Router
        basename={basename}
        children={children}
        location={state.location}
        navigationType={state.action}
        navigator={history}
      />
    );
}

好了我们现在来梳理一下 BrowserRouter 组件做的事:

  1. 调用createBrowserHistory创建history对象
  2. 将location、action等信息保存在useState中
  3. 通过 history.listen 方法监听路由的变更,并将 setState方法作为回调函数,当路由变更时就会触发组件的更新,进而就会重新执行 Routes 组件进行组件的替换。
  4. 将路由的信息通过props的形式传给 Router 组件。

讲到这里大家可能对 history.listen 的实现比较感兴趣,下面笔者也简单给大家介绍 history.listen 实现以及什么时候会被触发。

listen方法

// listen 方法
listen(listener) {
  return listeners.push(listener);
}

// 执行listeners中回调函数的地方
function applyTx(nextAction) {
    action = nextAction;
    [index, location] = getIndexAndLocation();
    listeners.call({
      action,
      location
    });
}

其实 listen 方法的实现很简单,就是往 listeners 数组中注册一个监听者,也就是我们的回调函数,然后当执行 applyTx 函数时就会去遍历所有的回调函数然后一一执行,那这个方法又是什么时候被调用呢?其实我们可以反过来想我们可以通过什么手段去改变路由呢?

我们平时用的比较多的就是 history库中封装的 push(对应原生的pushState)、replace(对应原生的replaceState)和go方法,我们看下 push方法的实现:

  function push(to, state) {
    // ...
    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
      try {
       // 调用原生的pushState方法
        globalHistory.pushState(historyState, '', url);
      } catch (error) {
        window.location.assign(url);
      }
      // 调用applyTx方法调用注册的回调函数
      applyTx(nextAction);
    }
  }

现在就很清晰了当我们调用一些改变路由的方法时就回去调用注册的回调函数。接下来要介绍一下 Router 组件,刚刚我们介绍了 BrowserRouter 组件最终会将路由的信息通过 props 的形式传给 Router 组件,那 Router 组件干了啥呢?

Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigationType = NavigationType.Pop,
  navigator,
  static: staticProp = false,
}){ 
  // 将basename、navigator保存在navigationContext对象中
  let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );
  let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp;
  // 将pathname、search、state等信息保存在location对象中
  let location = React.useMemo(() => {
    let trailingPathname = stripBasename(pathname, basename);
    // ...
    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);
  // 将location和navigationContext分别放入对应的Context中传递给组件,其实这样做   一是为了区分这两者,二是利用多层Context减小数据的粒度从而减少组件不必要的渲染
  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
}

Router 组件做了两件事:

  1. 为一些属性添加默认值
  2. 将 location 对象和 navigation 对象通过对应的 Context 传递给子组件,这样的话我们在对应的子组件中就可以通过 useNavigateuseLocation 这两个 hook 去拿到对应的值。

四、Route

v6 中我们的组件都是挂载在 Route 组件的 element 属性上,而 Route 组件是需要配合 Routes 组件使用的,这个和 v5 还是有所区别的,在 v5 中一般来说 Route 是要用 Switch 组件包裹的,Switch 组件会根据当前路由 path 去匹配唯一的 Route 组件并渲染出来,但是 Route 组件也可以单独存在,接下来看一下 Route 组件到底做了什么事?

export function Route(_props){
  invariant(
    false,
    `A <Route> is only ever to be used as the child of <Routes> element, ` +
      `never rendered directly. Please wrap your <Route> in a <Routes>.`
  );
}

是不是很惊讶,他居然什么也没干就是个空函数,而且我们还不能执行这个函数,一执行就会报错,但是我们仔细想一下我们真的需要这个 Route 组件吗?答案是不需要,因为所有的匹配和渲染组件的逻辑全部都交给 Routes 组件了,Routes 组件如果想要拿到挂载在 Route 上的组件就可以直接通过 children.props.emement 获取到。

五、Routes

接下来就是最重要的功能组件 Routes,先看下他的实现:

export function Routes({children,location }) { return useRoutes(createRoutesFromChildren(children), location); }

从代码中我们可以看出来 Routes 主要是做了两件事:

  1. 调用 createRoutesFromChildren 方法创建 routes 树。
  2. 调用 useRoutes 返回匹配到的 react element 对象。

下面针对这两个点来分析。

1、createRoutesFromChildren 方法

全文我们将用下面这个例子来讲解:

      <BrowserRouter>
        <Routes>
          <Route element={<Home/>} path={'/home'}></Route>
          <Route element={<Login/>} path={'/login'}></Route>
          <Route element={<List/>} path={'/list'}>
            <Route element={<Detail/>} path={'/list/detail'}>
              <Route element={<Singer/>} path={'/list/detail/:singerId'}></Route>
              <Route element={<Music/>} path={'/list/detail/music'}></Route>
              <Route element={<Comment/>} path={'/list/detail/comment'}></Route>
            </Route>
            <Route element={<Create/>} path={'/list/create'}></Route>
          </Route>
        </Routes>
      </BrowserRouter>

接下来再看一下 createRoutesFromChildren 方法的实现:

export function createRoutesFromChildren(
    children: React.ReactNode //Routes 组件的children节点
  ): RouteObject[] {
    let routes: RouteObject[] = []; 
    React.Children.forEach(children, (element) => {
      // ... 这里是一些边界条件的处理
      let route: RouteObject = {
        caseSensitive: element.props.caseSensitive,
        element: element.props.element, // 拿到在Route组件中注册的element元素
        index: element.props.index, 
        path: element.props.path, // 拿到Route组件中注册的path
      };
  
      if (element.props.children) {
        // 如果 Route 组件有子节点则将createRoutesFromChildren的返回值赋给children
        route.children = createRoutesFromChildren(element.props.children);
      }
      routes.push(route);
    });
  
    return routes;
  }

有一点算法基础的同学应该一眼就能看出来这就是一个简单的递归算法,这个方法会遍历每一个子节点,然后将每个节点的信息保存在 RouteObject 这种结构的对象中去,子孙节点之间通过 children 属性进行连接。

这里有一个知识点要注意一下,createRoutesFromChildren 方法拿到的 children 是 Routes 组件的子节点,这个子节点其实是 React.createlement 的返回结果(组件对应的jsx对象),此时 Route 组件并未真正的执行,在返回的 jsx 对象中有个 type 属性,如果这个组件是函数式组件,那么这个 type 就是这个函数,最终React在生成对应的 stateNode 时会调用这个函数,👇展示的是第一个字节点对应的 jsx 对象。

image.png 现在我们知道了 createRoutesFromChildren 方法的返回值就是一颗 routes 树,具体的结构如下图所示:

image.png 得到 routes 树之后我们就可以根据当前的路由进行匹配了,那到底是如何匹配的呢?下面来看下 useRoutes 的实现。

2、useRoutes

代码实现

export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
){
 // ... 处理pathname
let pathname = location.pathname || "/";
let remainingPathname =
  parentPathnameBase === "/"
    ? pathname
    : pathname.slice(parentPathnameBase.length) || "/";
// 根据pathname找到匹配的路径
let matches = matchRoutes(routes, { pathname: remainingPathname });

// 渲染匹配到的组件
return _renderMatches(matches && matches.map((match) =>
      Object.assign({}, match, {
        params: Object.assign({}, parentParams, match.params),
        pathname: joinPaths([parentPathnameBase, match.pathname]),
        pathnameBase:
          match.pathnameBase === "/"
            ? parentPathnameBase
            : joinPaths([parentPathnameBase, match.pathnameBase]),
      })
    ),parentMatches);
}

useRoutes 也做了三件事:

  1. 处理 pathname
  2. 调用 matchRoutes 找到与 pathname 匹配的路径,这条路径指的是从根节点到叶子结点的一条路径。
  3. 根据匹配到的路径渲染出最终的组件。 接下来我们主要介绍后面两步。

3、matchRoutes方法

function matchRoutes(routes, locationArg, basename = "/") {
   // ... 特性情况的处理
   // 扁平routers并对每个route进行打分
  let branches = flattenRoutes(routes);
  // 对扁平后的routes进行排序
  rankRouteBranches(branches);
  let matches = null;
  // 寻找与pathname匹配的路径
  for (let i = 0; matches == null && i < branches.length; ++i) {
    matches = matchRouteBranch(branches[i], pathname);
  }

  return matches;
}

matchRoutes 也分了三步:

  1. 扁平化routers并给每个route进行打分。 扁平化的作用我们很容易想到就是方便后续的遍历操作,但是为什么要打分呢?我来句一个很简单的例子,假如我们 Routes 结构如下, pathname还是用上面给的:
<Routes>
  <Route element={<List/>} path={'/list'}>
    <Route element={<Detail/>} path={'/list/detail'}>
      <Route element={<Singer/>} path={'/list/detail/:singerId'}></Route>
      <Route element={<Comment/>} path={'/list/detail/comment'}></Route>
    </Route>
  </Route>
</Routes>

此时同学们猜猜他会匹配到哪个 Route 组件呢?如果是按照 v5 的逻辑来的话那肯定是匹配到 '/list/detail/:singerId',但是很显然这不是我们想要的结果,这个问题在 v6 就得到了很好的解决,他通过给每一个 Route 进行打分,path 越精确分数就越高,在排序的时候就会被排到最前面,这样就会最先被遍历到。

那么我们的例子最终扁平化的结果是什么呢?如下图所示:

image.png 从图中我们能够很明显的看到每个 branche 的分数,同时在每个 branche 的的 routesMeta 中都保存了从当前节点到根节点这条路径上的所有的 route,因为我们在匹配的时候一旦匹配到了就会跳出循环,那如果我们想要拿到这条路径上的所有 route 就得提前保存。

  1. 根据每个 branche 的分数进行排序,如果分数相同就根据该 branche.routesMeta 中route 的 childrenIndex(每个route在其父组件中出现的顺序) 来进行比较,也就是 compareIndexes 函数,下面以 /list/detail/music/list/detail/comment 为例。
// /list/detail/music
childrenIndexs = [2,0,1] // 2为 /list 对应的 route 在其父组件中出现的位置,其他以此类推

// /list/detail/comment
childrenIndexs = [2,0,2]

// compareIndexes函数
function compareIndexes(a, b) {
  let siblings = a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]);
  return siblings ? a[a.length - 1] - b[b.length - 1] : 0;
}

此时将这两个 index 数组传进 compareIndexes ,先比较长度,如果长度相同再比较前 n-1 项是否相同,如果相同就比较最后一个值的大小,也就是说 /list/detail/comment 会排在 /list/detail/music 前面。

  1. 遍历排好序的 branches,返回匹配到的路径,我们看下 /list/detail/comment 最终匹配到的路径是什么。

image.png

4、renderMatches 方法

renderMatches 方法会根据我们匹配到的路径来渲染最终的组件,其实我们现在简单想一想,如果要我们实现其实直接将 matches 中的第一个 Route 中注册的 element(对应 List 组件) 渲染出来就行了,但是 List 组件下的子路由该怎么渲染呢?,这就要用到上面我们所介绍的 Outlet 组件了,也就是说 List 组件中的 <Outlet/> 组件要渲染的就是 /list/detail 对应的组件,也就是 matches 数组中的第二项,想到这里大家可以先脑补一下实现,我们来看下 renderMatches 的实现:

export function _renderMatches(
  matches: RouteMatch[] | null,
  parentMatches: RouteMatch[] = []
) {
  if (matches == null) return null;
  // 从右往左依次遍历将每个 route 对应的组件作为前一个 route 对应的组件的 children
  return matches.reduceRight((outlet, match, index) => {
    return (
      <RouteContext.Provider
        children={
          match.route.element !== undefined ? match.route.element : outlet
        }
        value={{
          // 这个outlet对应的就是该组件要渲染的子路由的组件
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1)),
        }}
      />
    );
  }, null);
}

代码看不太懂的可以先下面这张图:

image.png 其实原理也很简单,就是使用一个 Context 去包裹要渲染的组件,这个 Context 的 value 中保存的是该组件要渲染的子路由对应的组件,这样的话我们在 Outlet 组件中其实只要使用 useContext 就能从最近的 Context 中取出对应的 outlet 值就行,下面看下 Outlet 组件的实现。

六、Outlet

// Outlet 实现
function Outlet(props) {
  return useOutlet(props.context);
}

// useOutlet 实现
export function useOutlet(context?: unknown) {
  // 从最近的RouteContext中取出其outlet值并渲染出来
  let outlet = React.useContext(RouteContext).outlet;
  if (outlet) {
    return (
      <OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
    );
  }
  return outlet;
}

Outlet 组件的实现正如我们上面说的,就是通过 useContext 去取最近的 RouteContext 保存的 outlet 值。

七、总结

本章主要介绍了 react-router6 的一些基本使用以及一些实现原理,有什么写的不对的地方欢迎评论指出。