React-Router V5 源码手把手解析
为什么看源码
作为一个涉世未深的切图仔,不得不承认对框架的源码,内心是存在恐惧的。曾经企图阅读react的源码,结果一脚踩进去直接懵了,不得不退而求其次,找了一些博客从理论层面了解一些基本原理。但对于react-router,网上的一些博客要么就是直接上源码的截图,要么就是告诉我hashrouter是利用hashchange事件,browserrouter是利用了浏览器的history API。看过几篇博客之后,似乎懂了点,又似乎啥也没懂。为什么这次下定决心要看源码,是因为下面几个问题一直让我的内心备受煎熬,终于忍不下去了。问题比较菜,大佬勿喷。
- 初入职场的时候,不知道路由怎么传值,于是看到一篇博客说可以在
push的时候写query和state,query是明文,state是加密的。但实际用起来却感觉是一样的,好像没什么区别哎。 - 一直都知道
hashrouter如果刷新页面,路由跳转时通过state携带的参数无法保持。但是browserrouter不管是前进后退还是刷新都可以将参数保持下来。可是,为什么呢? - 万一面试官不满足于
hashchange和history API,还要问得更深入咋办,那不是暴露了自己只是会背八股文吗=,= - 之前做了一个新项目,用的V6版本,然后某一天在一个旧项目(V5)上迭代需求的时候写了个
pathname:"/xxx?x=x",并且信心满满地提交代码之后,发现,咦,好像不能这么写。。。
只会用API,不明白原理导致一些关键问题上总是很模糊。有时候甚至API都用不明白,实在愧对自己日益增长的工龄。所以这次下定决心,看源码!
写这篇文章的时候其实V6的版本已经出来了,我也已经用V6版本做了一个新项目了。但由于API还不是很熟悉,所以还是决定先阅读一下V5版本的源码。毕竟API用的不多的话,源码看起来也会比较吃力。
准备源码调试环境
首先明确一件事,源码指的是哪些库?
react-routerreact-router-domhistory
其实我们主要看这三个库就可以了,简单来说,react-router是react路由的核心库,Router、Route、Switch这些组件都属于这个库;react-router-dom是web相关的库,上面说的HashRouter、BrowserRouter就是这个库里的组件;History是一个基于原生history定义的库,我们在代码中用到的props.history就是一个history对象。
搞清楚目标后,我们就直接用create-react-app构建一个react项目用于调试源码吧。
构建好项目之后,我们去GitHub上下载react-router v5和history的源码,并放置在项目的src目录下。


然后我们随便在项目中写两个页面,并在App.js中定义好路由。

上图中我们导入了react-router-dom。平时做项目的时候我们会通过npm install的方式来安装这个包,但为了方便调试,我们是把源码直接下载到了我们项目的src目录下面。那我们接下来要做的就是让这个import可以找到我们下载的源码文件。即修改webpack中的alias,告诉webpack去哪里找上面说的三个库。

上面的改动仅仅只是告诉webpack包在哪里,webpack会根据包里的package.json中指定的入口文件加载具体的js代码。package.json指定的入口文件是打包后的文件,我们将其求改成打包前的文件。

依次修改好history、react-router、react-router-dom的入口文件的路径。然后可以尝试npm start一下项目,这时候应该还会报一些模块缺失的错误。因为这些模块我们不需要调试其源码,所以直接npm install就可以了。比如以下这些模块
tiny-invarianttiny-warningresolve-pathnamevalue-equalmini-create-react-contexthoist-non-react-statics反正就是缺什么就install什么,直到项目可以start起来。还有一个问题就是,源码中有一些开发过程的测试代码,比如下图。我们可以将__DEV__定义成全局变量,以防止报错说这个变量undefined。

经过上面的操作,一个源码调试工程就可以跑起来了。接下来就让我们一起走进react路由的源码,看看这货到底是怎么实现的。在此之前先写几行简单的代码(为简单起见,没用Switch组件),下面以BrowserRouter为例进行说明。

BrowserRouter
BrowserRouter组件做的事情就是引入history库创建一个history对象(createBrowserHistory),然后将history和children作为属性渲染一个Router组件。if(__DEV__)内部的代码在学习源码时可以不看,主要是一些开发阶段的校验和报错提醒。

所以在浏览器的控制台里看,组件的层级结构是这样的:

history对象作为在react中常用的一个对象,我们可以打印出来看看里面大概的结构:

可以看到里面就是包含我们常用的一些路由操作的方法,我们也可以在我们自己开发的组件代码中随时将history打印出来查看其内部结构。
Router
然后再来看看Router组件的实现逻辑。Router组件只有一个叫location的state,这个组件做的唯一的一件事情就是监听history中location的变化,并更新state,从而触发重新渲染。我们可以再看一下组件的render方法。

render中做的事情就是在渲染Route组件之前,外层包两个context:RouterContext和HistoryContext。其中RouterContext中包含了四个属性:history,location,match和staticContext。除第四个属性以外,前面三个属性都是我们在开发过程中会频繁使用到的。比如使用history进行路由跳转,从location中获取路由跳转时传递的参数,从match中获取路由中的动态参数。(这里不是很清楚为什么history需要再通过一个单独的context向下传递)我们也可以将location和match打印出来看一下内部结构。
当前浏览器的URL是:http://localhost:3000/son1/11?test=1#123

可以看到location记录的就是当前URL解析出来的一些字段,我们在路由跳转时使用push或replace方法传递的参数也就是一个location对象。Router组件中的match对象还只是一个初始化的对象,因为此处还未进行路由匹配。该对象会在Route组件中路由匹配之后重新赋值。

这里详细介绍一下match这个对象。首先,在每个Route组件里面都会用当前的路径和path属性生成的正则表达式进行正则匹配。如果匹配成功则会有一个match对象,如果匹配失败则match对象为null。在这个前提下(路径与path能够匹配),再来分别看看match对象的各个属性。isExact表示是否为精确匹配,params是从URL中解析出来的动态参数,path就是传给Route组件的属性,url是当前完整路径中与path生成的正则表达式匹配的部分。简而言之就是path是模式,url是模式的值。
Route
看完Router,我们接下来就继续看看他的子组件Route。Route组件做的事简而言之就是用path属性生成正则表达式,和当前的URL进行正则匹配,根据正则匹配的结果决定组件的渲染结果。同时更新RouterContext中的location和match的值,并将history、location、match作为属性传到子组件中。这样我们在组件中就可以通过props.history、props.location、props.match的形式获取值了。

路径匹配主要就是这部分代码,以上面提到的路由组件嵌套结构进行说明的话,代码会进入matchPath方法,可以看一下这个方法的实现。
这个方法里会先调用compilePath方法,compilePath方法使用了path-to-regexp这个库,根据Route组件中指定的参数(exact,strict,sensitive)生成path对应的正则表达式,同时解析出path中的动态参数的名字。然后将正则表达式与location中的pathname进行匹配,如果匹配成功的话,就返回一个match对象(对象的各个属性的含义前面已经说明了,这里特意再说明一下params参数:生成正则表达式的时候已经找出了所有动态参数的name,正则匹配的过程中再通过分组捕获的方式找到pathname中动态参数的值,这样就可以得到params对象了)。
props.history.push
有了上面这些基础之后,接下来看一下代码中经常使用的props.history.push方法的底层实现逻辑。仍然以BrowserRouter为例,从BrowserRouter组件的定义中我们可以看到,history是通过createBrowserHistory方法得到的,所以可以猜测我们常用的push、replace方法的定义应该会在这个方法里面。果然,在这个方法里面我们可以找到push方法的定义。直接看代码,关键逻辑在代码中用注释进行了说明。


为什么BrowserRouter在刷新的时候可以保持state
本次源码学习的直接动机是想搞清楚为什么HashRouter在刷新和前进后退的时候会导致state丢失,而BrowserRouter不会。从上面的代码逻辑中我们可以看到调用push方法的时候,会把一个随机生成的key和state参数构成一个对象,在调用pushState方法的时候作为第一个参数。因此路由跳转时state参数保存在了window.history的当前记录中。那么接下来我们看一下刷新页面的时候会发生什么,为什么我们在刷新之后还可以在props.location中找到state。
刷新页面会重新渲染整个react应用,自然也就会重新调用createBrowserHistory方法,所以可以直接看一下这个方法返回的history对象:

从上面代码可以看到页面初次加载的时候,history.location是一个叫initailLocation的对象。

getHistoryState就是从window.history中把state提取出来。

getDOMLocation就是根据window.history.state和window.location构建location对象。

所以从上面分析可以看到,push路由的时候会将state保存到window.history当前记录的state中。如果刷新页面,则会根据window.history.state和window.location构建初始的location对象。所以我们调用push方法传递的state参数在BrowserRouter中不会丢失。而如果传递了其他的参数,即使跳转之后的props.location中能够获取,但只要刷新或者前进后退就没了。原因就是代码针对名字为state的参数做了特殊处理。
前进/后退的时候发生了什么
接下来看一下浏览器前进/后退的时候发生了什么。浏览器的前进后退会触发popstate事件,所以可以在createBrowserHistory中全局搜一下popstate,最终定位到下面这个方法:

查看这个方法的调用栈可以发现,Router组件的生命周期中调用的lisener方法会调用这个方法。这个方法做的事情就是监听popstate事件。所以接下来看一下popstate事件的回调函数handlePopState方法。

这个方法首先根据popstate的事件对象中的state和window.location构建location对象,这部分逻辑在上面分析页面刷新的时候也用到过。然后将location对象作为参数,调用handlePop方法。

handlePop方法里,主要看else分支里的setState方法。前面已经说过,这个方法就是更新history的action、location、length,同时通过回调的方式告诉订阅者location已经变了,触发重新渲染(前面介绍Router组件的时候有提到,Router的生命周期方法中订阅了location的变化)。根据上面的分析也就不难理解push路由的时候传递的名字为state的参数也能够在前进后退的时候重新复原到构建的location中。
结语
总算写完了人生中的第一篇文章,拖延症患者得到了解脱。基本理了一遍一个简单的路由结构其内部是如何实现的,至于Switch组件,大家可以自行查看源码逻辑。总体来看,react-router的源码的阅读难度不算太难,文章的最后给大家提两点小建议:
- 不要畏惧源码,干就完事了!
- 不要掉进细节陷阱。除非你是专门开发相关组件库的,否则看个大概能有所收获即可,不必执着于每一行代码。
마지막으로, 파이팅!
转载自:https://juejin.cn/post/7143239162894745608