vue-router-analysis-part1

vue-router 源码解析 | 1.3w 字 | 多图预警 - 【上】

  • 各位好,我是光辉 😎
  • vue-router是每个vue开发者都会接触到的一个插件
  • 本着追本溯源的理念,我开启了vue-router源码分析之路 😂
  • 天天加班,所以前前后后花了好几个月时间
  • 时间拖的很长,所以可能存在思路不连贯的情况,还望见谅 🤣
  • 由于掘金字数限制,所以分为上中下三篇来介绍
  • 上篇即本篇,主要介绍了前端路由的设计思路及实现基本原则、vue-router的相关术语、目录结构、安装、实例化、初始化相关实现
  • 中篇介绍了其核心特性路由匹配、导航守卫触发的机制
  • 下篇为收尾篇,介绍了一些非核心特性,如滚动处理、view,link组件的实现
  • 第一次做源码解析,肯定有很多错误或理解不到位的地方,欢迎指正 🤞
  • 项目地址
    • https://github.com/BryanAdamss/vue-router-for-analysis
    • 如果觉得对你有帮助,记得给我一个star
  • uml 图源文件
    • https://github.com/BryanAdamss/vue-router-for-analysis/blob/dev/vue-router.EAP
  • 关联文章链接

设计思路

  • 在解析vue-router这个路由库前,我们要了解什么是路由,什么是前端路由?实现前端路由的常规套路是什么?

什么是前端路由

  • 关于路由的定义,维基是这样定义的;路由(routing)就是通过互联的网络把信息从源地址传输到目的地址的活动。路由发生在OSI网络参考模型中的第三层即网络层。路由引导分组转送,经过一些中间的节点后,到它们最后的目的地。作成硬件的话,则称为路由器。路由通常根据路由表——一个存储到各个目的地的最佳路径的表——来引导分组转送
  • 上面的定义可能很官方,但是我们可以抽出一些重点
    • 路由是一种活动,负责将信息从源地址传输到目的地址;
    • 要完成这样一个活动需要一个很重要的东西路由表-源地址和目标地址的映射表
  • 在web后台开发中,“route”是指根据url分配到对应的处理程序。;引用自https://www.zhihu.com/question/46767015@贺师俊
    • 用户输入一个 url,浏览器传递给服务端,服务端匹配映射表,找到对应的处理程序,返回对应的资源(页面 or 其它);
  • 对于前端来说,路由概念的出现是伴随着spa出现的;在spa出现之前,页面的跳转(导航)都是通过服务端控制的,并且跳转存在一个明显白屏跳转过程;spa出现后,为了更好的体验,就没有再让服务端控制跳转了,于是前端路由出现了,前端可以自由控制组件的渲染,来模拟页面跳转
  • 小结
    • 服务端路由根据url分配对应处理程序,返回页面、接口返回
    • 前端路由是通过js根据url返回对对应组件

如何实现前端路由

  • 看了上面的定义,要实现路由,需要一个很重要的东西-路由映射表;
    • 服务端做路由页面跳转时,映射表的反映的是url页面的关系
    • 现在前端基本走模块化了,所以前端的路由映射表反映的是url组件的关系
  • 就像下面的伪代码一样
1
2
3
4
5
6
7
8
9
10
11
12
13
// 使用es6 map
const routeMap = new Map([
['/', 首页组件],
['/bar', Bar组件],
['/bar/foo', Foo组件],
])

// 你也可以用对象字面量
const routeMap = {
'/': 首页组件,
'/bar': Bar组件,
'/bar/foo': Foo组件,
}
  • 有了映射表,我们就知道url和组件的映射关系;
  • 但是,映射表维护的只是一个关系,并不能帮我们完成,访问/bar,返回Bar组件这样一个流程,所以我们还需要一个匹配器,来帮我们完成从url组件的匹配工作;
  • 是不是有了路由映射表匹配器就可以实现前端路由了呢?
  • 我们的spa是运行在浏览器环境中的,浏览器是有前进、返回功能的,需要我们记录访问过的url
    • 我们知道,要实现这种类似撤销、恢复的功能,肯定需要使用到一种数据结构-栈(stack);每访问一个url,将urlpush到栈中,返回时,执行pop即可拿到上一次访问的url
    • 好在浏览器平台,已经给我们提供了这样的栈,无需我们自己实现,我们只需要去调用它的接口window.history实现功能即可
  • 画了个图,描述了下三者协作关系
    • sequence.png
      • sequence.png
  • 当我们访问某个 url 时,如/foo,匹配器会拿着/foo去路由映射表中去查找对应的组件,并将组件返回渲染,同时将访问记录推入历史栈中
  • 当我们通过前进/后退去访问某个url时,会先从历史栈中找到对应url,然后匹配器拿url去找组件,并返回渲染;只不过,这是通过前进/后退实现访问的,所以不需要再推入历史栈了

总结

  • 要实现一个前端路由,需要三个部分
    • 路由映射表
      • 一个能表达url组件关系的映射表,可以使用Map对象字面量来实现
    • 匹配器
      • 负责在访问url时,进行匹配,找出对应的组件
    • 历史记录栈
      • 浏览器平台,已经原生支持,无需实现,直接调用接口
  • 现在不用纠结他们的具体实现,你只需要知道有这三个东西,并且他们大概是如何协作的就可以了;
  • 后面我们将一起看看vue-router如何利用他们实现前端路由的

术语

  • 在分析vue-router源码前,我们先了解下vue-router中常出现的一些概念术语,如果理解起来吃力,可以先跳过,后面遇到,再回来看;

路由规则、配置对象(RouteConfig)

  • 路由的配置项,用来描述路由
  • 下图红框里面标出来的都是路由配置对象
  • route-config.png
    • route-config.png
  • 因为vue-router是支持嵌套路由的,所以配置对象也是可以相互嵌套的
  • 完整的形状如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface RouteConfig = {
path: string,
component?: Component,// 路由组件
name?: string, // 命名路由
components?: { [name: string]: Component }, // 命名视图组件
redirect?: string | Location | Function,
props?: boolean | Object | Function,
alias?: string | Array<string>,
children?: Array<RouteConfig>, // 嵌套路由
beforeEnter?: (to: Route, from: Route, next: Function) => void,
meta?: any,

// 2.6.0+
caseSensitive?: boolean, // 匹配规则是否大小写敏感?(默认值:false)
pathToRegexpOptions?: Object // 编译正则的选项
}

路由记录(RouteRecord)

  • 每一条路由规则都会生成一条路由记录;嵌套、别名路由也都会生成一条路由记录;是路由映射表的组成部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions), // 利用path-to-regexp包生成用来匹配path的增强正则对象,可以用来匹配动态路由
components: route.components || { default: route.component }, // 保存路由组件,支持命名视图https://router.vuejs.org/zh/guide/essentials/named-views.html#命名视图
instances: {}, // 保存每个命名router-view需要渲染的路由组件
name,
parent,
matchAs,
redirect: route.redirect, // 重定向的路由配置对象
beforeEnter: route.beforeEnter, // 路由独享的守卫
meta: route.meta || {}, // 元信息
// 动态路由传参;https://router.vuejs.org/zh/guide/essentials/passing-props.html#路由组件传参
props:
route.props == null
? {}
: route.components // 命名视图的传参规则需要使用route.props指定的规则
? route.props
: { default: route.props },
}

路由对象(Route)

  • Route表示当前激活的路由的状态信息,包含了当前 URL 解析得到的信息,还有 URL 匹配到的路由记录 (route records)。
    • https://router.vuejs.org/zh/api/#路由对象
  • 注意,这说明一个Route可以关联多个RouteRecord
  • 通过this.$route访问到的就是Route对象
  • route.png
    • route.png
  • 路由对象是不可变 (immutable) 的,每次成功的导航后都会产生一个新的对象

位置(Location)

  • 它并不是window.location的引用,vue-router在内部定义了一个Location,是一个用来描述目标位置的对象;
  • $router.push/replacerouter-link的to接收的就是Location对象
    • https://router.vuejs.org/zh/api/#to
  • vue-router内部可以将一个url string转换成Location对象,所以确切的说$router.push/replacerouter-link的to接收的都是一个RawLocation对象
  • RawLocation对象StringLocation的联合类型
1
2
3
4
5
6
7
8
9
10
11
export type RawLocation = string | Location

export interface Location {
name?: string
path?: string
hash?: string
query?: Dictionary<string | (string | null)[] | null | undefined>
params?: Dictionary<string>
append?: boolean
replace?: boolean
}

路由组件(RouteComponent)

  • 当路由成功匹配时,就需要在router-view渲染一个组件,这个需要被渲染的组件就是路由组件
  • RouteConfigcomponent、components中定义的vue组件就是路由组件
  • 路由组件的特殊性
    • 拥有只在路由组件中生效的守卫(beforeRouteEnter 、beforeRouteUpdate、beforeRouteLeave)
    • 你是否跟我一样,曾经在组件中调用beforeRouteEnter发现没有生效,那是因为这个守卫只能在路由组件中被调用,在所有非路由组件中都不会被调用,包括路由组件的后代组件;你如果想在路由组件中实现beforeRouteEnter类似守卫监听效果,可以通过watch $route来手动判断
  • 红框标记出来的是路由组件
  • route-component.png
    • route-component.png
  • 看完上面的术语,你可能还是云里雾里的,没事,后面会详解,现在你只需要有个大概了解即可;

环境

  • vue-router版本:v3.1.6
  • node版本:v8.17.0
  • 分析仓库地址:https://github.com/BryanAdamss/vue-router-for-analysis
    • 划重点
      • 注意查看commit记录commit记录了我整个分析的流程
      • 如果你觉得还可以,不要忘记starfork🤞

目录结构

  • 首先我们将vue-router仓库clone下来,看下目录结构大概是什么样的
  • git clone git@github.com:BryanAdamss/vue-router-for-analysis.git

目录

  • 可以看到会有如下目录
  • directory.png
    • directory.png
  • 别看目录多么多,其实我已经给你划了重点
  • 我们其实只需要关注下面几个目录或文件
  • examples
    • 这里面存放了官方精心准备的案例
    • 不仅告诉你vue-router有哪些基础特性,还会告诉你怎么应对一些复杂场景,例如权限控制、动态添加路由等;总之,值得你去一探究竟;
    • examples.png
      • examples.png
    • 另外,我们在分析源码的时候,还可以利用这些例子进行调试
      • 在源码你想调试处添加debugger断点标识,然后启动例子npm run dev,打开localhost:8080即可
  • src目录
    • 这是vue-router源码存放的目录,是我们需要重点关注的地方
    • src.png
      • src.png
    • 捡几个重要目录先说下
      • components目录是存放内置组件router-linkrouter-view
      • history是存放核心history类的地方
      • util中存放的是一些辅助函数
      • index.jsvue-router的入口文件,也是vue-router类定义的地方
      • install.js是安装逻辑所在文件
  • flow/declarations.js
    • 它是vue-router的 flow 类型声明文件,通过它我们能知道vue-router中几个核心类(对象)是长什么样的
    • 它里面,大概长这样
    • declarations.png
      • declarations.png

基础例子

  • 我们从最基础的例子开始看
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import Vue from 'vue'
import VueRouter from 'vue-router'
// 1. Use plugin.
// 安装插件
Vue.use(VueRouter)

// 2. Define route components
// 定义路由组件
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 3. Create the router
// 实例化vue-router
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar },
],
})

// 4. Create and mount root instance.
new Vue({
router, // 注入router实例
template: `
    <div id="app">
      <h1>Basic</h1>

      <ul>
        <li><router-link to="/">/</router-link></li>
        <li><router-link to="/foo">/foo</router-link></li>
        <li><router-link to="/bar">/bar</router-link></li>
</ul>

      <router-view class="view"></router-view>
    </div>
  `,
}).$mount('#app')
  • 可以看到,首先使用vue的插件语法安装了vue-router; 然后我们再实例化VueRouter; 最后我们将VueRouter的实例注入到Vue
  • 这其实就涉及到三个核心流程:安装、实例化、初始化
  • 我们先从这三个流程讲起

安装流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/index.js

/* @flow */
import { install } from './install' // 导入安装方法
...

// VueRouter类
export default class VueRouter {
  ... 
}

...

VueRouter.install = install // 挂载安装方法,Vue.use时,自动调用install方法
VueRouter.version = '__VERSION__'

// 浏览器环境,自动安装VueRouter
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}
  • 可以看到,开头导入了install方法,并将其做为静态方法直接挂载到VueRouter上,这样,在Vue.use(VueRouter)时,install方法就会被调用;
  • 可以看到,如果在浏览器环境,并且通过script标签的形式引入Vue时(会在window上挂载Vue全局变量),会尝试自动使用VueRouter
  • 我们接下来看看install.js中是什么

install.js

  • install方法并不长
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import View from './components/view'
import Link from './components/link'

export let _Vue // 用来避免将Vue做为依赖打包进来
// install方法
export function install(Vue) {
if (install.installed && _Vue === Vue) return // 避免重复安装
install.installed = true

_Vue = Vue // 保留Vue引用
const isDef = (v) => v !== undefined // 为router-view组件关联路由组件

const registerInstance = (vm, callVal) => {
let i = vm.$options._parentVnode // 调用vm.$options._parentVnode.data.registerRouteInstance方法 // 而这个方法只在router-view组件中存在,router-view组件定义在(../components/view.js @71行) // 所以,如果vm的父节点为router-view,则为router-view关联当前vm,即将当前vm做为router-view的路由组件
if (
isDef(i) &&
isDef((i = i.data)) &&
isDef((i = i.registerRouteInstance))
) {
i(vm, callVal)
}
} // 注册全局混入

Vue.mixin({
beforeCreate() {
// this === new Vue({router:router}) === Vue根实例

// 判断是否使用了vue-router插件
if (isDef(this.$options.router)) {
// 在Vue根实例上保存一些信息
this._routerRoot = this // 保存挂载VueRouter的Vue实例,此处为根实例
this._router = this.$options.router // 保存VueRouter实例,this.$options.router仅存在于Vue根实例上,其它Vue组件不包含此属性,所以下面的初始化,只会执行一次 // beforeCreate hook被触发时,调用
this._router.init(this) // 初始化VueRouter实例,并传入Vue根实例 // 响应式定义_route属性,保证_route发生变化时,组件(router-view)会重新渲染
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 回溯查找_routerRoot
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
} // 为router-view组件关联路由组件

registerInstance(this, this)
},
destroyed() {
// destroyed hook触发时,取消router-view和路由组件的关联
registerInstance(this)
},
}) // 在原型上注入$router、$route属性,方便快捷访问

Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._routerRoot._router
},
})
Object.defineProperty(Vue.prototype, '$route', {
// 每个组件访问到的$route,其实最后访问的都是Vue根实例的_route
get() {
return this._routerRoot._route
},
}) // 注册router-view、router-link全局组件

Vue.component('RouterView', View)
Vue.component('RouterLink', Link)

const strats = Vue.config.optionMergeStrategies // use the same hook merging strategy for route hooks
strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate =
strats.created
}
  • 可以看到,install方法干了下面几件事

避免重复安装

  • 通过添加installed标识来判断是否重复安装

保留Vue引用,避免将Vue做为依赖打包

  • install方法被调用时,会将Vue做为参数传入,Vue会被赋值给事先定义好的_Vue变量
  • 在其它模块中,可以导入这个_Vue,这样既能访问到Vue,又避免了将Vue做为依赖打包
  • 这是一个插件开发实用小技巧

注册了一个全局混入

  • 这个混入将影响注册之后所有创建的每个 Vue 实例,也就是后面每个Vue实例都会执行混入中的代码
  • 我们看下混入中的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 注册全局混入
Vue.mixin({
beforeCreate() {
// this === new Vue({router:router}) === Vue根实例

// 判断是否使用了vue-router插件
if (isDef(this.$options.router)) {
// 在Vue根实例上保存一些信息
this._routerRoot = this // 保存挂载VueRouter的Vue实例,此处为根实例
this._router = this.$options.router // 保存VueRouter实例,this.$options.router仅存在于Vue根实例上,其它Vue组件不包含此属性,所以下面的初始化,只会执行一次 // beforeCreate hook被触发时,调用
this._router.init(this) // 初始化VueRouter实例,并传入Vue根实例 // 响应式定义_route属性,保证_route发生变化时,组件(router-view)会重新渲染
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 回溯查找_routerRoot
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
} // 为router-view组件关联路由组件

registerInstance(this, this)
},
destroyed() {
// destroyed hook触发时,取消router-view和路由组件的关联
registerInstance(this)
},
})
  • 它注册了两个生命周期钩子beforeCreatedestroyed
  • 注意
    • 在这两个钩子中,this是指向当时正在调用钩子的vue实例
    • 这两个钩子中的逻辑,在安装流程中是不会被执行的,只有在组件实例化时执行到钩子时才会被调用
  • 我们先看beforeCreate钩子
  • 它先判断了this.$options.router是否存在,我们在new Vue({router})时,router已经被保存到到Vue根实例$options上,而其它Vue实例$options上是没有router
    • 所以if中的语句只在this === new Vue({router})时,才会被执行,由于Vue根实例只有一个,所以这个逻辑只会被执行一次
    • 我们可以在if中打印this并结合调试工具看看
    • root-instance.png
      • root-instance.png
    • 的确,if中的逻辑只执行了一次,并且this就是指向Vue根实例
  • 我们看下if中具体干了什么
    • 在根实例上保存了_routerRoot,用来标识router挂载的Vue实例
    • 在根实例上保存了VueRouter实例(router)
    • router进行了初始化(init)
      • router初始化逻辑我们后面细看
    • 在根实例上,响应式定义了_route属性
      • 保证_route变化时,router-view会重新渲染,这个我们后面在router-view组件中会细讲
  • 我们再看下else中具体干了啥
    • 主要是为每个组件定义_routerRoot,采用的是逐层向上的回溯查找方式
  • 我们看到还有个registerInstance方法,它在beforeCreatedestroyed都有被调用,只是参数个数不一样
    • beforeCreate中传入了两个参数,且都是this即当前vue实例,而在destroyed中只传入了一个vue实例
    • 我们在讲router-view时会细讲,你只需要知道它是用来为router-view组件关联或解绑路由组件用的即可
    • 传入两个参数即关联,传入一个参数即解绑

添加实例属性、方法

  • Vue原型上注入$router、$route属性,方便在vue实例中通过this.$routerthis.$route快捷访问
  • 通过Vue.component语法注册了router-viewrouter-link两个全局组件

设置路由组件守卫的合并策略

  • 设置路由组件的beforeRouteEnterbeforeRouteLeavebeforeRouteUpdate守卫的合并策略

总结

  • 我们用一张图,总结一下安装流程
  • install.png
    • install.png

实例化流程

  • 看完安装流程,我们紧接着来看下VueRouter的实例化过程
  • 这一节,重点关注实例化过程,所以我们只看constructor中的核心逻辑

VueRouter 的构造函数

  • 我们打开src/index.js看下VueRouter构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/index.js

export default class VueRouter {
constructor(options: RouterOptions = {}) {
this.app = null // 保存挂载实例
this.apps = [] // VueRouter支持多实例
this.options = options
this.beforeHooks = [] // 接收 beforeEach hook
this.resolveHooks = [] // 接收 beforeResolve hook
this.afterHooks = [] // 接收 afterEach hook
this.matcher = createMatcher(options.routes || [], this) // 创建路由matcher对象,传入routes路由配置列表及VueRouter实例
let mode = options.mode || 'hash'
this.fallback =
mode === 'history' && !supportsPushState && options.fallback !== false // 是否回退
if (this.fallback) {
mode = 'hash'
} // 非浏览器环境,强制使用abstract模式

if (!inBrowser) {
mode = 'abstract'
}
this.mode = mode // 根据不同mode,实例化不同history实例

switch (mode) {
case 'history':
this.history = new HTML5History(this, options.base)
break
case 'hash':
this.history = new HashHistory(this, options.base, this.fallback)
break
case 'abstract':
this.history = new AbstractHistory(this, options.base)
break
default:
if (process.env.NODE_ENV !== 'production') {
assert(false, `invalid mode: ${mode}`)
}
}
}
}
  • 构造函数主要干了下面几件事

接收 RouterOptions

  • 可以看到,构造函数接收一个options选项对象,它的类型是RouterOptions,我们来看下RouterOptions
  • 打开flow/declarations.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// flow/declarations.js

declare type RouterOptions = {
routes?: Array<RouteConfig>, // 路由配置规则列表
mode?: string, // 路由模式
fallback?: boolean, // 是否启用回退
base?: string, // base地址
linkActiveClass?: string, // router-link激活时类名
linkExactActiveClass?: string, // router-link精准激活时类名
parseQuery?: (query: string) => Object, // 自定义解析qs的方法
stringifyQuery?: (query: Object) => string, // 自定义序列化qs的方法
scrollBehavior?: (
// 控制滚动行为
to: Route,
from: Route,
savedPosition: ?Position
) => PositionResult | Promise<PositionResult>,
}
  • RouterOptions定义了VueRouter所能接收的所有选项;
  • 我们重点关注一下下面的几个选项值
    • routes是路由配置规则列表,这个主要用来后续生成路由映射表的;
      • 它是一个数组,每一项都是一个路由配置规则(RouteConfig),关于RouteConfig,可以回看术语那一节;
    • modefallback是跟路由模式相关的
      • 后面会详细讲VueRouter的路由模式

属性赋初值

  • 对一些属性赋予了初值,例如,对接收全局导航守卫(beforeEach、beforeResolve、afterEach)的数组做了初始化

创建 matcher

  • 通过createMatcher生成了matcher
  • 这个matcher对象就是最初聊的匹配器,负责 url 匹配,它接收了routesrouter实例createMatcher里面不光创建了matcher,还创建了路由映射表RouteMap,我们后面细看

确定路由模式

  • 三种路由模式我们后面细讲
  • 现在只需要知道VueRouter是如何确定路由模式的
  • VueRouter会根据options.modeoptions.fallbacksupportsPushStateinBrowser来确定最终的路由模式
  • 先确定fallbackfallback只有在用户设置了mode:history并且当前环境不支持pushState且用户主动声明了需要回退,此时fallback才为true
  • fallback为true时会使用hash模式;
  • 如果最后发现处于非浏览器环境,则会强制使用abstract模式
  • route-mode.png
    • route-mode.png
  • inBrowsersupportsPushState的实现都很简单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/util/dom.js
export const inBrowser = typeof window !== 'undefined' // 直接判断window是否存在,来确定是否在浏览器环境中

// src/util/push-state.js
export const supportsPushState =
inBrowser && // 浏览器环境
(function () {
const ua = window.navigator.userAgent
if (
(ua.indexOf('Android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) &&
ua.indexOf('Mobile Safari') !== -1 &&
ua.indexOf('Chrome') === -1 &&
ua.indexOf('Windows Phone') === -1
) {
return false // 特殊浏览器直接不支持
}
return window.history && typeof window.history.pushState === 'function' // 判断是否支持pushState
})()

根据路由模式生成不同的 History 实例

  • 根据上一步的路由模式生成不同的History实例,关于路由模式、History实例后面再讲

小结

  • VueRouter的构造函数主要干了下面几件事
    • 接收一个RouterOptions
    • 然后对一些属性赋了初值
    • 生成了matcher匹配器
    • 确定路由模式
    • 根据不同路由模式生成不同History实例

创建匹配器

  • 我们来细看一下createMatcher里面的实现
  • createMatcher的实现在src/create-matcher.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
 // src/create-matcher.js

...

// Matcher类型
export type Matcher = {
  match: (raw: RawLocation, current?: Route, redirectedFrom?: Location) => Route; // 匹配方法
  addRoutes: (routes: Array<RouteConfig>) => void; // 添加Route方法
};

// Matcher工厂函数
export function createMatcher (
  routes: Array<RouteConfig>, // 路由配置列表
  router: VueRouter // VueRouter实例
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes) // 创建路由映射表
  // 添加路由
  function addRoutes (routes{
    // 由于传入pathList, pathMap, nameMap了,所以createRouteMap方法会执行添加逻辑
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // 传入location,返回匹配的Route对象
  function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {
    ...
  }
  
  // 返回Matcher对象,暴露match、addRoutes方法
  return {
    match,
    addRoutes
  }
}
  • 可以看到,createMatcher方法接收一个路由配置规则列表和router实例,返回一个Matcher对象
  • Matcher对象包含一个用于匹配的match方法和一个动态添加路由的addRoutes方法;
    • 而这两个方法都声明在createMatcher内部,由于闭包特性,它能访问到createMatcher作用域的所有变量

小结

  • 我们总结下createMatcher的逻辑
  • create-matcher.png
    • create-matcher.png
  • 我们可以看到createMatcheraddRoutes方法中都调用了createRouteMap方法,二者只是传递的参数不同,从方法名看,这个方法肯定和路由表RouteMap有关
  • 我们接下来看看createRouteMap的实现

createRouteMap

  • createRouteMap方法位于src/create-route-map.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 创建路由映射map、添加路由记录
export function createRouteMap(
routes: Array<RouteConfig>, // 路由配置列表
oldPathList?: Array<string>, // 旧pathList
oldPathMap?: Dictionary<RouteRecord>, // 旧pathMap
oldNameMap?: Dictionary<RouteRecord> // 旧nameMap
): {
pathList: Array<string>
pathMap: Dictionary<RouteRecord>
nameMap: Dictionary<RouteRecord>
} {
// 若旧的路由相关映射列表及map存在,则使用旧的初始化(借此实现添加路由功能)
// the path list is used to control path matching priority
const pathList: Array<string> = oldPathList || [] // $flow-disable-line
const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null) // $flow-disable-line
const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null) // 遍历路由配置对象,生成/添加路由记录

routes.forEach((route) => {
addRouteRecord(pathList, pathMap, nameMap, route)
}) // ensure wildcard routes are always at the end // 确保path:*永远在在最后

for (let i = 0, l = pathList.length; i < l; i++) {
if (pathList[i] === '*') {
pathList.push(pathList.splice(i, 1)[0])
l--
i--
}
} // 开发环境,提示非嵌套路由的path必须以/或者*开头

if (process.env.NODE_ENV === 'development') {
// warn if routes do not include leading slashes
const found = pathList // check for missing leading slash
.filter(
(path) => path && path.charAt(0) !== '*' && path.charAt(0) !== '/'
)
if (found.length > 0) {
const pathNames = found.map((path) => `- ${path}`).join('\n')
warn(
false,
`Non-nested routes must include a leading slash character. Fix the following routes: \n${pathNames}`
)
}
}

return {
pathList,
pathMap,
nameMap,
}
}
  • 可以看到createRouteMap返回一个对象,它包含pathListpathMapnameMap
  • pathList中存储了routes中的所有path
  • pathMap维护的是path路由记录RouteRecord的映射
  • nameMap维护的是name路由记录RouteRecord的映射
    • 因为VueRouter支持命名路由
  • 后两者,都是为了快速找到对应的路由记录
  • 可以看下使用下面的routes调用createRouteMap会返回什么
1
2
3
4
5
6
7
8
9
10
11
12
13
14
;[
{ path: '/', component: Home },
{
path: '/foo',
component: Foo,
children: [
{
path: 'child',
component: FooChild,
},
],
},
{ path: '/bar/:dynamic', component: Bar },
]
  • route-map-obj.png
    • route-map-obj.png
  • 由于没有命名路由,所以nameMap为空
  • pathList存储了所有path,有个为空,其实是/,在normalizePath时被删除了
  • pathMap记录了每个path对应RouteRecord的映射关系

小结

  • VueRouter的路由映射表由三部分组成:pathListpathMapnameMap;后两者是用来快速查找的
  • createRouteMap的逻辑
    • 先判断路由相关映射表是否已经存在,若存在则使用,否则新建;
      • 这就实现了createRouteMap创建/新增的双重功能
    • 然后遍历routes,依次为每个route调用addRouteRecord生成一个RouteRecord并更新pathListpathMapnameMap
    • 由于pathList在后续逻辑会用来遍历匹配,为了性能,所以需要将path:*放置到pathList的最后
    • 最后检查非嵌套路由的path是否是以/或者*开头
  • 用图总结如下
  • create-route-map-sequence.png
    • create-route-map-sequence.png
  • 接下来,我们看看路由记录是如何生成的

addRouteRecord

  • 这个方法主要是创建路由记录并更新路由映射表
  • 位于src/create-route-map.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// src/create-route-map.js

// 添加路由记录,更新pathList、pathMap、nameMap
function addRouteRecord(
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord, // 父路由时记录
matchAs?: string // 处理别名路由时使用
) {
const { path, name } = route

if (process.env.NODE_ENV !== 'production') {
// route.path不能为空
assert(path != null, `"path" is required in a route configuration.`) // route.component不能为string

assert(
typeof route.component !== 'string',
`route config "component" for path: ${String(
path || name
)} cannot be a ` + `string id. Use an actual component instead.`
)
}
const pathToRegexpOptions: PathToRegexpOptions =
route.pathToRegexpOptions || {} // 生成格式化后的path(子路由会拼接上父路由的path)

const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)
// 匹配规则是否大小写敏感?(默认值:false)
if (typeof route.caseSensitive === 'boolean') {
pathToRegexpOptions.sensitive = route.caseSensitive
} // 生成一条路由记录

const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions), // 利用path-to-regexp包生成用来匹配path的增强正则对象,可以用来匹配动态路由
components: route.components || { default: route.component }, // 保存路由组件,支持命名视图https://router.vuejs.org/zh/guide/essentials/named-views.html#命名视图
instances: {}, // 保存router-view实例
name,
parent,
matchAs,
redirect: route.redirect, // 重定向的路由配置对象
beforeEnter: route.beforeEnter, // 路由独享的守卫
meta: route.meta || {}, // 元信息
// 动态路由传参;https://router.vuejs.org/zh/guide/essentials/passing-props.html#路由组件传参
props:
route.props == null
? {}
: route.components // 命名视图的传参规则需要使用route.props指定的规则
? route.props
: { default: route.props },
} // 处理有子路由情况

if (route.children) {
// Warn if route is named, does not redirect and has a default child route.
// If users navigate to this route by name, the default child will
// not be rendered (GH Issue #629)
// https://github.com/vuejs/vue-router/issues/629
// 命名路由 && 未使用重定向 && 子路由配置对象path为''或/时,使用父路由的name跳转时,子路由将不会被渲染
if (process.env.NODE_ENV !== 'production') {
if (
route.name &&
!route.redirect &&
route.children.some((child) => /^\/?$/.test(child.path))
) {
warn(
false,
`Named Route '${route.name}' has a default child route. ` +
`When navigating to this named route (:to="{name: '${route.name}'"), ` +
`the default child route will not be rendered. Remove the name from ` +
`this route and use the name of the default child route for named ` +
`links instead.`
)
}
} // 遍历生成子路由记录

route.children.forEach((child) => {
const childMatchAs = matchAs // matchAs若有值,代表当前路由是别名路由,则需要单独生成别名路由的子路由,路径前缀需使用matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
} // 若pathMap中不存在当前路径,则更新pathList和pathMap

if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
} // 处理别名;https://router.vuejs.org/zh/guide/essentials/redirect-and-alias.html#%E5%88%AB%E5%90%8D

if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias] // alias支持string,和Array<String>
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && alias === path) {
// alias的值和path重复,需要给提示
warn(
false,
`Found an alias with the same value as the path: "${path}". You have to remove that alias. It will be ignored in development.`
) // skip in dev to make it work
continue
} // 生成别名路由配置对象

const aliasRoute = {
path: alias,
children: route.children,
} // 添加别名路由记录

addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute, // 别名路由
parent, // 当前路由的父路由,因为是给当前路由取了个别名,所以二者其实是有同个父路由的
record.path || '/' // matchAs,用来生成别名路由的子路由;
) // ! 总结:当前路由设置了alias后,会单独为当前路由及其所有子路由生成路由记录,且子路由的path前缀为matchAs(即别名路由的path)
}
} // 处理命名路由

if (name) {
// 更新nameMap
if (!nameMap[name]) {
nameMap[name] = record
} else if (process.env.NODE_ENV !== 'production' && !matchAs) {
// 路由重名警告
warn(
false,
`Duplicate named routes definition: ` +
`{ name: "${name}", path: "${record.path}" }`
)
}
}
}
  • 看下逻辑
    • 检查了路由规则中的pathcomponent
    • 生成path-to-regexp的选项pathToRegexpOptions
    • 格式化path,如果是嵌套路由,则会追加上父路由的path
    • 生成路由记录
    • 处理嵌套路由,递归生成子路由记录
    • 更新pathListpathMap
    • 处理别名路由,生成别名路由记录
    • 处理命名路由,更新nameMap
  • 我们来看下几个核心逻辑

生成路由记录

  • 路由记录记录了路由的核心信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const record: RouteRecord = {
path: normalizedPath, // 规范化后的路径
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions), // 利用path-to-regexp包生成用来匹配path的增强正则对象,可以用来匹配动态路由
components: route.components || { default: route.component }, // 保存路由组件,支持命名视图https://router.vuejs.org/zh/guide/essentials/named-views.html#命名视图
instances: {}, // 保存router-view实例
name,
parent, // 父路由记录
matchAs, // 别名路由需要使用
redirect: route.redirect, // 重定向的路由配置对象
beforeEnter: route.beforeEnter, // 路由独享的守卫
meta: route.meta || {}, // 元信息
// 动态路由传参;https://router.vuejs.org/zh/guide/essentials/passing-props.html#路由组件传参
props:
route.props == null
? {}
: route.components // 命名视图的传参规则需要使用route.props指定的规则
? route.props
: { default: route.props },
}
  • 路由记录有个regex字段,它是一个增强的正则表达式,它是实现动态路由匹配的关键
  • regex是通过compileRouteRegex方法返回的,它里面调用了path-to-regexp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import Regexp from 'path-to-regexp'

...

// 使用path-to-regexp包,生成route对应的正则,可以用来生成动态路由需要的正则表达式
function compileRouteRegex (
  path: string,
  pathToRegexpOptions: PathToRegexpOptions
): RouteRegExp {

  const regex = Regexp(path, [], pathToRegexpOptions)
  if (process.env.NODE_ENV !== 'production') {
    const keys: any = Object.create(null)
    regex.keys.forEach(key => {
      // 重复key浸膏
      warn(
        !keys[key.name],
        `Duplicate param keys in route with path: "${path}"`
      )
      keys[key.name] = true
    })
  }
  return regex
}
  • 我们看下path-to-regexp是如何使用的
  • 官网是:https://www.npmjs.com/package/path-to-regexp
  • Regexp 接收三个参数path,keys,optionspath为需要转换为正则的路径,keys,是用来接收在path中找到的key,可以传入,也可以直接使用返回值上的keys属性,options为选项
1
2
3
4
5
const pathToRegexp = require('path-to-regexp')

const regexp = pathToRegexp('/foo/:bar') // regexp = /^\/foo\/([^\/]+?)\/?$/i
// :bar被处理成正则的一个组了,当正则被执行时,就可以通过组取到bar对应的值
console.log(regexp.keys) // keys = [{ name: 'bar', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' }]
  • 通过下面的例子,就可以知道动态路由获取参数值是如何实现的
1
2
3
4
5
6
7
8
9
// test.js

const pathToRegexp = require('path-to-regexp')
const regexp = pathToRegexp('/foo/:bar')
console.log(regexp.keys) // 记录了key信息

const m = '/foo/test'.match(regexp) // 正则的组记录了value信息

console.log('key:', regexp.keys[0].name, ',value:', m[1])
  • path-to-regex-demo.png
    • path-to-regex-demo.png

生成嵌套路由记录

  • 我们知道vue-router是支持嵌套路由的,我们来看看是如何生成嵌套路由记录的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/create-route-map

// 处理有子路由情况
if (route.children) {
// Warn if route is named, does not redirect and has a default child route.
// If users navigate to this route by name, the default child will
// not be rendered (GH Issue #629)
// https://github.com/vuejs/vue-router/issues/629
// 命名路由 && 未使用重定向 && 子路由配置对象path为''或/时,使用父路由的name跳转时,子路由将不会被渲染
if (process.env.NODE_ENV !== 'production') {
if (
route.name &&
!route.redirect &&
route.children.some((child) => /^\/?$/.test(child.path))
) {
warn(
false,
`Named Route '${route.name}' has a default child route. ` +
`When navigating to this named route (:to="{name: '${route.name}'"), ` +
`the default child route will not be rendered. Remove the name from ` +
`this route and use the name of the default child route for named ` +
`links instead.`
)
}
} // 遍历生成子路由记录
route.children.forEach((child) => {
const childMatchAs = matchAs // matchAs若有值,代表当前路由是别名路由,则需要单独生成别名路由的子路由,路径前缀需使用matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}
  • 首先针对#629问题做出了警告提示
    • #629问题主要是 当一个路由是 命名路由 && 未使用重定向 && 子路由配置对象path为''或/时,使用父路由的name跳转时,子路由将不会被渲染
  • 然后遍历子路由规则列表,生成子路由记录
    • 这里面还处理了别名路由的子路由情况
      • 遍历时如果发现父路由被标记为别名路由,则子路由的path前面需要加上父路由的path,然后再生成记录
  • 我们可以看下下面的嵌套路由,生成的路由映射表长什么样
1
2
3
4
5
6
7
;[
{
path: '/parent',
component: Parent,
children: [{ path: 'foo', component: Foo }],
},
]
  • nested-route.png
    • nested-route.png
  • 可以看到,子路由的path会前追加上父路由的path

生成别名路由记录

  • VueRouter支持给路由设置别名;/a 的别名是 /b,意味着,当用户访问 /b 时,URL 会保持为 /b,但是路由匹配则为 /a,就像用户访问 /a 一样
  • 我们来看看椒如何生成别名路由记录的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/create-route-map.js

if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias] // alias支持string,和Array<String>
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && alias === path) {
// alias的值和path重复,需要给提示
warn(
false,
`Found an alias with the same value as the path: "${path}". You have to remove that alias. It will be ignored in development.`
) // skip in dev to make it work
continue
} // 生成别名路由配置对象

const aliasRoute = {
path: alias,
children: route.children,
} // 添加别名路由记录

addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute, // 别名路由
parent, // 当前路由的父路由,因为是给当前路由取了个别名,所以二者其实是有同个父路由的
record.path || '/' // matchAs,用来生成别名路由的子路由;
) // ! 总结:当前路由设置了alias后,会单独为当前路由及其所有子路由生成路由记录,且子路由的path前缀为matchAs(即别名路由的path)
}
}
  • 别名是支持单别名和多别名的,即route.alias支持传入/foo['/foo','/bar'],所以先对这两种情况做了归一处理,统一处理成数组
  • 然后遍历这个数组,先检查别名和path是否重复,然后单独为别名路由生成一份配置传入,最后调用addRouteRecord生成别名路由记录
    • 注意此处还通过matchAs处理了别名路由生成子路由的场景,主要通过设置matchAsrecord.path || '/',然后在生成子路由记录时,会根据matchAs生成别名路由记录的子记录,具体可看上面的嵌套路由章节
  • 我们看看,别名路由生成的的路由映射表长什么样
1
2
3
4
5
6
7
8
9
10
11
;[
{ path: '/root', component: Root, alias: '/root-alias' },
{
path: '/home',
component: Home,
children: [
{ path: 'bar', component: Bar, alias: 'bar-alias' },
{ path: 'baz', component: Baz, alias: ['/baz', 'baz-alias'] },
],
},
]
  • alias-route.png
    • alias-route.png
  • 可以看到为别名路由和别名路由的子路由都单独生成了一条路由记录

小结

  • 路由记录是路由映射表的重要组成部分
    • 路由记录中的regex是处理动态路由传参的关键字段,主要是借助path-to-regexp实现的
  • 生成路由记录主要考虑了下面几种路由记录的生成
    • 嵌套路由
      • 子路由单独生成一条路由记录
    • 别名路由及别名路由子路由
      • 别名路由及其子路由分别会生成一条路由记录
    • 命名路由
  • 生成路由记录的整个流程如下图所示
  • add-route-record.png
    • add-route-record.png
  • 至此我们已经分析完了VueRouter实例化中创建匹配器(生成路由映射表)相关逻辑
  • 下面我们将看看根据路由模式生成History实例的相关逻辑

路由模式

  • 前端路由一个很重要的特性是要实现无刷新切换页面
    • 即 url 改变,页面不刷新实现页面的跳转
  • 要实现这一点,有两种方案
  • 一种hash+hashChange,另一种利用History APIpushState+popState
  • 前者主要利用hash改变时页面不会刷新并会触发hashChange这个特性来实现前端路由
  • 后者充分利用了HTML5 History APIpushState方法和popState事件来实现前端路由
  • 二者比较
  • 前者
    • 兼容性好,hashChange支持到 IE8
    • url中会携带/#/,不美观
    • 不需要服务端改造
  • 后者
    • 兼容到 IE10
    • url跟正常url一样
    • 由于其url跟正常url一样,所以在刷新时,会以此url为链接请求服务端页面,而服务端是没有这个页面的,会 404,因此需要服务端配合将所有请求重定向到首页,将整个路由的控制交给前端路由
  • VueRouter支持三种路由模式,分别为hashhistoryabstract
  • hash模式就是第一种方案的实现
  • history模式是第二种方案的实现
  • abstract模式是用在非浏览器环境的,主要用于SSR

核心类

  • VueRouter的三种路由模式,主要由下面的三个核心类实现
  • History
    • 基础类
    • 位于src/history/base.js
  • HTML5History
    • 用于支持pushState的浏览器
    • src/history/html5.js
  • HashHistory
    • 用于不支持pushState的浏览器
    • src/history/hash.js
  • AbstractHistory
    • 用于非浏览器环境(服务端渲染)
    • src/history/abstract.js
  • 通过下面这张图,可以了解到他们之间的关系
  • route-mode-class.png
    • route-mode-class.png
  • HTML5HistoryHashHistoryAbstractHistory三者都是继承于基础类History
  • 三者不光能访问History类的所有属性和方法,他们还都实现了基础类中声明的需要子类实现的 5 个接口(go、push、replace、ensureURL、getCurrentLocation)
  • 由于HashHistory监听hashChange的特殊性,所以会单独多一个setupListeners方法
  • AbstractHistory由于需要在非浏览器环境使用,没有历史记录栈,所以只能通过index、stack来模拟
  • 前面我们分析VueRouter实例化过程时,知道VueRouter会在确定完路由模式后,会实例化不同的History实例
  • 那我们来看看不同History的实例化过程

History 类

  • 它是父类(基类),其它类都是继承它的
  • 代码位于src/history/base.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/history/base.js
// 父类
export class History {
  router: Router
  base: string
  current: Route
  pending: ?Route
  cb: (r: Route) => void
  ready: boolean
  readyCbs: Array<Function>
  readyErrorCbs: Array<Function>
  errorCbs: Array<Function>

  // implemented by sub-classes
  // 需要子类(HTML5History、HashHistory)实现的方法
  +go: (n: number) => void
  +push: (loc: RawLocation) => void
  +replace: (loc: RawLocation) => void
  +ensureURL: (push?: boolean) => void
  +getCurrentLocation: () => string

  constructor (router: Router, base: ?string) {
    this.router = router
    // 格式化base,保证base是以/开头
    this.base = normalizeBase(base)
    // start with a route object that stands for "nowhere"
    this.current = START // 当前指向的route对象,默认为START;即from
    this.pending = null // 记录将要跳转的route;即to
    this.ready = false
    this.readyCbs = []
    this.readyErrorCbs = []
    this.errorCbs = []
  }
  
  ...

}
  • 可以看到,构造函数中主要干了下面几件事
    • 保存了router实例
    • 规范化了base,确保base是以/开头
    • 初始化了当前路由指向,默认只想START初始路由;在路由跳转时,this.current代表的是from
    • 初始化了路由跳转时的下个路由,默认为null;在路由跳转时,this.pending代表的是to
    • 初始化了一些回调相关的属性
  • START定义在src/utils/route.js
1
2
3
4
5
// src/utils/route.js
// 初始路由
export const START = createRoute(null, {
path: '/',
})
  • History类的实例化过程如下
  • history.png
    • history.png

HTML5History 类

  • 我们再看看HTML5History类,它是继承自History类
  • 位于src/history/html5.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export class HTML5History extends History {
constructor(router: Router, base: ?string) {
// 初始化父类History
super(router, base) // 检测是否需要支持scroll

const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll // 若支持scroll,初始化scroll相关逻辑

if (supportsScroll) {
setupScroll()
} // 获取初始location

const initLocation = getLocation(this.base) // 监听popstate事件

window.addEventListener('popstate', (e) => {
const current = this.current // Avoiding first `popstate` event dispatched in some browsers but first // history route not updated since async guard at the same time. // 某些浏览器,会在打开页面时触发一次popstate

// 此时如果初始路由是异步路由,就会出现`popstate`先触发,初始路由后解析完成,进而导致route未更新
// 所以需要避免
const location = getLocation(this.base)
if (this.current === START && location === initLocation) {
return
} // 路由地址发生变化,则跳转,并在跳转后处理滚动

this.transitionTo(location, (route) => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
}
}
  • 可以看到其是继承于History类,所以在构造函数中调用了父类构造函数(super(router,base))
  • 检查了是否需要支持滚动行为,如果支持,则初始化滚动相关逻辑
  • 监听了popstate事件,并在popstate触发时,调用transitionTo方法实现跳转
  • 注意这里处理了一个异常场景
    • 某些浏览器下,打开页面会触发一次popstate,此时如果路由组件是异步的,就会出现popstate事件触发了,但异步组件还没解析完成,最后导致route没有更新
    • 所以对这种情况做了屏蔽
  • 关于滚动和路由跳转后面有专门章节会讲
  • HTML5History类的实例化过程如下
  • h5history.png
    • h5history.png

HashHistory 类

  • 位于src/history/hash.js
1
2
3
4
5
6
7
8
9
10
11
12
// src/history/hash.js

export class HashHistory extends History {
constructor(router: Router, base: ?string, fallback: boolean) {
// 实例化父类
super(router, base) // check history fallback deeplinking // fallback只有在指明了mode为history,但是浏览器又不支持popstate,用户手动指明了fallback为true时,才为true,其它情况为false // 如果需要回退,则将url换为hash模式(/#开头) // this.base来自父类
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
}
  • 它继承于History,所以也调用了super(router,base)
  • 检查了fallback,看是否需要回退,前面说过,传入的fallback只有在用户设置了history且又不支持pushState并且启用了回退时才为true
  • 所以,此时,需要将history模式的url替换成hash模式,即添加上#,这个逻辑是由checkFallback实现的
  • 如果不是fallback,则直接调用ensureSlash,确保url是以/开头的
  • 我们看下checkFallbackensureSlash实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/history/hash.js

/**
* 检查回退,将url转换为hash模式(添加/#)
*/
function checkFallback(base) {
const location = getLocation(base) // 地址不以/#开头,则添加之
if (!/^\/#/.test(location)) {
window.location.replace(cleanPath(base + '/#' + location)) // 这一步实现了url替换
return true
}
}

/**
* 确保url是以/开头
*/
function ensureSlash(): boolean {
const path = getHash()
if (path.charAt(0) === '/') {
return true
}
replaceHash('/' + path)
return false
}

// 替换hash记录
function replaceHash(path) {
// 支持pushState,则优先使用replaceState
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}
  • 是不是发现HashHistory少了滚动支持和监听hashChange相关逻辑,这是因为hashChange存在一些特殊场景,需要等到mounts后才能监听
    • 这一块的逻辑全放在了setupListeners方法中,setupListeners会在VueRouter调用init时被调用,这个我们在初始化章节再看
  • HashHistory类的实例化过程如下
  • hash-history.png
    • hash-history.png

AbstractHistory 类

  • AbstractHistory是用于非浏览器环境的
  • 位于src/history/abstract.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/history/abstract.js

/**
* 支持所有 JavaScript 运行环境,如 Node.js 服务器端。如果发现没有浏览器的 API,路由会自动强制进入这个模式
*
* @export
* @class AbstractHistory
* @extends {History}
*/
export class AbstractHistory extends History {
constructor(router: Router, base: ?string) {
// 初始化父类
super(router, base)
this.stack = []
this.index = -1
}
}
  • 可以看到它的实例化是最简单的,只初始化了父类,并对indexstack做了初始化
  • 前面说过,非浏览器环境,是没有历史记录栈的,所以使用indexstack来模拟历史记录栈
  • AbstractHistory类的实例化过程如下
  • abstract-history.png
    • abstract-history.png

小结

  • 这一小节我们对三种路由模式做了简单分析,并且还一起看了实现这三种路由模式所需要的History类是如何实例化的
  • 到此,VueRouter的整个实例化过程基本讲完
  • 下面,我们通过一个图来简单总结下VueRouter实例化过程
  • vue-router-instance.png
    • vue-router-instance.png

初始化流程

  • 分析完实例化过程,下面我们来看看初始化是如何进行的,都做了哪些事情

调用 init 时机

  • 在分析安装流程时,我们知道VueRouter注册了一个全局混入,混入了beforeCreate钩子
  • 代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/install.js

// 注册全局混入
Vue.mixin({
  beforeCreate () {
    
    // 判断是否使用了vue-router插件
    if (isDef(this.$options.router)) {
     ...
      this._router = this.$options.router // 保存VueRouter实例,this.$options.router仅存在于Vue根实例上,其它Vue组件不包含此属性,所以下面的初始化,只会执行一次
      // beforeCreate hook被触发时,调用
      this._router.init(this// 初始化VueRouter实例,并传入Vue根实例
    } else {
      ...
    }
    ...
  },
  destroyed () {
   ...
  }
})
  • 我们知道全局混入,会影响后续创建的所有Vue实例,所以beforeCreate首次触发是在Vue根实例实例化的时候new Vue({router})时,
    触发后调用router实例的init方法并传入Vue根实例,完成初始化流程;
  • 由于router仅存在于Vue根实例$options上,所以,整个初始化只会被调用一次
  • 我们接下来看下init方法实现

init 方法

  • VueRouterinit方法位于src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// src/install.js
export default class VueRouter {
// 初始化,app为Vue根实例
init(app: any /* Vue component instance */) {
// 开发环境,确保已经安装VueRouter
process.env.NODE_ENV !== 'production' &&
assert(
install.installed,
`not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
`before creating root instance.`
)

this.apps.push(app) // 保存实例 // set up app destroyed handler // https://github.com/vuejs/vue-router/issues/2639 // 绑定destroyed hook,避免内存泄露
app.$once('hook:destroyed', () => {
// clean out app from this.apps array once destroyed
const index = this.apps.indexOf(app)
if (index > -1) this.apps.splice(index, 1) // ensure we still have a main app or null if no apps // we do not release the router so it can be reused // 需要确保始终有个主应用
if (this.app === app) this.app = this.apps[0] || null
}) // main app previously initialized // return as we don't need to set up new history listener // main app已经存在,则不需要重复初始化history 的事件监听

if (this.app) {
return
}

this.app = app

const history = this.history

if (history instanceof HTML5History) {
// 若是HTML5History类,则直接调用父类的transitionTo方法,跳转到当前location
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
// 若是HashHistory,在调用父类的transitionTo方法后,并传入onComplete、onAbort回调
const setupHashListener = () => {
// 调用HashHistory.setupListeners方法,设置hashchange监听
// 在 route 切换完成之后再设置 hashchange 的监听,
// 修复https://github.com/vuejs/vue-router/issues/725
// 因为如果钩子函数 beforeEnter 是异步的话, beforeEnter 钩子就会被触发两次. 因为在初始化时, 如果此时的 hash 值不是以 / 开头的话就会补上 #/, 这个过程会触发 hashchange 事件, 就会再走一次生命周期钩子, 也就意味着会再次调用 beforeEnter 钩子函数.
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupHashListener, // transitionTo的onComplete回调
setupHashListener // transitionTo的onAbort回调
)
} // 调用父类的listen方法,添加回调; // 回调会在父类的updateRoute方法被调用时触发,重新为app._route赋值 // 由于app._route被定义为响应式,所以app._route发生变化,依赖app._route的组件(route-view组件)都会被重新渲染

history.listen((route) => {
this.apps.forEach((app) => {
app._route = route
})
})
}
}
  • 可以看到,主要做了下面几件事
  • 检查了VueRouter是否已经安装
  • 保存了挂载router实例vue实例
    • VueRouter支持多实例嵌套,所以存在this.apps来保存持有router实例vue实例
  • 注册了一个一次性钩子destroyed,在destroyed时,卸载this.app,避免内存泄露
  • 检查了this.app,避免重复事件监听
  • 根据history类型,调用transitionTo跳转到不同的初始页面
  • 注册updateRoute回调,在router更新时,更新app._route完成页面重新渲染
    • 这个我们在view组件章节会详细讲
  • 我们重点看下transitionTo相关逻辑

setupListeners

  • 上面说到,在初始化时,会根据history类型,调用transitionTo跳转到不同的初始页面
  • 为什么要跳转初始页面?
    • 因为在初始化时,url 可能指向其它页面,此时需要调用getCurrentLocation方法,从当前 url 上解析出路由,然后跳转之
  • 可以看到HTML5History类HashHistory类调用transitionTo方法的参数不太一样
    • 前者只传入了一个参数
    • 后者传入了三个参数
  • 我们看下transitionTo方法的方法签名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

// src/history/base.js
...
// 父类
export class History {
  ...
  
  // 路由跳转
  transitionTo (
    location: RawLocation, // 原始location,一个url或者是一个Location interface(自定义形状,在types/router.d.ts中定义)
    onComplete?: Function// 跳转成功回调
    onAbort?: Function// 跳转失败回调
  ) {
    ...   
  }
}
  • 首个参数是需要解析的地址,第二是跳转成功回调,第三个是跳转失败回调
  • 我们来看下HashHistory 类为何需要传入回调
  • 可以看到传入的成功、失败回调都是setupHashListener函数,setupHashListener函数内部调用了history.setupListeners方法,而这个方法是HashHistory类独有的
  • 打开src/history/hash.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/history/hash.js

  // this is delayed until the app mounts
  // to avoid the hashchange listener being fired too early
  // 修复#725;https://github.com/vuejs/vue-router/issues/725
  // 因为如果钩子函数 beforeEnter 是异步的话, beforeEnter 钩子就会被触发两次. 因为在初始化时, 如果此时的 hash 值不是以 / 开头的话就会补上 #/, 这个过程会触发 hashchange 事件, 就会再走一次生命周期钩子, 也就意味着会再次调用 beforeEnter 钩子函数.
  setupListeners () {
    const router = this.router
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll
    // 若支持scroll,初始化scroll相关逻辑
    if (supportsScroll) {
      setupScroll()
    }
    // 添加事件监听
    window.addEventListener(
      supportsPushState ? 'popstate' : 'hashchange'// 优先使用popstate
      () => {
        const current = this.current
        if (!ensureSlash()) {
          return
        }
        this.transitionTo(getHash(), route => {
          if (supportsScroll) {
            handleScroll(this.router, /* to*/route, /* from*/current, true)
          }
          // 不支持pushState,直接替换记录
          if (!supportsPushState) {
            replaceHash(route.fullPath)
          }
        })
      }
    )
  }
  • 主要逻辑如下
  • setupListeners主要判断了是否需要支持滚动行为,如果支持,则初始化相关逻辑
  • 然后添加url变化事件监听,之前说过实现路由有两种方案pushState+popStatehash+hashChange
  • 可以看到这里即使是HashHistory也会优先使用popstate事件来监听url的变化
  • url发生变化时,会调用transitionTo跳转新路由
  • 可以看到这一块的逻辑和HTML5History类在实例化时处理的逻辑很类似
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/history/html5.js

export class HTML5History extends History {
constructor (router: Router, base: ?string) {
...

// 检测是否需要支持scroll
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll

// 若支持scroll,初始化scroll相关逻辑
if (supportsScroll) {
setupScroll()
}

// 获取初始location
const initLocation = getLocation(this.base)
// 监听popstate事件
window.addEventListener('popstate', e => {
...

// 路由地址发生变化,则跳转,并在跳转后处理滚动
this.transitionTo(location, route => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
}
}
  • 那为何二者处理的时机不同呢?
    • HTML5Histroy在实例化时监听事件
    • HashHistory在初次路由跳转结束后监听事件
  • 这是为了修复#725 问题
    • 如果beforeEnter是异步的话,beforeEnter就会触发两次,这是因为在初始化时,hash 值不是 / 开头的话就会补上#/,这个过程会触发 hashchange 事件,所以会再走一次生命周期钩子,导致再次调用 beforeEnter 钩子函数。所以只能将hashChange事件的监听延迟到初始路由跳转完成后;

小结

  • 针对 init 流程,总结了下面的活动图
  • init.png
    • init.png

总结

  • 文章主要从前端路由的整体设计思路开始,逐步分析前端路由设计的基本原则,理清设计思路
  • 然后介绍了贯穿全局的vue-router几个术语
  • 对术语有个大概印象后,我们又介绍了vue-router的目录结构,其是如何进行分层的
  • 了解目录结构后,我们从安装开始剖析,了解vue-router在安装时都做了哪些事
  • 安装结束,我们又介绍了几个History类之间是怎样的继承关系,其又是如何实例化的
  • 实例化结束,我们最后介绍了初始化的过程

PS

参考