试想一下在调用 vue-router 的 router.push、router.replace 等 API 从而实现从页面 A 跳到页面 B 时,这个过程中,最直观的感受是什么?地址发生变化,随后页面内容发生变化。所以我们要做的是,当地址发生变化时,保证页面内容也随之变化。实现途径有:
- 如果我们把地址发生变化当做事件 A,页面发生变化当做事件 B,那么我们可以监听 A,每当 A 发生时,B 自动随之发生
- 把 A 和 B 视作一个不可分割的整体事件 C,每当 C 发生时,意味着 A 和 B 必然先后发生,这样每次只要保证 C 发生即可
我们先来探讨一下 1 的可能性:如果刚在一个 DOM 事件 API 与 A 对应,那事情立马得到解决,因为这样我们只用监听那个事件,在对应的回调里让事件 B 发生就好了。刚好 DOM 有个 hashchange 事件可以监听 location.hash 的变化,但是除此之外再没有其他 DOM 事件与页面地址的变化对应了。固然我们可以通过将 hashchange 就当做事件 A 来做出途径 1 的一种实现,但是这样一来便只能使用 vue-router 中所谓的 hash 模式的路由了,如果你至少稍微有点强迫症的话,那你永远都要忍受浏览器地址栏里那多出来的让人抓狂的 '#',因为即便是 HTML5 的 History API 也没有为你提供一个类似 hashchange 的 'locationchange' 的事件来让充当事件 A。所以途径 1 最直观的一个缺陷就是除了让地址栏多出一个 '#' 外,没有其他选项。
显然途径 2 就没有这种限制,而 vue-router 正是使用的途径 2,不管是其 hash 模式还是 history 模式。 在其提供的 API 中router.push、router.replace 等接口的调用就会使得 C 发生,后面我们将会看到与 C 对应的方法实际是 history.transitionTo(此 history 非 window.history,是 vue-router 内部实现的一个类)。两种模式都能做到使得页面地址发生变化而不会造成当前页面刷新,区别在于前者通过改变 window.location.hash 而后者通过 window.history 的 pushState 和 replaceState API。但这里并不打算进一步介绍 DOM History API,因为这里我们只用知道它的上述两个方法可以改变页面地址而不引起页面刷新即可,本篇讨论时默认路由模式为 history mode。本篇分 vue-router 的初始化、完整的导航流程以及 guard 的实现原理两节;前者又包括 vue-router 源码中的一些概念、VueRouter.use(Vue) 所做的事、new VueRouter(routerConfig) 所做的事三小节。
vue-router 的初始化
如上所言,从直观上来看,vue-router 的导航流程就是:页面地址变化 -> 页面内容变化。但是这中间的具体细则究竟如何我们却不得而知,本节的内容将会提供答案。先来了解一下源码中一些关键的概念。
vue-router 源码中的一些概念
说是概念,实际上体现在源码中就是对类型或接口的定义,我觉得就提供的明晰性而言,没有什么能比得上编程语言中的接口了,符合接口的就是那个东西,不符合的就不是,而使用自然语言描述的概念就经常不那么容易为人所理解。这应该是因为相较于自然语言,编程语言语法更严格、语义更精确的缘故。
上面所说的那些类型或接口的定义在源文件 /type/router.d.ts 中:
// 字典类型的定义,实质就是由键值对组成的对象
type Dictionary < T > = { [key: string]: T }
// 当前页面的 location,或为字符串,或以对象表示
type RawLocation = string | Location
// 路由 gurad 包含三个参数的函数,前两个参数为 route,最后一个参数为一个函数,函数的参数如下
type NavigationGuard < V extends Vue = Vue > = (
to: Route,
from: Route,
next: (to?: RawLocation | false | ((vm: V) => any) | void) => void
) => any
// 以对象的形式描述当前页面地址除 window.location.origin 以外的那部分
interface ILocation {
name?: string
path?: string
hash?: string
query?: Dictionary<string | (string | null)[] | null | undefined>
params?: Dictionary<string>
append?: boolean
replace?: boolean
}
// 也可以称为 RouteOption,即每个 route 对应的配置(选项)
interface RouteConfig {
path: string
name?: string
component?: Component
components?: Dictionary<Component>
redirect?: RedirectOption
alias?: string | string[]
children?: RouteConfig[]
meta?: any
beforeEnter?: NavigationGuard
props?: boolean | Object | RoutePropsFunction
caseSensitive?: boolean
pathToRegexpOptions?: PathToRegexpOptions
}
// 与当前页面 rawLocation 对应且由之生成的东西,称之为 route
interface IRoute {
path: string
name?: string | null
hash: string
query: Dictionary<string | (string | null)[]>
params: Dictionary<string>
fullPath: string
matched: RouteRecord[]
redirectedFrom?: string
meta?: any
}
// 与 route 相比包含更多的信息
interface IRouteRecord {
path: string
regex: RegExp
components: Dictionary<Component>
instances: Dictionary<Vue>
name?: string
parent?: RouteRecord
redirect?: RedirectOption
matchAs?: string
meta: any
beforeEnter?: (
route: Route,
redirect: (location: RawLocation) => void,
next: () => void
) => any
props:
| boolean
| Object
| RoutePropsFunction
| Dictionary<boolean | Object | RoutePropsFunction>
}
// history 相关的 Base 和 HTML5History,后面补充
// 作为 API 对外提供的 Router,其内部的接口基本就是我们能使用的 API
基本的类型和接口的定义和意义如上所声明和注释。
VueRouter.use(Vue) 所做的事
源码如下:
let _Vue
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
// 用于 router-view 组件
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
// use the same hook merging strategy for route hooks
strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}
- 全局混入
beforeCreate和destroyed两个 hook,在destroyedhook 中每个实例执行的都是registerInstance(this),但在beforeCreate中,根 vm 和非根 vm 的表现不一致:- 根 vm:
- 添加
_routerRoot、_router两个属性,前者为自身,后者为 options 里传入的 router 实例 - 调用
this._router.init(this) - 添加
_route响应式属性,其值为_router.history.current,所有的Route-View组件在 render 时都会访问这个属性
- 添加
- 非根 vm:添加
_routerRoot属性,指向根 vm
- 根 vm:
- 执行
registerInstance(this, this) - 往
Vue.prototype上添加$router、$route两个访问器属性,返回的值分别为this._routerRoot._router和this._routerRoot._route - 全局注册
RouterView和Router-link两个组件 - 往
Vue.config.optionMergeStrategies里增加beforeRouteEnter、beforeRouteLeave、beforeRouteUpdate三个 hook 的合并策略,值与created相同。这意味着组件的 options 里可以新增那三个 hook 选项,但这些 hook 只在router-view对应的组件上生效
其中,registerInstance 用于 RouterView 组件。RouterView 组件的细则也可以暂且不管,只需要了解到其 render 时访问了响应式属性 $route 从而将之相关的 dep 与 Dep.target 发生关联 ,然后在 $route 发生变化时,Dep.target 会触发对应组件的 re-render 从而实现页面地址变化导致页面内容变化即可。
new VueRouter(option) 做的事:
VueRouter 的构造函数内容如下:
class VueRouter {
constructor(options = {}) {
this.app = null
this.apps = []
this.options = options
this.beforeHooks = []
this.resolveHooks = []
this.afterHooks = []
this.matcher = createMatcher(options.routes || [], this)
this.mode = 'history'
this.history = new HTML5History(this, options.base || '/')
}
}
主要是做一些初始化的工作,其中最重要的是 this.matcher 和 this.history,前者是主要是由 options.routes 包含两个方法,都是生成与路由相关的数据用于后续路由变化时跳转,而后者则是跳转动作的执行者。
createMatcher
function createMatcher(routes, router) {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
function addRoutes(routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
function match(raw, currentRoute, redirectedFrom) {
// 通过 raw 找到匹配的 routeRecord,然后生成一个新的 route 返回
return _createRoute(record, location, redirectedFrom)
}
return {
addRoutes,
match
}
}
function createRouteMap(routes, pathList, pathMap, nameMap) {
// ...
// 其中 pathList 是一个 routes 中包含的所有的 path 的列表,通配符 * 在最后
// pathMap 是由 routes 中所有 path 和其对应的 routeRecord 的 key-value 对
// nameMap 是由 routes 中所有 name 和其对应的 routeRecord 的 key-value 对
// 即 nameMap 使得我们可以通过 route 的 name 来进行 route 跳转
// 以上三个值一直存活于 addRoutes 和 match 闭包中,而且会被 addRoutes 更新
return {
pathList,
pathMap,
nameMap
}
}
// 创建新 route
function createRoute(record, location, redirectFrom, router) {
const stringifyQuery = router && router.options.stringifyQuery
let query = location.query || {}
query = clone(query)
const route = {
name: location.name || (record && record.name),
meta: (record && record.meta) || {},
path: location.path || '/',
hash: location.hash || '',
query: query,
params: location.params || {},
fullPath: getFullPath(location, stringifyQuery),
// formatMatch 将 record 追本溯源,找出每个匹配的 record 的父 record
// 最终 matched 是一个包含了从祖先 router-view 到叶子 route-view 所对应的 routeRecord 的列表
matched: record ? formatMatch(record) : []
}
return Object.freeze(route)
}
route 的创建过程:先由 routeConfig 生成 route record,然后每次 location 发生变化时又由对应的 route record 和 location 生成 route。
HTML5History
class HTML5History extends History {
constructor(router, base) {
super(router, base)
// scroll 相关
const expectScorll = router.options.scrollBehavior
const supportScroll = supportsPushState && expectScroll
if(supportScroll) {
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(supportScroll) {
handleScroll(router, route, current, true)
}
})
})
}
// 下面四个方法是需要继承 History 类的子类实现
go() {}
push() {}
replace() {}
ensureURL() {}
getCurrentLocation() {}
}
class History {
constructor(router, base) {
this.router = router
// 路径的前缀 如果 base 为 '/' 返回 '',否则保证 base 以 '/' 开头,不以 '/' 结尾
this.base = normalizeBase(base)
// 当前匹配的 route,初始化 current 为 createRoute(null, { path: '/' })
this.current = STAT
// 处于 pending 状态的 route
this.pending = null
this.ready = false
this.readyCbs = []
this.readyErrorCbs = []
this.errorCbs = []
}
listen(cb) {
this.cb = cb
}
updateRoute(route) {
const prev = this.current
this.current = route
this.cb && this.cb(route)
this.router.afterHooks.forEach(hook => hook && hook(route, prev))
}
// 其他方法
}
以上就是创建 new VueRouter(routerOption) 的过程:
- 根据传入的
options.routes来生成所有与创建 route 相关的数据pathList、routeRecord等 - 创建 history 实例提供路由跳转的方法。后续 history 根据变化后的 location 生成的新的 route 更新
history.current,从而更新页面内容,history 创建的工程也包括一些初始化的工作:- 数据初始化
- setupScroll(如果支持的话)
- 监听页面的 popstate 事件,发生时
this.transitionTo(route)
router 自身相关的初始化工作那是上面那些了,剩下的就是 new Vue({ router, ...otherOptions }) 中 router 与 vm 实例结合时的初始化了。
完整的导航流程以及 guard 原理
上一节说到 Vue.use(VueRouter) 时,vm 实例会被混入 beforeCreate、destroyed 两个 hook,而根 vm 的 beforeCreate hook 与非根 vm 的内容又不一样,主要体现在 this._router.init(this),现在就来看下 VueRouter 的 init 方法:
class VueRouter {
constructor(options){}
init(app) {
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
}
const history = this.history
// 创建根 vm 后,根据当前的 location 「跳转」去该去的地方
history.transitionTo(history.getCurrentLocation())
// 每次 route 变化,都更新 app 里的 _route
// 因为它是响应式的,所以订阅了它的 vm 会得到通知从而 re-render
history.listen(route => this.apps.forEach(app => app._route = route))
}
getCurrentLocation() {
return getLocation(this.base)
}
}
内容其实也很简单:
- 初始化
router.apps、router.app,添加一个在根 vm destroy 时执行一次的事件 - transitionTo 当前的 location
- 将更新 apps 里所有
app._route的回调赋给history.cb,这个 cb 将会在 history.updateRoute 时执行
可以看到在 init 中会执行一次 history.transitionTo(history.getCurrentLocation()),这也是第一次导航,即每次进入应用或刷新浏览器页面时,在根 vm 的 beforeCreate 中 history 会根据当前的 url 匹配出对应的路由,然后跳转之(渲染与路由对应的组件)。在使用 Vue-router 时,页面的跳转由 router.push、router.replace、router.go 完成,这些方法最终都会导致 history.transitionTo 的调用,而 history.transitionTo 即标志着导航动作的开始,即所谓「触发导航」。此外 Vue-router 支持全局、route、component 级别的 guard,那些 guard 就是从导航触发开始作用的。如 Vue-router 官网所言,一个完整的导航流程如下:
- 导航被触发。
- 新的 location
- 在
pathMap或nameMap里找出与新 location 对应的 route record,并由之生成新的 route - 对比新旧 record 这的 match,找出前后状态发生变化的 vm,状态包括:updated、deactivated、activated
- 在 deactivated 组件里调用
beforeRouteLeave守卫。 - 调用全局的
beforeEach守卫。 - 在 updated 的组件里调用
beforeRouteUpdate守卫 (2.2+)。 - 在路由配置里调用
beforeEnter。 - 解析异步路由组件。
- 在 activated 的组件里调用
beforeRouteEnter。 - 调用全局的
beforeResolve守卫 (2.5+)。 - 导航被确认。
- 调用全局的
afterEach钩子。 - 触发 DOM 更新。
- 调用
beforeRouteEnter守卫中传给next的回调函数,创建好的组件实例会作为回调函数的参数传入。
下面我们通过 History 中相关的源码来看下其具体实现:
class History {
// ...
transitionTo(location, completeCb, abortCb) {
// location 可以是一个字符串,也可以是一个 vue-router 认可的 location 对象
const route = this.router.match(location)
onComplete = () => {
this.updateRoute(route)
completeCb && completeCb()
this.ensureURL()
if(!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => cb(route))
}
}
onAbort = err => {
if(abortCb) {
abortCb(err)
}
if(!this.ready) {
this.ready = true
this.errorCbs.forEach(cb => cb(err))
}
}
// 1.触发导航
this.comfirmTransiton(route, onComplete, onAbort)
}
confirmTransition(route, onComplete, onAbort) {
const current = this.current
const abort = err => {
// 错误处理...
}
// resolveQueue 的作用是根据新旧 route 的 matched 解析出 updated、deactivated、activated 的
// routeRecord
const { updated, deactivated, activated } = resolveQueue(current.matched, route.matched)
const queue = [
// 2.从 deactivated 的 route component 里解析出他们的 beforeRouteLeave hook
...extractLeaveGuards(deactivated),
// 3.全局的 beforeEach guard
...this.router.beforeHooks,
// 4.从 updated 的route component 里解析出 beforeRouteUpdate hook
...extractUpdateHooks(updated),
// 5.route 级的 beforeEnter guard
...activated.map(m => m.beforeEnter),
// 6.解析异步 route component
...resolveAsyncComponents(activated)
]
// 依据下面 runQueue 的逻辑,调用 next 即执行当前 guard 的后一个 guard
// 显然,如果某一个 guard 中未执行 next 的话,那之后的导航流程就会中断
// 反之,如果执行多次的话,就可能会导致原本需要在某一个 guard 中断导航流程、或重定向
// 但是最终还是将原来的整个导航流程走完
const iterator = (guard, next) => {
if(this.pending !== route) {
return abort()
}
try {
// route 即 to, current 即 from,第三个参数即 next
guard(route, current, to => {
if(to === false || isError(to)) {
this.ensureURL(true)
abort(to)
} else if(
typeof to === 'string' ||
typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string')
) {
// redirect
abort()
if(typeof to === 'object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
} else {
// 未 abort 也未重定向
next(to)
}
})
} catch(e) {
abort(e)
}
}
const beforeCb = () => {
const postEnterCbs = []
const isValid = () => this.current === route
// 7.解析 activated 的 route component 里的 beforeRouteEnter hook
// 同时将对应 route compoennt 的 beforeRouteEnter 里的第三个参数 cb
// 包装成一个新的函数 push 到 postEnterCbs,新函数里会调用 cb(vm)
// 具体做法在 poll 函数里
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
// 8.route component 级的 beforeRouteEnter 后面紧接着全局的 beforeResolve guard
const queue = enterGuards.concat(this.router.resolveHooks)
const postCb = () => {
if(this.pending !== route) {
return abort()
}
//
this.pending = null
// 9.导航确认,开始执行 onComlete 和回调
// 10.onComplete 里会执行 this.updateRoute,其内部会调用全局 afterEach guard
// 11.updateRoute 时,this.current 得到更新,其以 vm.$route 的响应式的形式被对应
// 的 route component 的 render-watcher 所 subscribe,所以它的更新势必引起对应的
// component re-render 从而触发 DOM 的更新
onComplete(route)
if(this.router.app) {
// 12.在 mounted 之后调用一次执行 beforeRouteEnter 的第三个参数
// 使用 $nextTick 保证回调在 vm mounted 之后执行
this.router.app.$nextTick(() => postEnterCbs.forEach(cb => cb()))
}
}
runQueue(queue, iterator, postCb)
}
// 这步的作用是,先在对 queue 的遍历时执行 2,3,4,5,6
// 遍历结束后,在 beforeCb 里又去执行 7,8,9,10,11,12
runQueue(queue, iterator, beforeCb)
}
}
// runQueue 的逻辑就是从头开始遍历 queue
// 1.当前遍历下标是否达是 queue 的最后一个元素
// 2.是则调用 cb
// 3.否则看当前元素是否为空,不为空则默认将之看作一个有效的 guard,并连同内容为「遍历下一个元素」的函数传给 fn
// 4.为空则遍历下一个元素
function runQueue(queue, fn, cb) {
const step = index => {
if(index >= queue.length) {
cb()
} else {
if(queue[index]) {
fn(queue[index], () => {
step(index + 1)
})
} else {
step(index + 1)
}
}
}
step(0)
}