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
和组件
的关系
- 就像下面的伪代码一样
const routeMap=new Map([
['/',首页组件],
['/bar',Bar组件],
['/bar/foo',Foo组件]
])
const routeMap={
'/':首页组件,
'/bar':Bar组件,
'/bar/foo':Foo组件
}
- 有了映射表,我们就知道
url
和组件的映射关系;
- 但是,映射表维护的只是一个关系,并不能帮我们完成,访问
/bar
,返回Bar组件
这样一个流程,所以我们还需要一个匹配器,来帮我们完成从url
到组件
的匹配工作;
- 是不是有了
路由映射表
和匹配器
就可以实现前端路由了呢?
- 我们的
spa
是运行在浏览器环境中的,浏览器是有前进、返回
功能的,需要我们记录访问过的url
;
- 我们知道,要实现这种类似
撤销、恢复
的功能,肯定需要使用到一种数据结构-栈(stack)
;每访问一个url
,将url
,push
到栈中,返回时,执行pop
即可拿到上一次访问的url
- 好在浏览器平台,已经给我们提供了这样的栈,无需我们自己实现,我们只需要去调用它的接口window.history实现功能即可
- 画了个图,描述了下三者协作关系
- 当我们访问某个url时,如
/foo
,匹配器会拿着/foo
去路由映射表中去查找对应的组件,并将组件返回渲染,同时将访问记录推入历史栈中
- 当我们通过前进/后退去访问某个
url
时,会先从历史栈中找到对应url
,然后匹配器拿url
去找组件,并返回渲染;只不过,这是通过前进/后退实现访问的,所以不需要再推入历史栈了
总结
- 要实现一个前端路由,需要三个部分
- 路由映射表
- 一个能表达
url
和组件
关系的映射表,可以使用Map
、对象字面量
来实现
- 匹配器
- 历史记录栈
- 现在不用纠结他们的具体实现,你只需要知道有这三个东西,并且他们大概是如何协作的就可以了;
- 后面我们将一起看看
vue-router
如何利用他们实现前端路由的
术语
- 在分析
vue-router
源码前,我们先了解下vue-router
中常出现的一些概念术语,如果理解起来吃力,可以先跳过,后面遇到,再回来看;
路由规则、配置对象(RouteConfig
)
- 路由的配置项,用来描述路由
- 下图红框里面标出来的都是路由配置对象
- route-config.png
- 因为
vue-router
是支持嵌套路由
的,所以配置对象也是可以相互嵌套的
- 完整的形状如下
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,
caseSensitive?: boolean,
pathToRegexpOptions?: Object
}
路由记录(RouteRecord
)
- 每一条路由规则都会生成一条路由记录;嵌套、别名路由也都会生成一条路由记录;是路由映射表的组成部分
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}
路由对象(Route
)
Route
表示当前激活的路由的状态信息,包含了当前 URL
解析得到的信息,还有 URL
匹配到的路由记录们 (route records
)。
https://router.vuejs.org/zh/api/#路由对象
- 注意,这说明一个
Route
可以关联多个RouteRecord
- 通过
this.$route
访问到的就是Route
对象
- route.png
- 路由对象是不可变 (
immutable
) 的,每次成功的导航后都会产生一个新的对象
位置(Location
)
- 它并不是
window.location
的引用,vue-router
在内部定义了一个Location
,是一个用来描述目标位置的对象;
$router.push/replace
、router-link的to
接收的就是Location对象
https://router.vuejs.org/zh/api/#to
vue-router
内部可以将一个url string
转换成Location对象
,所以确切的说$router.push/replace
、router-link的to
接收的都是一个RawLocation对象
RawLocation对象
是String
和Location
的联合类型
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
渲染一个组件,这个需要被渲染的组件就是路由组件
RouteConfig
中component、components
中定义的vue组件
就是路由组件
- 路由组件的特殊性
- 拥有只在路由组件中生效的守卫(
beforeRouteEnter 、beforeRouteUpdate、beforeRouteLeave
)
- 你是否跟我一样,曾经在组件中调用
beforeRouteEnter
发现没有生效,那是因为这个守卫只能在路由组件
中被调用,在所有非路由组件中都不会被调用,包括路由组件的后代组件;你如果想在路由组件中实现beforeRouteEnter
类似守卫监听效果,可以通过watch $route
来手动判断
- 红框标记出来的是
路由组件
- route-component.png
- 看完上面的术语,你可能还是云里雾里的,没事,后面会详解,现在你只需要有个大概了解即可;
环境
vue-router
版本:v3.1.6
node
版本:v8.17.0
- 分析仓库地址:
https://github.com/BryanAdamss/vue-router-for-analysis
- 划重点
- 注意查看
commit记录
,commit
记录了我整个分析的流程
- 如果你觉得还可以,不要忘记
star
、fork
🤞
目录结构
- 首先我们将
vue-router
仓库clone
下来,看下目录结构大概是什么样的
git clone git@github.com:BryanAdamss/vue-router-for-analysis.git
目录
- 可以看到会有如下目录
- directory.png
- 别看目录多么多,其实我已经给你划了重点
- 我们其实只需要关注下面几个目录或文件
examples
- 这里面存放了官方精心准备的案例
- 不仅告诉你
vue-router
有哪些基础特性,还会告诉你怎么应对一些复杂场景,例如权限控制、动态添加路由等;总之,值得你去一探究竟;
- examples.png
- 另外,我们在分析源码的时候,还可以利用这些例子进行调试
- 在源码你想调试处添加
debugger断点标识
,然后启动例子npm run dev
,打开localhost:8080
即可
src
目录
- 这是
vue-router
源码存放的目录,是我们需要重点关注的地方
- src.png
- 捡几个重要目录先说下
components
目录是存放内置组件router-link
、router-view
的
history
是存放核心history类
的地方
util
中存放的是一些辅助函数
index.js
是vue-router
的入口文件,也是vue-router
类定义的地方
install.js
是安装逻辑所在文件
flow/declarations.js
- 它是
vue-router
的flow类型声明文件,通过它我们能知道vue-router
中几个核心类(对象)是长什么样的
- 它里面,大概长这样
- declarations.png
基础例子
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar },
]
})
new Vue({
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
- 这其实就涉及到三个核心流程:安装、实例化、初始化
- 我们先从这三个流程讲起
安装流程
- 我们知道,只要是
vue plugin
,肯定都会有一个install
方法;
- 上面也提到
vue-router
的入口文件在src/index.js
中,那我们去index.js
中找找install
方法
import { install } from './install'
...
export default class VueRouter {
...
}
...
VueRouter.install = install
VueRouter.version = '__VERSION__'
if (inBrowser && window.Vue) {
window.Vue.use(VueRouter)
}
- 可以看到,开头导入了
install
方法,并将其做为静态方法直接挂载到VueRouter
上,这样,在Vue.use(VueRouter)
时,install
方法就会被调用;
- 可以看到,如果在浏览器环境,并且通过
script标签
的形式引入Vue
时(会在window
上挂载Vue
全局变量),会尝试自动使用VueRouter
- 我们接下来看看
install.js
中是什么
install.js
import View from './components/view'
import Link from './components/link'
export let _Vue
export function install (Vue) {
if (install.installed && _Vue === Vue) return
install.installed = true
_Vue = Vue
const isDef = v => v !== undefined
const registerInstance = (vm, callVal) => {
let i = vm.$options._parentVnode
if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
i(vm, callVal)
}
}
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._routerRoot = this
this._router = this.$options.router
this._router.init(this)
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
const strats = Vue.config.optionMergeStrategies
strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}
避免重复安装
保留Vue
引用,避免将Vue
做为依赖打包
install
方法被调用时,会将Vue
做为参数传入,Vue
会被赋值给事先定义好的_Vue
变量
- 在其它模块中,可以导入这个
_Vue
,这样既能访问到Vue
,又避免了将Vue
做为依赖打包
- 这是一个插件开发实用小技巧
注册了一个全局混入
- 这个混入将影响注册之后所有创建的每个
Vue
实例,也就是后面每个Vue
实例都会执行混入中的代码
- 我们看下混入中的代码
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._routerRoot = this
this._router = this.$options.router
this._router.init(this)
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})
- 它注册了两个生命周期钩子
beforeCreate
和destroyed
;
- 注意
- 在这两个钩子中,
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
- 的确,
if
中的逻辑只执行了一次,并且this
就是指向Vue根实例
- 我们看下
if
中具体干了什么
- 在根实例上保存了
_routerRoot
,用来标识router
挂载的Vue实例
- 在根实例上保存了
VueRouter
实例(router
)
- 对
router
进行了初始化(init
)
- 在根实例上,响应式定义了
_route
属性
- 保证
_route
变化时,router-view
会重新渲染,这个我们后面在router-view
组件中会细讲
- 我们再看下
else
中具体干了啥
- 主要是为每个组件定义
_routerRoot
,采用的是逐层向上的回溯查找方式
- 我们看到还有个
registerInstance
方法,它在beforeCreate
、destroyed
都有被调用,只是参数个数不一样
- 在
beforeCreate
中传入了两个参数,且都是this
即当前vue实例
,而在destroyed
中只传入了一个vue实例
- 我们在讲
router-view
时会细讲,你只需要知道它是用来为router-view
组件关联或解绑路由组件
用的即可
- 传入两个参数即关联,传入一个参数即解绑
添加实例属性、方法
- 在
Vue
原型上注入$router、$route
属性,方便在vue实例
中通过this.$router
、this.$route
快捷访问
注册router-view、router-link全局组件
- 通过
Vue.component
语法注册了router-view
和router-link
两个全局组件
设置路由组件守卫的合并策略
- 设置路由组件的
beforeRouteEnter
、beforeRouteLeave
、beforeRouteUpdate
守卫的合并策略
总结
- 我们用一张图,总结一下安装流程
- install.png
实例化流程
- 看完安装流程,我们紧接着来看下
VueRouter
的实例化过程
- 这一节,重点关注实例化过程,所以我们只看
constructor
中的核心逻辑
VueRouter的构造函数
- 我们打开
src/index.js
看下VueRouter
构造函数
export default class VueRouter {
constructor (options: RouterOptions = {}) {
this.app = null
this.apps = []
this.options = options
this.beforeHooks = []
this.resolveHooks = []
this.afterHooks = []
this.matcher = createMatcher(options.routes || [], this)
let mode = options.mode || 'hash'
this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract'
}
this.mode = mode
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
declare type RouterOptions = {
routes?: Array<RouteConfig>;
mode?: string;
fallback?: boolean;
base?: string;
linkActiveClass?: string;
linkExactActiveClass?: string;
parseQuery?: (query: string) => Object;
stringifyQuery?: (query: Object) => string;
scrollBehavior?: (
to: Route,
from: Route,
savedPosition: ?Position
) => PositionResult | Promise<PositionResult>;
}
RouterOptions
定义了VueRouter
所能接收的所有选项;
- 我们重点关注一下下面的几个选项值
routes
是路由配置规则列表,这个主要用来后续生成路由映射表的;
- 它是一个数组,每一项都是一个路由配置规则(
RouteConfig
),关于RouteConfig
,可以回看术语那一节;
mode
、fallback
是跟路由模式相关的
属性赋初值
- 对一些属性赋予了初值,例如,对接收
全局导航守卫(beforeEach、beforeResolve、afterEach)
的数组做了初始化
创建matcher
- 通过
createMatcher
生成了matcher
- 这个
matcher对象
就是最初聊的匹配器,负责url匹配,它接收了routes
和router实例
;createMatcher
里面不光创建了matcher
,还创建了路由映射表RouteMap
,我们后面细看
确定路由模式
- 三种路由模式我们后面细讲
- 现在只需要知道
VueRouter
是如何确定路由模式的
VueRouter
会根据options.mode
、options.fallback
、supportsPushState
、inBrowser
来确定最终的路由模式
- 先确定
fallback
,fallback
只有在用户设置了mode:history
并且当前环境不支持pushState
且用户主动声明了需要回退,此时fallback
才为true
- 当
fallback为true
时会使用hash
模式;
- 如果最后发现处于非浏览器环境,则会强制使用
abstract
模式
- route-mode.png
inBrowser
和supportsPushState
的实现都很简单
export const inBrowser = typeof window !== 'undefined'
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'
})()
根据路由模式生成不同的History实例
- 根据上一步的路由模式生成不同的
History实例
,关于路由模式、History实例
后面再讲
小结
VueRouter
的构造函数主要干了下面几件事
- 接收一个
RouterOptions
- 然后对一些属性赋了初值
- 生成了
matcher
匹配器
- 确定路由模式
- 根据不同路由模式生成不同
History实例
创建匹配器
- 我们来细看一下
createMatcher
里面的实现
createMatcher
的实现在src/create-matcher.js
中
...
export type Matcher = {
match: (raw: RawLocation, current?: Route, redirectedFrom?: Location) => Route;
addRoutes: (routes: Array<RouteConfig>) => void;
};
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
function addRoutes (routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {
...
}
return {
match,
addRoutes
}
}
- 可以看到,
createMatcher
方法接收一个路由配置规则列表和router实例
,返回一个Matcher
对象
Matcher
对象包含一个用于匹配的match
方法和一个动态添加路由的addRoutes
方法;
- 而这两个方法都声明在
createMatcher
内部,由于闭包特性,它能访问到createMatcher
作用域的所有变量
小结
- 我们总结下
createMatcher
的逻辑
- create-matcher.png
- 我们可以看到
createMatcher
和addRoutes
方法中都调用了createRouteMap
方法,二者只是传递的参数不同,从方法名看,这个方法肯定和路由表RouteMap
有关
- 我们接下来看看
createRouteMap
的实现
createRouteMap
createRouteMap
方法位于src/create-route-map.js
中
export function createRouteMap (
routes: Array<RouteConfig>,
oldPathList?: Array<string>,
oldPathMap?: Dictionary<RouteRecord>,
oldNameMap?: Dictionary<RouteRecord>
): {
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>
} {
const pathList: Array<string> = oldPathList || []
const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)
routes.forEach(route => {
addRouteRecord(pathList, pathMap, nameMap, route)
})
for (let i = 0, l = pathList.length; i < l; i++) {
if (pathList[i] === '*') {
pathList.push(pathList.splice(i, 1)[0])
l--
i--
}
}
if (process.env.NODE_ENV === 'development') {
const found = pathList
.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
返回一个对象,它包含pathList
、pathMap
和nameMap
pathList
中存储了routes
中的所有path
pathMap
维护的是path
和路由记录RouteRecord
的映射
nameMap
维护的是name
和路由记录RouteRecord
的映射
- 后两者,都是为了快速找到对应的
路由记录
- 可以看下使用下面的
routes
调用createRouteMap
会返回什么
[
{ path: '/', component: Home },
{ path: '/foo', component: Foo ,
children:[
{
path:'child',
component:FooChild
}
]},
{ path: '/bar/:dynamic', component: Bar },
]
- route-map-obj.png
- 由于没有命名路由,所以
nameMap
为空
pathList
存储了所有path
,有个为空,其实是/
,在normalizePath
时被删除了
pathMap
记录了每个path
和对应RouteRecord
的映射关系
小结
VueRouter
的路由映射表由三部分组成:pathList
、pathMap
、nameMap
;后两者是用来快速查找的
createRouteMap
的逻辑
- 先判断路由相关映射表是否已经存在,若存在则使用,否则新建;
- 这就实现了
createRouteMap
创建/新增的双重功能
- 然后遍历
routes
,依次为每个route
调用addRouteRecord
生成一个RouteRecord
并更新pathList
、pathMap
和nameMap
- 由于
pathList
在后续逻辑会用来遍历匹配,为了性能,所以需要将path:*
放置到pathList
的最后
- 最后检查非嵌套路由的
path
是否是以/
或者*
开头
- 用图总结如下
- create-route-map-sequence.png
- 接下来,我们看看路由记录是如何生成的
addRouteRecord
- 这个方法主要是创建路由记录并更新路由映射表
- 位于
src/create-route-map.js
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') {
assert(path != null, `"path" is required in a route configuration.`)
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 || {}
const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)
if (typeof route.caseSensitive === 'boolean') {
pathToRegexpOptions.sensitive = route.caseSensitive
}
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}
if (route.children) {
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
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}
if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
}
if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && 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.`
)
continue
}
const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/'
)
}
}
if (name) {
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}" }`
)
}
}
}
- 看下逻辑
- 检查了路由规则中的
path
和component
- 生成
path-to-regexp
的选项pathToRegexpOptions
- 格式化
path
,如果是嵌套路由,则会追加上父路由的path
- 生成路由记录
- 处理嵌套路由,递归生成子路由记录
- 更新
pathList
、pathMap
- 处理别名路由,生成别名路由记录
- 处理命名路由,更新
nameMap
- 我们来看下几个核心逻辑
生成路由记录
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}
- 路由记录有个
regex
字段,它是一个增强的正则表达式,它是实现动态路由匹配的关键
regex
是通过compileRouteRegex
方法返回的,它里面调用了path-to-regexp
import Regexp from 'path-to-regexp'
...
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 => {
warn(
!keys[key.name],
`Duplicate param keys in route with path: "${path}"`
)
keys[key.name] = true
})
}
return regex
}
- 我们看下
path-to-regexp
是如何使用的
- 官网是:www.npmjs.com/package/pat…
- Regexp接收三个参数
path,keys,options
;path
为需要转换为正则的路径,keys
,是用来接收在path
中找到的key
,可以传入,也可以直接使用返回值上的keys
属性,options
为选项
const pathToRegexp= require('path-to-regexp')
const regexp = pathToRegexp("/foo/:bar");
console.log(regexp.keys)
- 通过下面的例子,就可以知道动态路由获取参数值是如何实现的
const pathToRegexp= require('path-to-regexp')
const regexp = pathToRegexp("/foo/:bar");
console.log(regexp.keys)
const m = '/foo/test'.match(regexp)
console.log('key:',regexp.keys[0].name,',value:',m[1])
生成嵌套路由记录
- 我们知道
vue-router
是支持嵌套路由的,我们来看看是如何生成嵌套路由记录的
if (route.children) {
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
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}
- 首先针对
#629
问题做出了警告提示
#629
问题主要是 当一个路由是 命名路由 && 未使用重定向 && 子路由配置对象path为''或/
时,使用父路由的name
跳转时,子路由将不会被渲染
- 然后遍历子路由规则列表,生成子路由记录
- 这里面还处理了别名路由的子路由情况
- 遍历时如果发现父路由被标记为别名路由,则子路由的
path
前面需要加上父路由的path
,然后再生成记录
- 我们可以看下下面的嵌套路由,生成的路由映射表长什么样
[
{
path: '/parent',
component: Parent,
children: [
{ path: 'foo', component: Foo },
]
}
]
- nested-route.png
- 可以看到,子路由的
path
会前追加上父路由的path
生成别名路由记录
VueRouter
支持给路由设置别名;/a
的别名是 /b
,意味着,当用户访问 /b
时,URL
会保持为 /b
,但是路由匹配则为 /a
,就像用户访问 /a
一样
- 我们来看看椒如何生成别名路由记录的
if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && 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.`
)
continue
}
const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/'
)
}
}
- 别名是支持单别名和多别名的,即
route.alias
支持传入/foo
或['/foo','/bar']
,所以先对这两种情况做了归一处理,统一处理成数组
- 然后遍历这个数组,先检查别名和
path
是否重复,然后单独为别名路由生成一份配置传入,最后调用addRouteRecord
生成别名路由记录
- 注意此处还通过
matchAs
处理了别名路由生成子路由的场景,主要通过设置matchAs
为record.path || '/'
,然后在生成子路由记录时,会根据matchAs
生成别名路由记录的子记录,具体可看上面的嵌套路由章节
- 我们看看,别名路由生成的的路由映射表长什么样
[
{ 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
- 可以看到为别名路由和别名路由的子路由都单独生成了一条路由记录
小结
- 路由记录是
路由映射表
的重要组成部分
- 路由记录中的
regex
是处理动态路由传参的关键字段,主要是借助path-to-regexp
实现的
- 生成路由记录主要考虑了下面几种路由记录的生成
- 生成路由记录的整个流程如下图所示
- add-route-record.png
- 至此我们已经分析完了
VueRouter
实例化中创建匹配器(生成路由映射表)相关逻辑
- 下面我们将看看根据路由模式生成
History实例
的相关逻辑
路由模式
- 前端路由一个很重要的特性是要实现无刷新切换页面
- 要实现这一点,有两种方案
- 一种
hash+hashChange
,另一种利用History API
的pushState+popState
- 前者主要利用
hash
改变时页面不会刷新并会触发hashChange
这个特性来实现前端路由
- 后者充分利用了
HTML5 History API
的pushState
方法和popState
事件来实现前端路由
- 二者比较
- 前者
- 兼容性好,
hashChange
支持到IE8
url
中会携带/#/
,不美观
- 不需要服务端改造
- 后者
- 兼容到IE10
url
跟正常url
一样
- 由于其
url
跟正常url
一样,所以在刷新时,会以此url
为链接请求服务端页面,而服务端是没有这个页面的,会404,因此需要服务端配合将所有请求重定向到首页,将整个路由的控制交给前端路由
VueRouter
支持三种路由模式,分别为hash
、history
、abstract
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
HTML5History
、HashHistory
、AbstractHistory
三者都是继承于基础类History
;
- 三者不光能访问
History类
的所有属性和方法,他们还都实现了基础类中声明的需要子类实现的5个接口(go、push、replace、ensureURL、getCurrentLocation
)
- 由于
HashHistory
监听hashChange
的特殊性,所以会单独多一个setupListeners
方法
AbstractHistory
由于需要在非浏览器环境使用,没有历史记录栈,所以只能通过index、stack
来模拟
- 前面我们分析
VueRouter
实例化过程时,知道VueRouter
会在确定完路由模式后,会实例化不同的History实例
- 那我们来看看不同
History
的实例化过程
History类
- 它是父类(基类),其它类都是继承它的
- 代码位于
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>
+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
this.base = normalizeBase(base)
this.current = START
this.pending = null
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
中
export const START = createRoute(null, {
path: '/'
})
History类
的实例化过程如下
- history.png
HTML5History类
- 我们再看看
HTML5History类
,它是继承自History类
- 位于
src/history/html5.js
export class HTML5History extends History {
constructor (router: Router, base: ?string) {
super(router, base)
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
const initLocation = getLocation(this.base)
window.addEventListener('popstate', e => {
const current = this.current
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
HashHistory类
export class HashHistory extends History {
constructor (router: Router, base: ?string, fallback: boolean) {
super(router, base)
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
}
- 它继承于
History
,所以也调用了super(router,base)
- 检查了
fallback
,看是否需要回退,前面说过,传入的fallback
只有在用户设置了history
且又不支持pushState
并且启用了回退时才为true
- 所以,此时,需要将
history
模式的url
替换成hash
模式,即添加上#
,这个逻辑是由checkFallback
实现的
- 如果不是
fallback
,则直接调用ensureSlash
,确保url
是以/
开头的
- 我们看下
checkFallback
、ensureSlash
实现
function checkFallback (base) {
const location = getLocation(base)
if (!/^\/#/.test(location)) {
window.location.replace(cleanPath(base + '/#' + location))
return true
}
}
function ensureSlash (): boolean {
const path = getHash()
if (path.charAt(0) === '/') {
return true
}
replaceHash('/' + path)
return false
}
function replaceHash (path) {
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}
- 是不是发现
HashHistory
少了滚动支持和监听hashChange
相关逻辑,这是因为hashChange
存在一些特殊场景,需要等到mounts
后才能监听
- 这一块的逻辑全放在了
setupListeners
方法中,setupListeners
会在VueRouter
调用init
时被调用,这个我们在初始化章节再看
HashHistory类
的实例化过程如下
- hash-history.png
AbstractHistory类
AbstractHistory
是用于非浏览器环境的
- 位于
src/history/abstract.js
export class AbstractHistory extends History {
constructor (router: Router, base: ?string) {
super(router, base)
this.stack = []
this.index = -1
}
}
- 可以看到它的实例化是最简单的,只初始化了父类,并对
index
、stack
做了初始化
- 前面说过,非浏览器环境,是没有历史记录栈的,所以使用
index
、stack
来模拟历史记录栈
AbstractHistory类
的实例化过程如下
- abstract-history.png
小结
- 这一小节我们对三种路由模式做了简单分析,并且还一起看了实现这三种路由模式所需要的
History
类是如何实例化的
- 到此,
VueRouter
的整个实例化过程基本讲完
- 下面,我们通过一个图来简单总结下
VueRouter
实例化过程
- vue-router-instance.png
初始化流程
- 分析完实例化过程,下面我们来看看初始化是如何进行的,都做了哪些事情
调用init时机
- 在分析安装流程时,我们知道
VueRouter
注册了一个全局混入,混入了beforeCreate
钩子
- 代码如下
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
...
this._router = this.$options.router
this._router.init(this)
} else {
...
}
...
},
destroyed () {
...
}
})
- 我们知道全局混入,会影响后续创建的所有
Vue实例
,所以beforeCreate
首次触发是在Vue根实例实例化的时候
即new Vue({router})
时,
触发后调用router
实例的init
方法并传入Vue根实例
,完成初始化流程;
- 由于
router
仅存在于Vue根实例
的$options
上,所以,整个初始化只会被调用一次
- 我们接下来看下
init
方法实现
init方法
VueRouter
的init
方法位于src/index.js
export default class VueRouter {
init (app: any ) {
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)
app.$once('hook:destroyed', () => {
const index = this.apps.indexOf(app)
if (index > -1) this.apps.splice(index, 1)
if (this.app === app) this.app = this.apps[0] || null
})
if (this.app) {
return
}
this.app = app
const history = this.history
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
const setupHashListener = () => {
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
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
完成页面重新渲染
- 我们重点看下
transitionTo
相关逻辑
setupListeners
- 上面说到,在初始化时,会根据
history类型
,调用transitionTo
跳转到不同的初始页面
- 为什么要跳转初始页面?
- 因为在初始化时,url可能指向其它页面,此时需要调用
getCurrentLocation
方法,从当前url上解析出路由,然后跳转之
- 可以看到
HTML5History类
和HashHistory类
调用transitionTo
方法的参数不太一样
- 我们看下
transitionTo
方法的方法签名
...
export class History {
...
transitionTo (
location: RawLocation,
onComplete?: Function,
onAbort?: Function
) {
...
}
}
- 首个参数是需要解析的地址,第二是跳转成功回调,第三个是跳转失败回调
- 我们来看下
HashHistory
类为何需要传入回调
- 可以看到传入的成功、失败回调都是
setupHashListener
函数,setupHashListener
函数内部调用了history.setupListeners
方法,而这个方法是HashHistory类
独有的
- 打开
src/history/hash.js
setupListeners () {
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
window.addEventListener(
supportsPushState ? 'popstate' : 'hashchange',
() => {
const current = this.current
if (!ensureSlash()) {
return
}
this.transitionTo(getHash(), route => {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
)
}
- 主要逻辑如下
setupListeners
主要判断了是否需要支持滚动行为,如果支持,则初始化相关逻辑
- 然后添加
url变化
事件监听,之前说过实现路由有两种方案pushState+popState
、hash+hashChange
- 可以看到这里即使是
HashHistory
也会优先使用popstate
事件来监听url
的变化
- 当
url
发生变化时,会调用transitionTo
跳转新路由
- 可以看到这一块的逻辑和
HTML5History类
在实例化时处理的逻辑很类似
export class HTML5History extends History {
constructor (router: Router, base: ?string) {
...
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
const initLocation = getLocation(this.base)
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
总结
- 文章主要从前端路由的整体设计思路开始,逐步分析前端路由设计的基本原则,理清设计思路
- 然后介绍了贯穿全局的
vue-router
几个术语
- 对术语有个大概印象后,我们又介绍了
vue-router
的目录结构,其是如何进行分层的
- 了解目录结构后,我们从安装开始剖析,了解
vue-router
在安装时都做了哪些事
- 安装结束,我们又介绍了几个
History类
之间是怎样的继承关系,其又是如何实例化的
- 实例化结束,我们最后介绍了初始化的过程
PS
参考