vue-router 源码解析 | 1.5w 字 | 多图预警 - 【中】
- 各位好,我是光辉 😎
- 本篇是
vue-router
源码解析的中篇,也是核心篇 - 本篇主要介绍了下面几点
- 介绍了
vue-router
是如何做路由匹配的 - 以及其相关的守卫、钩子是如何触发的
- 异步组件又是如何处理的等等
- 介绍了
- 虽然看着知识点很少,但内容却一点都不少
- 另外还是要说一下
- 第一次做源码解析,肯定有很多错误或理解不到位的地方,欢迎指正 🤞
- 项目地址
https://github.com/BryanAdamss/vue-router-for-analysis
- 如果觉得对你有帮助,记得给我一个
star
✨
- uml 图源文件
https://github.com/BryanAdamss/vue-router-for-analysis/blob/dev/vue-router.EAP
- 关联文章链接
路由跳转
- 前面介绍过,要实现路由的跳转,首先得从路由映射表中找到与地址匹配的路由对象,这个过程称之为路由匹配,找到匹配的路由后,然后再解析跳转
- 所以实现路由跳转有两个关键步骤:路由匹配、导航解析
VueRouter
将上述两个关键步骤封装到transitionTo
方法中了- 接下来,我们先看看
transitionTo
的实现
transitionTo
- 前面介绍过,
transitionTo
方法定义在基类History
上的
1 | // src/history/base.js |
- 在
setupListeners
章节也介绍过transitionTo
的方法签名 - 接收三个参数
location
为RawLocation
类型,代表需要解析的地址onComplete
是跳转成功回调,在路由跳转成功时调用onAbort
是跳转失败(取消)回调,在路由被取消时调用
- 看下内部逻辑
- 调用
router实例
的match
方法,从路由映射表中取到将要跳转到的路由对象route
;这其实就是路由匹配
过程; - 拿到将要跳转的
route
后,调用confirmTransition
完成route
的解析跳转,并在跳转成功、取消时调用对应回调方法;这是导航解析
过程- 成功时,调用
updateRoute
触发重新渲染,然后触发相关回调;关于渲染,我们后面章节会讲 - 取消(失败)时,触发相关回调
- 成功时,调用
- 那我们下面先看下路由匹配过程
路由匹配
transitionTo
中会调用router实例
的match
方法实现路由匹配
1 | // src/index.js |
router实例的match
方法,又调用的匹配器的match
方法,将参数直接透传过去- 关于匹配的创建可以看之前的
创建匹配器
章节
- 关于匹配的创建可以看之前的
- 我们继续看匹配器的
match
方法
1 | // src/create-matcher.js |
- 由于
router.match
是将参数透传过来的,所以二者的签名一模一样raw
是RawLocation
类型,是需要进行路由匹配的地址currentRoute
是当前路由对象redirectedFrom
代表从哪个地址重定向过来的
- 我们看下
match
方法逻辑- 首先它对传入的
raw
地址,进行了格式化(规范化) - 然后取出格式化地址中的
name
name
存在,判断是否能通过name
在nameMap
中找到对应的路由记录RouteRecord
- 无法找到,则创建一个新
route
对象返回 - 可以找到,则填充
params
,并使用此路由记录创建一个新的Route
对象返回
- 无法找到,则创建一个新
name
不存在,则判断path
是否存在- 存在,则利用
pathList、pathMap
调用matchRoute
判断是否匹配,进而找到匹配的路由记录,然后使用此路由记录创建新route
对象返回
- 存在,则利用
name
、path
都不存在- 则直接创建一个新
route
对象返回
- 则直接创建一个新
- 首先它对传入的
- 活动图如下
- match.png
- 我们提取一下上述流程的关键词
地址格式化normalizeLocation
、地址是否匹配判断matchRoute
、填充参数fillParams
、创建路由对象_createRoute
地址格式化 normalizeLocation
- 我们看下为何需要对地址做格式化
- 我们知道
VueRoute
定义的地址是RawLocation
类型的,而它是联合类型的,支持string
和Location
类型
1 | // flow/declarations.js |
- 所以下面的地址都是合法的
$router.push
方法的参数也是的RawLocation
类型,所以使用$router.push
来举例
1 | // 字符串形式 |
- 可以看到
VueRouter
需要兼容上面所有情况,为了方便处理,需要对地址做格式化 - 看下实现逻辑
1 | // src/util/location.js |
- 首先将
string
类型的转换为对象形式,方便后面统一处理 - 如果发现地址已经做过格式化处理,则直接返回
- 再判断是否是命名路由
- 若是,则拷贝原始地址
raw
,拷贝params
,直接返回
- 若是,则拷贝原始地址
- 处理了仅携带参数的相对路由(相对参数)跳转,就是
this.$router.push({params:{id:1}})
形式- 对这种地址的定义是
没有path
、仅有params
并且当前路由对象存在
- 主要处理逻辑是
- 先合并
params
- 若是命名路由,则使用
current.name
做为next.name
,并赋值params
- 非命名路由,从当前路由对象中找到匹配的路由记录,并取出路由记录上的
path
做为next.path
,然后填充params
- 返回处理好的地址
- 先合并
- 由于这中跳转方式,仅有
params
,所以必须从当前路由对象current
上获取可用字段(path
、name
),做为自身值,然后跳转
- 对这种地址的定义是
- 处理通过
path
跳转的方式- 调用
parsePath
从path
中解析出path、query、hash
- 然后以
current.path
为basePath
,解析(resolve
)出最终path
- 对
query
进行合并操作 - 对
hash
进行前追加#
操作 - 返回带有
_normalized:true
标识的Location
对象
- 调用
- 经过上面一番处理,无论传入何种地址,都返回一个带有
_normalized:true
标识的Location类型
的对象 - normalize-location.png
地址是否匹配判断 matchRoute
- 我们知道
VueRouter
是支持动态路由匹配的,如下图所示 - dynamic-route.png
- 我们在上篇的
生成路由记录
章节也介绍过,VueRouter
在生成路由记录时,会通过path-to-regexp
包生成一个正则扩展对象并赋值到路由记录的regex
字段上,用于后续的动态路由参数的获取- 主要的逻辑是提供一个动态路由
user/:id
和一个地址/user/345
,通过path-to-regexp
就能生成一个对象{id:345}
来表达参数的映射关系 - 是一个借助动态路由,从 url 上提取参数的过程;
/user/345
->{id:345}
- 具体例子可查看
生成路由记录
章节
- 主要的逻辑是提供一个动态路由
- 上述提取参数的逻辑是在
matchRoute
实现的 matchRoute
位于src/create-matcher.js
1 | // src/create-matcher.js |
- 通过方法签名,可以知道它返回一个
boolean
值,这个值代表传入的path
是否能通过regex的匹配
;虽然返回一个boolean
值,但是其内部还做了件很重要的事,从path
上提取动态路由参数值,我们看下完整逻辑 - 首先调用
path.match(regex)
- 不能匹配直接返回
false
- 可以匹配且无
params
,返回true
- 剩下的就只有一种情况,可以匹配且
params
存在,此时需要对params
进行正确赋值- 整个赋值,主要是遍历
path.match(regex)
返回值并取出regex
中存储的key
,然后依次赋值,关于细节可以参考上面的注释; - 关于
regex
、path-to-regexp
,可以参考生成路由记录
章节和https://www.npmjs.com/package/path-to-regexp
- 整个赋值,主要是遍历
- 还有一个点,赋值时的
pathMatch
是什么?- 这其实是跟通配符即
*
有关的 VueRouter
关于通配符的特殊处理可以看https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#捕获所有路由或-404-not-found-路由- 即
pathMatch
会代表通配符匹配到的路径
- 即
- 这其实是跟通配符即
- 官方例子如下
1 | { |
- match-route.png
填充参数 fillParams
fillParams
可以看做是matchRoute
的逆操作,是一个借助动态路径,使用参数生成 url 的过程;即/user/:id
+{id:345}
->/user/345
- 可以看下它的实现
1 | // src/util/params.js |
- 可以看到整个逆解析逻辑是借助
Regexp.compile
结合regexpCompileCache
实现的 Regexp.compile
接收一个动态路由path
,返回一个函数,可用这个函数做逆解析;Regexp.compile
例子如下
1 | // https://www.npmjs.com/package/path-to-regexp#compile-reverse-path-to-regexp |
- 可以看到首先对
Regexp.compile
返回的函数做了缓存 - 然后将
matchRoute
中添加的pathMatch
赋值给params[0]
- 调用
Regexp.compile
返回函数,以params
为入参,逆解析url
并返回 - 删除添加的
params[0]
- fill-params.png
创建路由对象_createRoute
- 上面无论是
normalizeLocation
、matchRoute
、fillParams
都是针对传入的地址
做一些操作; - 而
match
方法的作用是找到与地址匹配的路由对象,而这个主要是由_createRoute
方法实现 - 从命名上可以看出,这是个内部方法
1 | //src/create-matcher.js createMatcher方法内 |
- 可以看到它接收三个参数
record
用来生成Route对象
的目标路由记录location
目标地址redirectedFrom
重定向的来源地址,这个参数只在发生重定向时才会有值
- 我们知道,在新增路由记录时,会对不同类型的记录添加上不同的标记字段
- 如为重定向路有记录添加
redirect
字段 - 为别名路由添加
matchAs
字段- 具体可看前面的
生成路由记录
章节
- 具体可看前面的
- 如为重定向路有记录添加
- 可以看到针对不同的路由记录类型调用了不同方法
- 重定向路由调用
redirect
方法 - 别名路由调用
alias
方法 - 其余的调用
createRoute
方法
- 重定向路由调用
- 活动图如下
- create-route-inner.png
- 其实
redirect
、alias
方法内部也调用了createRoute
方法 - 所以我们先看
createRoute
方法实现
createRoute
createRoute
位于src/util/route.js
1 | // src/util/route.js |
- 由于
VueRouter
支持传入自定义序列化queryString
方法,所以第一步先获取序列化queryString
的方法 - 然后对
query
做了一个深拷贝,避免相互影响 - 接下来就是生成新
Route
对象 - 如果是从其他路由重定向过来的,则生成完整的重定向来源地址,并赋值给新生成的
Route
对象 - 最后调用
Object.freeze
冻结新Route
对象,因为Route
对象是immutable
的 - 整个流程如下
- create-route.png
- 可以看到生成
Route
时,会调用getFullPath
生成完整fullPath
1 | // src/util/route.js |
- 可以看到
getFullPath
是在path
后面追加了qs
和hash
- 另外生成
Route
时,还会调用formatMatch
来获取所有关联的路由记录
- 主要通过向上查找的形式找到所有关联的路由记录
1 | // src/util/route.js |
- 难道一条
Route
不是对应(关联)一个路由对象
吗? - 其实在术语表介绍
Route路由对象
时,也有所提及,一个Route路由对象
可能会关联多个RouteRecord路由记录对象
- 这是因为存在嵌套路由的情况,当子路由记录被匹配到时,其实代表着父路由记录也一定被匹配到了,看下面例子
1 | // 当有下面的路由规则 |
- 访问
/parent/foo
时,匹配到的路由记录有两个 - match-demo-record.png
- 而且精准匹配到路由记录一定是最后一个,所以后面会看到用
route.matched[route.matched.length - 1]
来获取当前route
对应的精准匹配的RouteRecord
- 看完
createRoute
的实现,我们再来看看alias
的实现
创建别名路由对象 alias
alias
位于src/create-matcher.js
1 | // src/create-matcher.js |
- 逻辑如下
- 先拿
matchAs
得到aliasedPath
, - 然后拿
aliasedPath
走一遍match
得到aliasedMatch
路由对象 aliasedMatch
如果存在,拿aliasedMatch
精准匹配的路由记录对象
和location
,生成路由对象
返回- 不存在,则创建一个新的
路由对象
返回 - 可能有点绕,我们举个例子
- 前面我们知道,
/a
设置了别名/b
时,会生成两条路由记录,且/b
的路由记录上的matchAs
为/a
- 忘记的可以看前面的
生成别名路由记录
章节
- 忘记的可以看前面的
- 此处传入的
alias
的matchAs
就相当于/a
,先拿matchAs
即/a
得到填充过params
的路径 - 再以此路径调用
match
找到匹配的路由对象
,记为routeA
- 前面也提过,路由对象会关联路由记录,所以从
routeA
中可以得到精准匹配的路由记录routeRecordA
- 拿此路由记录和
/b
的location
去生成路由对象并返回 - 这样就实现了官网上说的
/a 的别名是 /b,意味着,当用户访问 /b 时,URL 会保持为 /b,但是路由匹配则为 /a,就像用户访问 /a 一样。
效果
- 前面我们知道,
- 活动图如下
- alias.png
- 我们再来看看
redirect
的实现
创建重定向路由对象 redirect
- 我们先看下
record.redirect
可能的几种情况;https://router.vuejs.org/zh/guide/essentials/redirect-and-alias.html#重定向
- 字符串
{redirect:'/'}
- 对象
{redirect:{path:'/test'}}
、{redirect:{name:'Test'}}
- 也支持传入函数
{redirect:to=>{ return {name:'Test'}}}
- 字符串
- 我们先看这个
redirect
方法的入口
1 | // src/create-matcher.js _createRoute方法内部 |
- 由于存在多次重定向的场景,所以需要保留首次触发重定向的地址即
redirectedFrom
/a
->/b
->/c
,在/c
中需要保留首次触发重定向的地址即/a
- 多次重定向,如何保留首次触发重定向的地址呢?
- 在第一次重定向时,
redirectedFrom
没有值 - 在
redirect
方法内部会将location
做为redirectedFrom
参数调用match
方法,match
如果发现仍然需要重定向,则会继续调用redirect
,此时redirectedFrom
是有值的,就是首次传入的location
,依次循环,这样就完成了初始地址的传递
- 在第一次重定向时,
- 可以看下下面的例子
1 | ;[ |
- 当我们访问
/named-redirect
路由时(触发路由跳转),会重定向到/baz
,/baz
又会重定向到/foo
,最终展示Foo
组件;所以,redirect
方法应该会被调用两次; - 我们可以看下上面例子的输出
- redirect-demo.png
- 会发现
redirect
方法被调用了四次,前两次是路由跳转导致的redirect
调用,后两次则是组件渲染时,需要解析路由从而触发的redirect
调用; - 可以对比下调用栈
- redirect-stack-1.png
- redirect-stack-2.png
- 可以看到第一、第二次的
redirect
是由transitionTo
触发的 - redirect-stack-3.png
- redirect-stack-4.png
- 而第三、第四次都是组件渲染
render
调用resolve
触发的- 为何组件渲染时需要解析路由,这个我们在后面组件相关章节解释
- redirect-stack-1.png
- 可以看到第一次调用
redirect
是从/named-redirect
重定向到/baz
,此时redirectFrom
是没有值的 - 而第二次调用是从
/baz
重定向到/foo
,此时redirectFrom
就是触发第一次重定向的地址/named-redirect
- 而且最终的
$route
上也会有个redirectFrom
保留了触发第一次重定向的地址 - 上面我们只是看了
redirectFrom
的意义,下面我们看看redirect
的具体实现
1 | // src/create-matcher.js createMatcher方法内 |
- 可以看到首先对
record.redirect
进行规范化,统一生成一个redirect
对象(重定向目标)- 为什么要进行规范化,前面也提过,
redirect
支持字符串、对象、函数类型,所以需要规范化,方便后面统一处理
- 为什么要进行规范化,前面也提过,
- 接下来会优先取
redirect
的query hash params
值来做match
,不存在时才会取初始地址location
的query hash params
- 接下来会判断重定向目标是
命名形式
还是path形式
- 命名形式
- 先判断
nameMap
中有没有目标路由记录,没有则中断,并给予提示; - 再重走
match
流程,并将location
做为redirectedFrom
传入,这样就完成了redirectedFrom
的传递闭环 match
里面会继续判断是否有重定向,这样就覆盖了多重重定向的场景
- 先判断
- path 形式
- 拿
path
匹配,需要获取完整路径,所以先从record
拿出原始路径rawPath
并填充前面解析出的params
得出完整地址 - 再拿完整地址重走
match
流程,同时也将location
做为redirectedFrom
传入,完成redirectedFrom
的传递闭环 match
里面会继续判断是否有重定向,这样就覆盖了多重重定向的场景
- 拿
- 如果既不是
命名形式
也不是path形式
,则直接创建一个新路由对象返回 - 流程如下
- redirect-full.png
小结
- 路由匹配的过程,其实就是拿
地址RawLocation
生成路由对象Route
的过程,这中间路由记录RouteRecord
起中间桥梁的作用,因为路由记录上保存了生成路由对象的重要信息;所以流程应该是拿地址
从路由映射表中找到对应的路由记录
,然后拿路由记录
生成路由对象
- 上述匹配逻辑主要由
match
函数实现的,关键逻辑包含地址格式化normalizeLocation
、地址是否匹配判断matchRoute
、填充参数fillParams
、创建路由对象_createRoute
- 在
normalizeLocation
时,会对rawLocation
进行规范化,方便后续处理 - 在
matchRoute
时,会借助path-to-regexp
检测地址是否匹配并提取出params
fillParams
可以看做是提取params
的逆向操作,主要用来对地址中的动态部分进行填充- 在
_createRoute
时,会分别处理别名、重定向、多重重定向等场景 - 经过上述流程,就可以拿到
RawLocation
对应的Route
- 拿到
Route
,我们就可以进行导航的解析
导航解析(确认)流程
- 前面提过在
transitionTo
方法中,调用完match
方法得到目标Route
后,就会调用confirmTransition
方法来做导航解析 - 我们知道
vue-router
在路由跳转时,会按顺序触发各种钩子、守卫函数,例如beforeRouteLeave、beforeRouteEnter等等
; - 首先这些钩子、守卫有的是定义在
vue-router
实例上的,有的是路由独享的,有的是位于.vue
组件中的,所以第一步必须抽出这些钩子、守卫函数统一处理 - 其次这些钩子、守卫是按顺序依次执行的,所以需要设计一个队列和迭代器来保证顺序执行
- 最后还有一些特殊场景需要处理,例如异步路由组件如何保证顺序执行
- 上述的相关逻辑封装在
confirmTransition
中 confirmTransition
方法被定义在src/base.js
中
1 | // src/base.js History类中 |
- 我们可以先看下方法签名
route
目标路由对象,需要解析的目标,可以理解为路由跳转时的to
对象,而current
则可以理解为from
对象。onComplete
跳转完成的回调onAbort
取消、错误的回调
- 看下主要逻辑
- 首先处理了重复跳转的问题
- 然后通过对比找出需要更新、失活、激活的路由记录
- 从上述三种路由记录中抽取出对应钩子、守卫函数
- 将钩子及守卫函数放入队列中并执行
- 接下来,我们依次看下相关逻辑
判断重复跳转
- 在判断重复跳转前定义了
abort
方法,它主要对onAbort
方法做了一层包装;这个方法在导航发生取消时会被调用到- 它接收一个
err
参数,如果有注册错误回调并且err
为非NavigationDuplicated
错误则遍历errorCbs
列表执行其中的错误回调 - 最后调用
onAbort
回调并传入err
参数交给外部处理
- 它接收一个
- 接下来判断了是否重复跳转,主要利用
isSameRoute
检测了当前路由对象和目标路由对象是否相同,若相同且二者匹配到路由记录数量相同,则视为重复跳转,此时调用abort
方法并传入NavigationDuplicated
错误并终止流程 isSameRoute
主要判断了path、name、hash、query、params
等关键信息是否相同,若相同则视为相同路由对象
1 | // src/util/route.js |
- 注意,在确定是重复跳转后,仍然会调用子类的
ensureURL
方法来更新url
对比找出需要更新、失活、激活的路由记录
- 判断完重复跳转后,就需要对比
from
、to
路由对象,找出哪些路由记录需要更新,哪些失活、哪些需要激活,用来后续抽取钩子、守卫函数
1 | // src/history/base.js confirmTransition方法中 |
- 可以看到逻辑封装在了
resolveQueue
方法中,传入了当前和目标路由对象的记录列表,从返回值中解构出了updated, deactivated, activated
- 看下
resolveQueue
实现
1 | // 对比curren、next的路由记录列表,找出需要更新、失活、激活的路由记录 |
- 逻辑很简单
- 首先找出
current
和next
列表长度的最大值, - 然后以此为循环最大次数循环找出首个不相等的路由记录索引
- 以此索引为分界线,
next列表
当前索引左侧为需要更新的路由记录、索引及索引右侧的为需要激活的路由记录 current列表
索引及右侧是需要失活的路由记录
- 首先找出
- 举例
current
为:[1,2,3]
、next
为[1,2,3,4,5]
,当前路由对象包含1、2、3
三个路由记录,目标路由对象包含1、2、3、4、5
五个路由记录- 计算后
max
为5
- 循环,发现首个不相等的索引为
3
- 所以需要更新的为
next.slice(0,3)
即1、2、3
- 需要激活的为
next.slice(3)
即4、5
- 需要失活的为
current.slice(3)
,没有需要失活的 - 找出了需要更新、激活、失活的路由记录,我们就可以从中抽取出对应的钩子、守卫函数
抽取钩子、守卫函数、解析异步组件
- 首先我们梳理下
vue-router
有哪些钩子、守卫函数router.beforeEach
全局前置守卫router.beforeResolve
全局解析守卫(v2.5.0 新增)router.afterEach
全局后置钩子RouteConfig.beforeEnter
路由独享的守卫vm.beforeRouteEnter
vue 组件内路由进入守卫vm.beforeRouteUpdate
vue 组件内路由更新守卫(v2.2 新增)vm.beforeRouteLeave
vue 组件内路由离开守卫
- 可以看到有些是定义
VueRouter
实例上的,有些是定义在配置规则RouteConfig
上的,有些是定义在RouteComponent路由组件
上的- 前两者的钩子、守卫是很容易获取到的,因为我们在
History类
中持有了VueRouter
实例,很容易访问到这些守卫、钩子并且几乎不需要做额外处理就可以直接执行; - 唯一不好处理的是定义在
RouteComponent路由组件
中的守卫函数,需要借助RouteRecord
拿到所有RouteComponent路由组件
并从中抽取出对应守卫,最后还要为其绑定上下文,保证执行结果正确;
- 前两者的钩子、守卫是很容易获取到的,因为我们在
- 上节,我们已经拿到需要更新、激活、失活的
RouteRecord路由记录
,我们看下分别要从中抽取出哪些守卫 deactivated
中抽取beforeRouteLeave
updated
中抽取beforeRouteUpdate
activated
中抽取beforeRouteEnter
,这里存在一个特殊场景,就是异步路由组件,需要等待异步路由组件解析完成后,才能抽取beforeRouteEnter
守卫,这个后面会讲- 我们先看下抽取的入口代码
1 | // src/history/base.js confirmTransition方法内 |
- 可以看到定义了一个队列
queue
- 依次做了下面的事
- 抽取了
deactivated
中的beforeRouteLeave
守卫 - 获取了
VueRouter
实例上定义的beforeEach
守卫beforeEach
守卫是直接定义在VueRouter
实例上的
- 从
updated
中抽取了beforeRouteUpdate
守卫 - 从
activated
中获取了路由独享的beforeEnter
守卫beforeEnter
守卫最初是定义在RouteConfig
上的,后面又传递给路由记录,所以在路由记录上能直接获取到
- 解析
activated
中的异步路由组件- 路由组件支持
import()
动态导入,所以这里要处理
- 路由组件支持
- 抽取了
- 我们先看方法名很类似的
extractLeaveGuards
和extractUpdateHooks
extractLeaveGuards、extractUpdateHooks
- 二者都位于
src/base.js
1 | // src/base.js |
- 可以看到二者内部都调用了
extractGuards
,前者多传了一个参数true
- 我们再看下
extractGuards
1 | // src/base.js |
- 看下方法签名
- 接收一个路由记录数组
records
- 即
extractLeaveGuards
中传入的deactivated
路由记录数组;extractUpdateHooks
中传入的updated
路由记录数组
- 即
- 接收一个需要提取的守卫名
name
beforeRouteLeave
和beforeRouteUpdate
字符串
- 一个绑定守卫上下的函数
bind
extractLeaveGuards
、extractUpdateHooks
传递的都是bindGuard
方法,这个方法我们在下面解析
- 以及一个是否需要逆序输出的
reverse
布尔值;可选参数;extractLeaveGuards
传递的是true
,代表返回的数组(守卫函数数组)需要逆序输出;
- 返回一个
item
是Function
的数组
- 接收一个路由记录数组
- 看下内部逻辑
- 调用
flatMapComponents
传入records
和一个接收def, instance, match, key
参数的箭头函数,返回一个guards
守卫数组 - 然后根据
reverse
来决定是否对guards
数组做逆序处理- 为何需要逆序?
- 在
createRoute
章节也提过,在保存路由记录时是逆序的,精准匹配的路由记录在数组最后(length - 1
位置),父记录在前 - 部分守卫函数需要逆序逆序执行,例如
beforeRouteLeave
,它需要先在精准匹配的路由组件上调用,再在父组件上调用
- 最后调用
flatten
将guards
扁平化
- 调用
- 先看下
flatMapComponents
实现
1 | // src/util/resolve-components.js |
- 可以看到其接收一个路由记录数组
matched
和一个函数fn
,返回一个经过flatten
处理的数组matched
就是我们传入的records
fn
就是接收def, instance, match, key
参数的箭头函数
- 这个方法主要是遍历路由记录中的每个路由组件并用其做入参依次调用外部函数
fn
,返回结果由fn
函数决定,最后将结果数组扁平化输出- 在解析异步组件时也会用到此方法
- 其会对传入的
records
调用map
方法,并遍历每个record
上定义的components
字段,并对components
再次进行map
遍历,然后调用传入的fn
,map
结果就是fn
返回的结果 components
字段是定义命名视图用的,长下面这样,key 为视图名,value 为对应路由组件
1 | components: { |
- 所以传入的 fn,即接收
def, instance, match, key
参数的箭头函数的四个参数分别为def
对应m.components[key]
即路由组件定义(Foo、Bar、Baz
)instance
对应m.instances[key]
是router-view
组件实例,关于路由记录和route-view
是如何关联的,会在介绍view组件
时解析m
对应的就是当前遍历到的路由记录key
是当前遍历到的视图名
- 大体逻辑如下
- flat-map-components.png
- 我们看下箭头函数内部的逻辑
1 | const guards = flatMapComponents(records, ( |
- 首先调用
extractGuard
从路由组件定义中直接抽取出对应name
的守卫函数 - 接下来调用传入
extractGuards
的bind
方法为守卫绑定上下文 - 我们看下
extractGuard
实现
1 | // src/base.js |
- 主要有两个逻辑
- 调用
extend
以应用全局 mixins - 返回对应守卫函数
- 调用
- 提取完单个守卫后,就需要调用传入的
bind
方法对其绑定上下文; bind
方法其实是bindGuard
1 | // src/history/base.js |
- 经过上面的上下文绑定,从路由组件中抽取出的守卫函数就又回来到路由组件上下文中执行了,这样就保证了守卫函数无论在何处被调用,都能返回正确的结果
- 小结
extractGuards
主要完成了从路由组件中抽取守卫函数并为其绑定上下文的工作- extract-guards.png
- 接下来我们要对激活的路由记录进行异步组件的解析
- 主要通过
resolveAsyncComponents
方法实现的
resolveAsyncComponents
- 在看如何解析异步组件前,我们先看下
vue
中的异步组件长什么样?
1 | // https://cn.vuejs.org/v2/guide/components-dynamic-async.html#异步组件 |
- 文档对异步组件的描述是
Vue 允许你以一个工厂函数的方式定义你的组件,这个工厂函数会异步解析你的组件定义
- 可以理解为:异步组件是一个工厂函数,函数内
resolve、reject
组件的定义、返回一个Promise
、返回一个带有特定标识字段的对象 - 解析路由记录中的异步组件代码位于
src/util/resolve-components.js
1 | // src/util/resolve-components.js |
- 可以看到其接收一个路由记录数组
matched
,返回一个接收from、to、next
的函数,内部是异步组件的解析逻辑 resolveAsyncComponents
被调用时并不会执行解析异步组件的逻辑,因为其只会返回一个函数,返回的函数会在运行队列时才会被调用,这时才会解析异步组件- 队列的运行,我们后面再看,我们先看下返回的函数内部是什么逻辑
- 定义了一个是否有异步组件的标识字段
hasAsync
、以及当前待解析的异步组件数量pending
- 然后调用了
flatMapComponents
拿到records
中的所有路由组件,并依次调用传入的回调方法 - 回调方法会接收到被遍历的路由组件,此时需要判断这个路由组件是否是异步组件,如果是,则开始异步组件的解析,否则跳过
- 如果遍历结束发现
hasAsync
仍然为false
,代表没有异步组件直接next()
进行下一步即可
- 定义了一个是否有异步组件的标识字段
- 如何确定某个组件是否是异步组件呢?
- 前面我们说过,在
vue
中异步组件一定是个工厂函数,内部会调用resove、reject
或返回Promise
或返回特定格式对象,总之他肯定是个函数 - 其次
vue
中每个实例都会有个唯一标识cid
,如果有cid
就代表已经生成相应实例,所以异步组件的cid
一定为undefined
- 所以判断是否是异步组件的依据就是
函数 && cid === 'undefined'
- 前面我们说过,在
- 如果判断是异步组件,则将
hasAsync
置为true
并让pending
自增,代表有发现异步组件,在解析完组件后pending
自减,当pending<=0
则代表异步组件解析结束,可以调用next
进行下一步 - 前面提过,
vue
的异步组件工厂函数会接收resolve、reject
两个方法并在从服务器得到组件定义后被调用; - 在接收到服务器返回异步组件的定义时,这两个方法会被传入异步组件工厂函数
- 由于异步组件工厂函数会返回一个
Promise
函数或特定格式的对象,所以会有下面情况- 如果是返回
Promise
,则将这两方法再传入返回的Promise.then
中 - 如果返回特定格式对象,则找到
component
字段,并将这两方法再传入component.then
中
- 如果是返回
- 由于
resolve、reject
已经被once
包装,即使传入多次,也只会被执行一次 - 我们看下
resolve、reject
方法 - 他们都被一个
once
方法包裹以保证只会被执行一次 reject
- 直接抛出一个错误并调用
next
传递到下一流程
- 直接抛出一个错误并调用
resolve
- 先判断下是否是
esm
,若是,则取其.default
字段来获取组件定义 - 拿到组件定义后,会先保留异步组件工厂函数,方便后续使用
- 然后替换路由记录的命名视图中的对应组件,这就完成了组件的解析并绑定到路由记录上
- 先判断下是否是
- 再次重申上面提到的逻辑都是
resolveAsyncComponents
返回的函数逻辑,这个函数逻辑会等到队列被执行时才实际调用 - 至此,我们的队列
queue
已经包含了抽取出来的守卫、钩子、包含解析异步组件逻辑的函数
- 队列已经构建完成,下面我们来看看它是如何执行的
守卫队列的执行
- 队列的执行是通过
runQueue、iterator
相互配合来实现的
runQueue
runQueue
方法位于src/util/async.js
1 | // src/util/async.js |
- 可以看到它接收一个队列
queue
、一个迭代函数fn
、一个执行完毕的回调函数cb
- 内部是一个递归的实现
- 定义了一个
step
函数并接收一个标识队列执行步骤的index
- 必须通过手动调用
step
才能跳到下一个队列项的执行- 在解析组件时会用到
- 当
index
大于等于队列的长度时(递归的结束条件),代表队列项全执行完毕,可以调用cb
- 否则,若还有队列项,则继续调用迭代函数
fn
并传入队列项和跳转下个队列项的step(index + 1)
函数 - 若无队列项了,则直接跳到下个队列项的执行
- 递归通过
step(0)
来激活
iterator
- 迭代器相关代码好下
1 | // src/history/base.js |
- 可以看到其接收一个
hook
也就是守卫队列中的守卫、钩子函数和next
函数(runQueue传递过来的step函数
) - 当在执行的过程中,路由发生变化,会立即取消
- 然后尝试调用
hook
,并传入目标路由对象、当前路由对象、以及一个接收to
的箭头函数 - 其实这三个参数就对应守卫会接收到的
from、to、next
三个参数
1 | router.beforeEach((to, from, next) => { |
- 我们知道守卫的
next
是一个function
,并能接收下面几种参数以满足不同的路由跳转需求next()
: 进行管道中的下一个钩子。next(false)
: 中断当前的导航。next('/')
或者next({ path: '/' })
: 跳转到一个不同的地址。当前的导航被中断,然后进行一个新的导航。你可以向 next 传递任意位置对象,且允许设置诸如 replace: true、name: ‘home’ 之类的选项以及任何用在 router-link 的 to prop 或 router.push 中的选项。next(error)
: (2.4.0+) 如果传入 next 的参数是一个 Error 实例,则导航会被终止且该错误会被传递给 router.onError() 注册过的回调。
- 上面接收
to
的箭头函数就处理了上述几种场景 - 队列中的每一项都会在
iterator
中被调用一次并通过next()
到跳转到下一个队列项的执行 - 了解了
runQueue、iterator
后,我们再来看看队列实际执行的代码是什么样的
队列执行
- 队列执行的完整代码如下
1 | // src/history/base.js |
- 可以看到,传入了队列
queue
、迭代器iterator
以及一个全部执行结束的回调函数 - 先回顾下
queue
队列中有哪些元素beforeRouteLeave守卫
- 全局的
beforeEach守卫
beforeRouteUpdate守卫
beforeEnter守卫
- 以及一个高阶函数,执行后会返回解析异步组件的函数
- 队列中的函数会在队列执行时依次在
iterator
中被调用 - 前面几个都是已经提取出来的守卫函数,可以同步执行
- 但是最后一个高阶函数执行后,会返回一个解析异步组件的函数
- 其借助闭包的特性,能访问从
iterator
中传入的from、to、next
- 然后在解析完异步组件后调用
next
,进入队列下一项的执行 - 这样就能保证,即使队列中有异步函数,也能顺序地将队列执行完
- 在整个守卫队列执行完后,就会执行结束回调
- 执行结束回调时,此时异步组件已经全部解析完毕,就可以抽取
beforeRouteEnter
了
抽取 beforeRouteEnter
- 抽取
beforeRouteEnter
和其它守卫稍微有点不同 - 因为
beforeRouteEnter
所在的组件可能是异步的,所以beforeRouteEnter
必须等到异步组件解析完毕才能开始抽取
- 因为
- 还有一个不同,就是在路由过渡动画为
out-in
时,异步组件可能已经解析完毕了,但是router-view
实例可能还未注册,此时是不能调用beforeRouteEnter
的;具体见issue #750
- 还有一个不同,就是在路由过渡动画为
- 因为
beforeRouteEnter
支持传一个回调给next
来访问组件实例,就像下面这样
1 | beforeRouteEnter (to, from, next) { |
- 而这个
vm
是保存在router-view
实例上的,所以需要等到router-view
实例存在时,才能调用回调 - 我们看下代码实现
1 | //src/history/base.js |
- 可以看到在调用
extractEnterGuards
前 - 在外层声明了一个
postEnterCbs
数组- 用来保存
beforeRouteEnter中传给next的回调函数
,我们称为postEnterCb
,也就是进入后的回调
- 用来保存
- 以及一个判断跳转是否结束的
isValid
函数 isValid
函数会被传入extractEnterGuards
中extractEnterGuards
中通过高阶函数形式返回一个包装了beforeRouteEnter
的具名函数routeEnterGuard
,其会在执行队列时被调用,并执行真正的beforeRouteEnter
守卫guard
guard
在被执行时,会接收from、to
以及一个被’改造’过的next
,其接收一个postEnterCb
- 这个
postEnterCb
可能在将来需要访问vm
- 所以将
postEnterCb
用poll
方法包裹塞入在外面定义好的postEnterCbs
数组中 poll
方法主要是用来解决前面提到的issue #750
的,它会一直轮询,直到router-view
实例存在时,再调用postEnterCb
并传入挂载到router-view
上的组件实例- 这样就实现了
next
中能访问到组件实例的逻辑 - 抽取完
beforeRouteEnter
守卫和其中的postEnterCbs
后,又在queue
后拼接了beforeResolve
守卫
1 | const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid) // 等待异步组件解析完,再抽取组件内的beforeRouteEnter守卫 |
- 此时
queue
中是routeEnterGuard
函数及resolveHook
- 然后执行此队列,队列中的
routerEnterGuard
和resolveHook
会执行
1 | runQueue( |
- 执行的逻辑和之前类似,
beforeRouteEnter
和beforeResolve
会被依次调用,然后执行队列结束回调 - 队列结束回调中会调用
onComplete
并传入目标Route
并在$nextTick
中遍历之前保存的postEnterCbs
,即传入next
的回调 - 此处的
onComplete
是确认路由时(confirmTransition
)传入的
1 | // src/history/base.js transitionTo方法中 |
- 可以看到其调用
updateRoute
来更新route
,这会触发afterEach
钩子 - 调用
ensureURL
更新 url - 并调用传入
transitionTo
的onComplete
函数,主要用来在vue-router
初始化时为hash
模式做初始化绑定(setupHashListener
) - 最后触发通过
onReady
注册的readyCbs
回调
1 | // src/history/base.js |
updateRoute
会调用History
上通过listen
方法注册的更新回调,触发roter-view
的重新渲染- 这些更新回调是在
vue-router
初始化时注册的
1 | // src/index.js init |
- 然后执行所有
afterEach
钩子 - 至此一次完整的路由跳转完成,相应的守卫及钩子也触发完成
总结
- 整个导航的解析(确认),其实就是从不同状态的路由记录中抽取出对应的守卫及钩子
- 然后组成队列,使用
runQueue
、iterator
巧妙的完成守卫的执行 - 并在其中处理了异步组件的解析、
postEnterCb
中实例获取的问题 - 整个守卫、钩子的执行流程如下
- 导航被触发。
- 在失活的组件里调用 beforeRouteLeave 守卫。
- 调用全局的 beforeEach 守卫。
- 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
- 在路由配置里调用 beforeEnter。
- 解析异步路由组件。
- 在被激活的组件里调用 beforeRouteEnter。
- 调用全局的 beforeResolve 守卫 (2.5+)。
- 导航被确认。
- 调用全局的 afterEach 钩子。
- 触发 DOM 更新。
- 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。
参考
- https://github.com/dwqs/blog/issues/53
- https://github.com/dwqs/blog/issues/54
- https://github.com/dwqs/blog/issues/55
- https://github.com/vuejs/vue-router/issues/2184#issuecomment-393484643
- https://juejin.im/post/5e456513f265da573c0c6d4b
- https://juejin.im/post/584040e1ac502e006cbedb23
- https://juejin.im/post/5df0ada0f265da33d56d1a2f
- https://juejin.im/post/5dbed0bef265da4cff701f68
- https://www.jianshu.com/p/29e8214d0bee
- https://ustbhuangyi.github.io/vue-analysis/v2/vue-router/
- https://blog.liuyunzhuge.com/2020/04/08/vue-router 源码:create-matcher/
PS
- 后面还会介绍其余部分,如果觉得还行,可以给个赞哦 ✨
- 个人
github
,也总结了一些东西,欢迎 star - 基于 canvas 的绘图板drawing-board
- 前端入门 demo、最佳实践集合 fe-awesome-demos
- 一个自动生成别名的
vue-cli-plugin
https://www.npmjs.com/package/vue-cli-plugin-auto-alias
npm 包
交流
- 如果有问题,可以加微信交流,共同成长,共同进步~