likes
comments
collection
share

React-Router V5 源码手把手解析

作者站长头像
站长
· 阅读数 29

为什么看源码

作为一个涉世未深的切图仔,不得不承认对框架的源码,内心是存在恐惧的。曾经企图阅读react的源码,结果一脚踩进去直接懵了,不得不退而求其次,找了一些博客从理论层面了解一些基本原理。但对于react-router,网上的一些博客要么就是直接上源码的截图,要么就是告诉我hashrouter是利用hashchange事件,browserrouter是利用了浏览器的history API。看过几篇博客之后,似乎懂了点,又似乎啥也没懂。为什么这次下定决心要看源码,是因为下面几个问题一直让我的内心备受煎熬,终于忍不下去了。问题比较菜,大佬勿喷。

  1. 初入职场的时候,不知道路由怎么传值,于是看到一篇博客说可以在push的时候写querystatequery是明文,state是加密的。但实际用起来却感觉是一样的,好像没什么区别哎。
  2. 一直都知道hashrouter如果刷新页面,路由跳转时通过state携带的参数无法保持。但是browserrouter不管是前进后退还是刷新都可以将参数保持下来。可是,为什么呢?
  3. 万一面试官不满足于hashchangehistory API,还要问得更深入咋办,那不是暴露了自己只是会背八股文吗=,=
  4. 之前做了一个新项目,用的V6版本,然后某一天在一个旧项目(V5)上迭代需求的时候写了个pathname:"/xxx?x=x",并且信心满满地提交代码之后,发现,咦,好像不能这么写。。。

只会用API,不明白原理导致一些关键问题上总是很模糊。有时候甚至API都用不明白,实在愧对自己日益增长的工龄。所以这次下定决心,看源码!

写这篇文章的时候其实V6的版本已经出来了,我也已经用V6版本做了一个新项目了。但由于API还不是很熟悉,所以还是决定先阅读一下V5版本的源码。毕竟API用的不多的话,源码看起来也会比较吃力。

准备源码调试环境

首先明确一件事,源码指的是哪些库?

  • react-router
  • react-router-dom
  • history

其实我们主要看这三个库就可以了,简单来说,react-routerreact路由的核心库,RouterRouteSwitch这些组件都属于这个库;react-router-dom是web相关的库,上面说的HashRouterBrowserRouter就是这个库里的组件;History是一个基于原生history定义的库,我们在代码中用到的props.history就是一个history对象。

搞清楚目标后,我们就直接用create-react-app构建一个react项目用于调试源码吧。

构建好项目之后,我们去GitHub上下载react-router v5history的源码,并放置在项目的src目录下。

React-Router V5 源码手把手解析

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

依次修改好historyreact-routerreact-router-dom的入口文件的路径。然后可以尝试npm start一下项目,这时候应该还会报一些模块缺失的错误。因为这些模块我们不需要调试其源码,所以直接npm install就可以了。比如以下这些模块

  • tiny-invariant
  • tiny-warning
  • resolve-pathname
  • value-equal
  • mini-create-react-context
  • hoist-non-react-statics 反正就是缺什么就install什么,直到项目可以start起来。还有一个问题就是,源码中有一些开发过程的测试代码,比如下图。我们可以将__DEV__定义成全局变量,以防止报错说这个变量undefined

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

BrowserRouter

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

可以看到里面就是包含我们常用的一些路由操作的方法,我们也可以在我们自己开发的组件代码中随时将history打印出来查看其内部结构。

Router

然后再来看看Router组件的实现逻辑。Router组件只有一个叫locationstate,这个组件做的唯一的一件事情就是监听historylocation的变化,并更新state,从而触发重新渲染。我们可以再看一下组件的render方法。

React-Router V5 源码手把手解析

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

当前浏览器的URL是:http://localhost:3000/son1/11?test=1#123

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

这里详细介绍一下match这个对象。首先,在每个Route组件里面都会用当前的路径和path属性生成的正则表达式进行正则匹配。如果匹配成功则会有一个match对象,如果匹配失败则match对象为null。在这个前提下(路径与path能够匹配),再来分别看看match对象的各个属性。isExact表示是否为精确匹配,params是从URL中解析出来的动态参数,path就是传给Route组件的属性,url是当前完整路径中与path生成的正则表达式匹配的部分。简而言之就是path是模式,url是模式的值。

Route

看完Router,我们接下来就继续看看他的子组件RouteRoute组件做的事简而言之就是用path属性生成正则表达式,和当前的URL进行正则匹配,根据正则匹配的结果决定组件的渲染结果。同时更新RouterContext中的locationmatch的值,并将historylocationmatch作为属性传到子组件中。这样我们在组件中就可以通过props.historyprops.locationprops.match的形式获取值了。

React-Router V5 源码手把手解析

路径匹配主要就是这部分代码,以上面提到的路由组件嵌套结构进行说明的话,代码会进入matchPath方法,可以看一下这个方法的实现。

这个方法里会先调用compilePath方法,compilePath方法使用了path-to-regexp这个库,根据Route组件中指定的参数(exactstrictsensitive)生成path对应的正则表达式,同时解析出path中的动态参数的名字。然后将正则表达式与location中的pathname进行匹配,如果匹配成功的话,就返回一个match对象(对象的各个属性的含义前面已经说明了,这里特意再说明一下params参数:生成正则表达式的时候已经找出了所有动态参数的name,正则匹配的过程中再通过分组捕获的方式找到pathname中动态参数的值,这样就可以得到params对象了)。

props.history.push

有了上面这些基础之后,接下来看一下代码中经常使用的props.history.push方法的底层实现逻辑。仍然以BrowserRouter为例,从BrowserRouter组件的定义中我们可以看到,history是通过createBrowserHistory方法得到的,所以可以猜测我们常用的pushreplace方法的定义应该会在这个方法里面。果然,在这个方法里面我们可以找到push方法的定义。直接看代码,关键逻辑在代码中用注释进行了说明。

React-Router V5 源码手把手解析

React-Router V5 源码手把手解析

为什么BrowserRouter在刷新的时候可以保持state

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

刷新页面会重新渲染整个react应用,自然也就会重新调用createBrowserHistory方法,所以可以直接看一下这个方法返回的history对象:

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

所以从上面分析可以看到,push路由的时候会将state保存到window.history当前记录的state中。如果刷新页面,则会根据window.history.statewindow.location构建初始的location对象。所以我们调用push方法传递的state参数在BrowserRouter中不会丢失。而如果传递了其他的参数,即使跳转之后的props.location中能够获取,但只要刷新或者前进后退就没了。原因就是代码针对名字为state的参数做了特殊处理。

前进/后退的时候发生了什么

接下来看一下浏览器前进/后退的时候发生了什么。浏览器的前进后退会触发popstate事件,所以可以在createBrowserHistory中全局搜一下popstate,最终定位到下面这个方法:

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

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

React-Router V5 源码手把手解析

handlePop方法里,主要看else分支里的setState方法。前面已经说过,这个方法就是更新historyactionlocationlength,同时通过回调的方式告诉订阅者location已经变了,触发重新渲染(前面介绍Router组件的时候有提到,Router的生命周期方法中订阅了location的变化)。根据上面的分析也就不难理解push路由的时候传递的名字为state的参数也能够在前进后退的时候重新复原到构建的location中。

结语

总算写完了人生中的第一篇文章,拖延症患者得到了解脱。基本理了一遍一个简单的路由结构其内部是如何实现的,至于Switch组件,大家可以自行查看源码逻辑。总体来看,react-router的源码的阅读难度不算太难,文章的最后给大家提两点小建议:

  1. 不要畏惧源码,干就完事了!
  2. 不要掉进细节陷阱。除非你是专门开发相关组件库的,否则看个大概能有所收获即可,不必执着于每一行代码。

마지막으로, 파이팅!