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-router
react-router-dom
history
其实我们主要看这三个库就可以了,简单来说,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-invariant
tiny-warning
resolve-pathname
value-equal
mini-create-react-context
hoist-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