【面试官】React router dom 原理你知道吗

3,524 阅读10分钟

React router dom v6 从浅到浅

前言

当面试官问你,React router 原理是什么,很多同学可能脱口而出 history、hash ,但这就足够了么?相信面试官肯定想听到更多理解,本文的浅分析,分成三大块,html5 history apiHistory库 以及重点 React router dom库,您可以了解到:

  • 为何 React-react 包内有三个不同模块
  • 如何监听路由变化并切换相应组件
  • 嵌套路由是如何实现的

本文纯属自己兴趣在探究原理的过程中记录下的,有不对地方请多包涵。当然有帮助的话不要吝啬您的👍

阅读前准备

阅读本文前最好能够先把源码下下来对照分析,会加深印象

git clone https://github.com/ReactTraining/history.git
git clone https://github.com/ReactTraining/react-router.git (记得切换分支到 v6.0 版本)

HTML5 - History API

History 是 HTML5 新出的API,允许操作浏览器的曾经在标签页或者框架里访问的会话历史记录。主要特性是可以在不刷新整个页面的情况下修改站点的URL。像 vue-routerreact-router-dom 都是基于这个特性来实现路由的跳转的。

直接在控制台上打印 window.history 看看:

主要讲解常用的几个属性以及 API

History.length :返回一个整数,表示会话历史中元素的数目,包括当前加载的页

History.state :返回一个表示历史堆栈顶部的状态的值

History.back() :前往上一页,即返回按钮,等价于 history.go(-1)

History.go() :通过当前页面的相对位置从浏览器历史记录( 会话记录 )加载页面

History.pushState() :按指定的名称和URL(如果提供该参数)将数据push进会话历史栈

History.replaceState() :按指定的数据,名称和URL(如果提供该参数),更新历史栈上最新的入口

注意: pushStatereplaceState 主要区别在于,前者是会往历史记录栈顶加一条记录,而后者是直接替换当前的栈记录。比如当在登录页 /login 登录成功后进行跳转时,一般使用 repalceState 直接替换掉 login 这条记录,这样防止点击后退时进入登录页又直接跳转回主页的情况。

这里还有个事件可以注意下:popstate ,当活动历史记录条目更改时,将触发 popstate 事件。当调用 pushStatereplaceState 时不会触发 onPopstate 事件,只有在调用了 back() 或者 forward() 方法才会触发。

更多相关知识可以查看 MDN介绍

History库

这里主要是针对 history@5.0.0 版本进行分析。5.0版本是基于 ts 进行重构的,所以对 ts 不熟悉的同学可以简单去过下 ts 的基础知识,但其实理解上也差不多通用。

History 库基于浏览器history api 上封装了下,提供了一个包含 URLsstatelocation 对象,追踪浏览器的历史记录。下面分析下源码。

由于这里主要针对浏览器的 history 进行解析的,所以着重看 createBrowserHistory 这个方法,在代码的第 397 ~ 600 行,内容不多。

首先先来看下 createBrowserHistory 返回了什么内容(第 559 ~ 600 行)

这里我们主要讲两个,一个是 location ,另一个是 listen ,这两个是比较核心的物件。其他api像 go()back() 跟原生的方法基本相同,有兴趣可以自己琢磨下

createBrowserHistory

  • 🤠 第 397 ~ 401

比较容易理解,解构传过来的 options 的值,如果没有 window 参数,则默认取当前窗口对象所关联的 window 对象。然后把 history 存放到一个变量里

  • 403 ~ 416

通过 window.location 获取当前的路由位置信息并解构,然后返回一个由 history stateidx 属性以及 location 对象组合的数组。继续往下看

  • 🤠 第 419 ~ 463

这里我省略了一些代码,那块主要是处理路由跳转前的一个确认操作,先不做分析。此处主要监听了一个事件 popState ,这个事件开头也说过了,当调用 back() 或者是 forward() 才会触发。来看下这个事件做了哪些操作。定义一个变量 nextActionAction.Pop 也就是 pop 。然后调用 applyTx() 方法。下面继续看这个方法做了什么操作。

  • 🤠 第 508 ~ 512

接收一个参数 nextAction ,然后调用 getIndexAndLocation() 方法, 此方法我们上面讲过,主要是获取当前路由的相关信息,即 location 对象。然后调用了一个 listeners.call()actionlocation 传入。继续来看 listeners 是何方神圣。

  • 🤠 第 465 ~ 468 以及 1048 ~ 1065

来看 listeners 的定义,是由一个 createEvents() 返回的。这个方法,顾名思义,就是创建事件。定义了一个变量 handlers 数组,用于存放要处理的回调函数事件。然后返回了一个对象。push 方法就是往 handlers 中添加要执行的函数。这块主要在 history.listen() 中使用,可以翻到开头看下 history 中返回了 listen() 方法,就是调用了 listeners.push(listener) 。最后 call() 方法就比较容易理解,就是取出 handlers 里面的回调函数并逐个执行。

梳理

按照上面一步一步讲下来,肯定有同学又忘了上一步是干啥的了。这里简单用图把所有流程串起来,梳理清楚。

  • history 导出了名为 createBrowserHistory 的方法,其中监听了 popState 事件
  • 当触发 popState 事件后,调用 getIndexAndLocation 获取当前最新的路由对象信息并更新 location 的值
  • 取出放在 listeners 里面的回调函数,并循环执行,传入当前的 actionlocation 作为回调函数的参数

从这里可以看出 react-router 能够得知路由的变化,主要靠的就是 history.listen 这个方法,将路由变化时要执行的回调函数传入进去。下节开始分析重头戏 react-router-dom 是如何结合 History 库应用的。

React-router-dom

注意:以下源码是基于 v6.0.0 版本的。

下面开始分析 React-router-dom ,直接查看源码包,可以发现有三个文件夹

  • react-router :这是实现路由的核心代码
  • react-router-dom :这是为浏览器dom专用,新增了如 createBrowserHistory
  • react-router-native :这是为 RN 准备的路由相关库

v6版本基本用法

直接先来看在 v6 版本中一个路由该如何写

可以看到,最外层仍然是用 BrowserRouter 包裹着路由,只不过 Switch 换成了 RoutesRoutes 组件是整个路由中最核心的,决定了当前路径应匹配显示那个页面组件。这里还有个点不一样,对于嵌套路由,比如示例中当路径是 users/me 时,会先匹配到 Users 组件,Users 组件里可以看到有个 <Outlet /> 组件,相当于占位符,二级路径 me 匹配到了 <OwnUserProfile> 组件,填充进 Outlet ,从而实现路由的嵌套。下面看下每个组件的内容

BrowserRouter

🤠react-router-dom 第 92 ~ 118 行

前几行,使用 useRef 定义了一个可变的对象,调用了 History 库的 createBrowserHistory 方法,并存放到 ref 里去。这样可以达到变量 history 对象中取的永远是最新的值

接下来,定义了一个 useReducer ,初始的 state 是从 history 对象中取出 action 以及 location 的值。具体 dispatch 做了什么,等下再说,先往下看

调用了 history.listen() 并把上面返回的 dispatch 方法作为回调传进去。具体 listen 方法做了什么上面讲过了,忘了的同学可以翻回去看下。当dispatch 被调用时会接收一个参数 { action, location } ,这个参数会传递到 reducer 里面的 action 里去,然后直接返回 action ,从而更新了 state 的值。注意要区分history里的action 和 reducer里的action,这段 reducer 就比较好理解了。

最后返回 <Router> 组件,紧跟着找下这个组件。

🤠react-router 第 281 ~ 300 行

比较容易理解,接收参数,并返回一个 Context.Provider ,传递数据给下面的子组件。

Route

🤠react-router 第 249 ~ 253 行

我们先跳过 Routes ,直接先看 Route ,很简单,就直接返回 element 里的内容

Routes

下面看下 Routes ,作为路由里面最核心的部位,内容比较多,分析过程中可能会省略一些不重要的东西。

🤠react-router 第 333 ~ 340 行

实际就两行,调用 createRoutesFromChildren 方法,这个方法就不具体讲了。主要是利用 React.Children 将子组件里的内容提取出来,返回一个数组。格式如下图所示:

最后返回 useRoutes_ 方法的结果。

useRoutes_

🤠react-router 第 582 ~ 639 行

这个方法比较复杂,所以分析时可能会省略一些。整体看下,该方法调用了 matchRoutes 匹配出和当前路径相符和的列表,然后直接返回渲染对应的组件。因此重点是 matchRoutes 这个方法

🤠react-router 第 768 ~ 798 行

location 对象中获取 pathname ,并调用 flattenRoutes 将数组打平(该方法不做说明,可以去源码看下实现内容),branches 内容如下:

可见,branches 中的每项由三部分组成,最主要是前两个,第一个是 路径 ,第二个则是包含的 route 对象,比如 users/me 是由两部分组成的,因此 route 数组中就包含了这两个路径的属性值。接下来的 rankRouteBranches 是根据路径来进行排序,比如通配符,路径长短等。具体可以看下实现方式,不详细赘述。

接下来遍历了 branches 数组,调用 matchRouteBranch 进行一一匹配。看下 matchRouteBranch 方法。

matchRouteBranch

🤠react-router 第 904 ~ 941 行

这部分能说是路由匹配的核心算法,取上部分每一项 branch 进行匹配。比如还是按照上面那个例子,当前路径是 /users/me ,一开始 remainingPathname 初始值就是要匹配的路径 /users/me ,调用 matchPath 进行匹配判断,匹配成功返回匹配到的路径即 /users ,放到 matches 数组里面。下个循环,remainingPathname 则变成了 /me ,去掉了已经匹配到的 /users 部分,后面操作同样。最终 matches 数组如下(此处暂不考虑路径带有参数的情况):

这部分路由匹配可能比较繁杂,建议可以截取源码部分放到控制台上,自己模拟数据运行 debugger,一步一步走,可能会更加清晰。

最后,回头看下一开始的 useRoutes_ 方法返回了什么

分析上面代码时先看看 outlet 做什么,这个在前面已经讲过它的作用,相当于子路由的占位符

可以看到,其实就是使用 useContext 获取最近的 RouteContext 中的 outlet 的值。

因此,使用 reduceRighr 从右侧即从子路由的组件开始遍历,children 理所当然是 element 里的组件内容。每次循环 outlet 的值都会等于其中 return 的值,总的来说,有点像 千层饼,层层嵌套。类似下面:

<RouteContext.Provider 
    value={{
        outlet // 嵌套
           |
           |----<RouteContext.Provider 
                        value={{
                            outlet // 嵌套
                                |
                                |----<RouteContext.Provider 
/>

这样 outlet 占位符取到的就是离它最近的父组件提供的 value 的值,渲染对应的组件。

总结

由于本篇只是针对 react-router-dom 的原理进行简单分析,所以略过了一些路由匹配边界的判断以及携带参数时的处理方法。

看完想必有些同学可能还是懵懵懂懂,借用一张图来概括下整个流程

  1. 首先,<Router> 元件用 history.location 初始化 location 状态
  2. <Route> 元件会从 Context 中拿到 location ,然后渲染符合 location 的元件
  3. 当使用者点击 UI 上的 <Link> 时,会呼叫 history.push() 把定义在 <Route> 上的 to 放进 history 中,这时浏览器的 URL 会跟着一起变动,但不会跳转页面
  4. 接着,位置的改动会触发 history.listen(),调用 useReducer 中的 dispatch ,进而改变原本存储在 <Router> 中的 location 状态,重复以上第二步骤

Tips: 如果本文有错误欢迎各位同学指出,谢谢啦😉

参考资料

React-router-dom | 原理解析