深入浅出解析React Router 源码
秦氏明月白月光7
2021年04月13日 11:48

最近组里有同学做了 React Router 源码相关的分享,我感觉这是个不错的选题, React Router 源码简练好读,是个切入前端路由原理的好角度。在分享学习的过程中,自己对前端路由也产生了一些思考和见解,所以写就本文,和大家分享我对前端路由的理解。

本文会先用原生JS实现一个基本的前端路由,再介绍 React Router 的源码实现,通过比较二者的实现方式,分析 React Router 实现的动机和优点。阅读完本文,读者们应该能了解:

  1. 前端路由的基本原理

  2. React Router 的实现原理

  3. React Router 的启发和借鉴

一. 我们应该如何实现一个前端路由

一开始,我们先跳出 React Router,思考如何用原生 JavaScript 实现一个的前端路由,所谓前端路由,我们无非要实现两个功能:监听记录路由变化,匹配路由变化并渲染内容。以这两点需求作为基本脉络,我们就能大致勾勒出前端路由的形状。

路由示例:

1.Hash 实现

我们都知道,前端路由一般提供两种匹配模式, hash  模式和  history  模式,二者的主要差别在于对 URL 监听部分的不同,hash 模式监听 URL 的 hash 部分,也就是 # 号后面部分的变化,对于 hash 的监听,浏览器提供了 onHashChange  事件帮助我们直接监听 hash 的变化:

hash 模式的实现比较简单,我们通过 hashChange 事件就能直接监听到路由 hash 的变化,并根据匹配到的 hash 的不同来渲染不同的内容。

2.History 实现

相较于 hash 实现的简单直接,history 模式的实现需要我们稍微多写几行代码,我们先修改一下 a 标签的跳转链接,毕竟 history 模式相较于 hash 最直接的区别就是跳转的路由不带 # 号,所以我们尝试直接拿掉 #​号:

点击 a 标签,会看到页面发生跳转,并提示找不到跳转页面,这也是意料之中的行为,因为 a 标签的默认行为就是跳转页面,我们在跳转的路径下没有对应的网页文件,就会提示错误。那么对于这种非 hash 的路由变化,我们应该怎么处理呢?大体上,我们可以通过以下三步来实现 history 模式下的路由:

在开始写代码之前,我们有必要先了解一下 H5 的几个 history API 的基本用法。其实 window.history  这个全局对象在 HTML4 的时代就已经存在,只不过那时我们只能调用 back()、go()等几个方法来操作浏览器的前进后退等基础行为,而 H5 新引入的 pushState()和 replaceState()及 popstate事件 ,能够让我们在不刷新页面的前提下,修改 URL,并监听到 URL 的变化,为 history 路由的实现提供了基础能力。

详细的参数介绍和用法读者们可以进一步查阅 MDN,这里只介绍和路由实现相关的要点以及基本用法。了解了这几个 API 以后,我们就能按照我们上面的三步来实现我们的 history 路由:

Tips:history 模式的代码无法通过直接打开 html 文件的形式在本地运行,在切换路由的时候,将会提示:

Uncaught SecurityError: A history state object with URL file://xxx.html cannot be created in a document with origin 'null&#​39;.

这是由于 pushState 的 url 必须与当前的 url 同源,而 file:// 形式打开的页面没有 origin ,导致报错。如果想正常运行体验,可以使用 http-server 为文件启动一个本地服务。

History 模式的实现代码也比较简单,我们通过重写 a 标签的点击事件,阻止了默认的页面跳转行为,并通过 history API 无刷新地改变 url,最后渲染对应路由的内容。到这里,我们基本上了解了hash 和history 两种前端路由模式的区别和实现原理,总的来说,两者实现的原理虽然不同,但目标基本一致,都是在不刷新页面的前提下,监听和匹配路由的变化,并根据路由匹配渲染页面内容。既然我们能够如此简单地实现前端路由,那么 React Router 的优势又体现在哪,它的实现能给我们带来哪些启发和借鉴呢。

二. React Router 用法回顾

在分析源码之前,我们先来回顾一下 React Router 的基本用法,从用法中分析一个前端路由库的基本设计和需求。只有先把握作为上游的需求和设计,才能清晰和全面地解析作为下游的源码。

React Router 的组件通常分为三种:

路由器组件:  和 ,路由器组件的作为根容器组件, 等路由组件必须被包裹在内才能够使用。 路由匹配组件:  和 ,路由匹配组件通过匹配 path,渲染对应组件。 导航组件:  和 ,导航组件起到类似 a 标签跳转页面的作用。在后续对源码的讲解中,也将分别以这六个组件代码的解析为线索,来一窥 React Router 的整体实现。看回我们的代码,对于我们开头实现的原生路由,如果用 React Router 改写,应该是怎样的写法呢:

我们使用  React Router 重新实现了一遍开头原生路由的功能,二者既有对应,也有差别。  对应 a标签,实现跳转路由的功能; 对应 onPopState() 中的渲染逻辑,匹配路由并渲染对应组件;而 对应 addEventListener 对路由变化的监听。

下面我们就进入 React Router 的源码,去一探这些组件的实现。

三. React Router 源码实现

1.目录概览

React Router 的代码主要存在于 packages 文件夹下,在 v4 版本后,React Router 就分为了四个包来发布,本文解析的部分主要位于 react-router 和  react-router-dom 文件夹。

2.BrowserRouter 和 HashRouter

和  都是路由容器组件,所有的路由组件都必须被包裹在这两个组件中才能使用:

我们会发现这二者就是一个壳,两者的代码量很少,代码也几乎一致,都是创建了一个 history对象,然后将其和子组件一起透传给了,二者区别只在于引入的 createHistory() 不同。因此对于这二者的解析,其实是对  和 history 库的解析。

history 库

先来看 history 库,这里的 history 并非 H5 的 history 对象,而是一个有着 7k+ star 的会话历史管理库,是 React Router 的核心依赖。本小节我们来看 history 库的用法,以及了解为什么 React Router 要选择 history 来管理会话历史。

在看具体用法之前,我们先思考一下我们的"会话历史管理&#​34;的需求。所谓会话历史管理,我们很容易想到维护一个页面访问历史栈,跳转页面的时候 push 一个历史,后退 pop 一个历史即可。不过我们通过第一节对 hash 和 history 路由的原生实现就能明白,不同路由模式之间,操作会话历史的 API 不同、监听会话历史的方式也不同,而且前端路由并不只有这两种模式,React Router 还提供了 memory 模式 static 模式,分别用于 RN 开发和 SSR。

所以我们希望在中间加一层抽象,来屏蔽几种模式之间操作会话历史的差别,而不是将这些差别和判断带进 React Router 的代码中。

history 使您可以在任何运行 JavaScript 的地方轻松管理会话历史记录。一个 history 对象可以抽象出各种环境中的差异,并提供一个最小的API,使您可以管理历史记录堆栈,导航和在会话之间保持状态。

这是 history 文档的第一句,很好地概括了 history 的作用、优势和使用范围,直接来看 API:

API 简洁好懂,就不再赘述了。出于篇幅的考虑,本小节只介绍 history库部分用法,其实现原理放到末尾番外篇,好让读者先专注了解 React Router 的实现。

Router 的实现 我们已经知道, 和  本质上都是 ,只是二者引入的 createHistory() 方法不同。 的代码在 react-router 这个包里,是一个相对公共的组件,其他包的  都引自这里:

代码看起来不少,但如果刨除当中各种判断场景的代码,其实  只做了两件事,一是给子组件包了一层context,让路由信息( history 和 location 对象)能传递给其下所有子孙组件;二是绑定了路由监听事件,使每次路由的改变都触发setState。

其实看到这我们就能明白,为什么  等路由组件要求被包裹在  等路由器容器组件内才能使用,因为路由信息都由外层的容器组件通过 context 的方式,传递给所有子孙组件,子孙组件在拿到当前路由信息后,才能匹配并渲染出对应内容。此外在路由发生改变的时候,容器组件 会通过 setState() 的方式,触发子组件重新渲染。

本章小结

在看完了  的实现后,我们来和原生实现做一个比较,我们之前提到,前端路由主要的两个点是监听和匹配路由的变化,而 就是帮我们完成了监听这一步。在原生实现中,我们分别实现了 hash 模式和 history 模式的监听,又是绑定事件,又是劫持 a 标签的点击,而在 React Router 中,这一步由 history 库来完成,代码内调用了history.listen 就完成了对几种模式路由的监听。

此外在原生实现中,我们还忽略了路由嵌套的情况,我们其实只在根节点绑定了监听事件,没有考虑子组件的路由,而在 React Router 中,通过context的方式,将路由信息传递给其子孙组件,使其下的   等路由组件都能感知路由变化,并拿到相应路由信息。

Route 的实现

我们前面提到,前端路由的核心在于监听和匹配,上面我们使用  实现了监听,那么本小节就来分析  是如何做匹配的,同样地我们先回顾  的用法:

匹配模式:

路径 path 写法:

渲染方式:

所做的事情也很简单,匹配到传入的 path,渲染对应的组件。此外  还提供了几种不同的匹配模式、path写法以及渲染方式, 的源码实现,和这些配置项有着紧密的联系:

Route的实现相对简单,代码分为两部分:获取 match 对象和渲染组件。我们在代码中会看到多次 match 对象,这个 match 对象其实是由根组件的 computedMatch() 或 matchPath() 生成,包含了当前匹配信息。对于这个 match 对象的生成过程,我们放到下一小节,这里我们只需要知道,如果当前 Route 匹配了路由,那么会生成对应 match 对象,如果没有匹配,match 对象为 null。

第二部分是  组件的渲染逻辑,这部分代码还是得从  的行为去理解,Route 提供了三种渲染方式:子组件、props.component、props.render,三者之间又存在优先级,因此就形成了我们看到了多层三元表达式渲染的结构。

这部分渲染逻辑不用细看,参照下边的树状图理解即可,代码用了四层三元表达式的嵌套,来实现 子组件> component属性传入的组件 > children是函数 这样的优先级渲染。

红色节点是最终渲染结果:

matchPath

如果让我们去实现路由匹配,我们会怎么去做呢?全等比较?正则判断?反正看起来应该是很简单的一个实现,但如果我们打开matchPath()的代码,却会发现它用了60行代码、引了一个第三方库来做这件事情:

小结

本小节我们通过对  和 mathPath 源码的解析,讲解 React Router 实现匹配和渲染的过程,匹配路由这部分的工作由 mathPath 通过 path-to-regexp进行, 其实相当于一个高阶组件,以不同的优先级和匹配模式渲染匹配到的子组件。

尾声

到这里,我们基本完成了对 React Router 的主要组件源码解析,最后回顾一下整体的实现:

  • 对于监听功能的实现,React Router 引入了 history 库,以屏蔽了不同模式路由在监听实现上的差异, 并将路由信息以 context 的形式,传递给被 <Router> 包裹的组件, 使所有被包裹在其中的路由组件都能感知到路由的变化, 并接收到路由信息

  • 在匹配的部分, React Router 引入了 path-to-regexp 来拼接路径正则以实现不同模式的匹配,路由组件 <Route> 作为一个高阶组件包裹业务组件, 通过比较当前路由信息和传入的 path,以不同的优先级来渲染对应组件

整体而言,React Router 的源码相对简单清晰,源码中所体现的前端路由的设计实现,也相信会对读者们有所启发借鉴。虽然本文对 React Router 源码的解析就到此为止, 但有关前端路由以及 React Router 的探索不会停止,怎样从源码到落地,怎样为项目做路由选型,怎样设计一个合理的前端路由系统... 对于前端路由, 我们需要挖掘的东西还很多, 源码解析只是在这条道路路上迈出了一小步。而前端路由,也会在前端er的不断迭代下, 在当下这波前端技术的滔滔浪潮中,继续摸索和前进,在更广阔的场景上,去发挥它的价值。

由于时间紧张, 本文成文比较匆忙,潦草之处,敬请谅解,以下有些坑还没来得及填, 算是留给读者们的思考题了~

  • 集中式静态配置路由和分布式动态组件路由之争

  • 和 < Link> 组件源码解析

  • React Router hooks 源码解析

  • history 库源码解析

作者:腾讯IMWeb团队  来源:掘金  著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。