vue-router.js源码学习 —— 导航守卫

这篇文章接着上一篇vue-router.js源码学习 —— 路由匹配,也是 Vue Router 的核心部分,我们会重点来看一下导航解析的整个过程的实现,最后来过一下hashhistory模式下是如何实现路由跳转的。

Vue Router 提供了 3 种路由跳转模式:hashhistoryabstract。实现的代码位于文件夹history中,每种模式单独用一个类来实现,但都继承于History基类。abstract是给服务端渲染时使用的,从history/abstract.js文件中可以看到,AbstractHistory类内部自己维护了一个路由对象的栈,来模拟导航。我们今天主要看hashhistory两种路由模式的实现。

History类

打开history/base.js可以看到,History类的上方给出了子类需要实现的方法,有这些:

1
2
3
4
5
6
7
8
9
10
11
// implemented by sub-classes
+go: (n: number) => void
+push: (loc: RawLocation, onComplete?: Function, onAbort?: Function) => void
+replace: (
loc: RawLocation,
onComplete?: Function,
onAbort?: Function
) => void
+ensureURL: (push?: boolean) => void
+getCurrentLocation: () => string
+setupListeners: Function

都是跟真正跳转相关的,而History类中主要处理的是各种导航守卫钩子的触发时机。

所谓导航守卫,就是Vue Router给了开发者在导航前后处理一些事情的机会,开发者可以根据业务需要决定是否要导航到下一个页面。一共有3种地方的导航守卫:全局的、路由中的、组件内的。咱们先举个简单的例子,实现所有提供的钩子,然后看一下调用顺序。

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
const Foo = {
template: `
<div>Foo</div>
`,
beforeRouteEnter (to, from, next) {
console.log('foo beforeRouteEnter')
next()
},
beforeRouteUpdate (to, from, next) {
console.log('foo beforeRouteUpdate')
next()
},
beforeRouteLeave (to, from, next) {
console.log('foo beforeRouteLeave')
next()
}
}

const Bar = {
template: '<div>Bar</div>',
beforeRouteEnter (to, from, next) {
console.log('bar beforeRouteEnter')
next()
},
beforeRouteUpdate (to, from, next) {
console.log('bar beforeRouteUpdate')
next()
},
beforeRouteLeave (to, from, next) {
console.log('bar beforeRouteLeave')
next()
}
}

const router = new VueRouter({
routes: [
{
path: '/foo/:id',
component: Foo,
beforeEnter: (to, from, next) => {
console.log('foo beforeEnter')
next()
}
},
{
path: '/bar',
component: Bar,
beforeEnter: (to, from, next) => {
console.log('bar beforeEnter')
next()
}
}
]
})

router.beforeEach((to, from, next) => {
console.log('beforeEach')
next()
})

router.beforeResolve((to, from, next) => {
console.log('beforeResolve')
next()
})

router.afterEach((to, from) => {
console.log('afterEach')
})

new Vue({
router,
template: `
<div id="app">
<ul>
<li><router-link to="/foo/123">/foo (id = 123)</router-link></li>
<li><router-link to="/foo/456">/foo (id = 456)</router-link></li>
<li><router-link to="/bar">/bar</router-link></li>
</ul>
<router-view></router-view>
</div>
`
}).$mount('#app')

当点击/foo (id = 123)时,输出结果如下:

1
2
3
4
5
beforeEach
foo beforeEnter
foo beforeRouteEnter
beforeResolve
afterEach

当点击/foo (id = 456)时,输出的结果如下:

1
2
3
4
beforeEach
foo beforeRouteUpdate
beforeResolve
afterEach

当点击/bar时,输出的结果如下:

1
2
3
4
5
6
foo beforeRouteLeave
beforeEach
bar beforeEnter
bar beforeRouteEnter
beforeResolve
afterEach

通过直观的观察已经大概能了解到导航解析的处理过程,下面我们看一下History类中是怎么处理的。

导航解析的流程

History类对外提供了transitionTo方法来触发一次导航,transitionTo的方法签名如下:

1
2
3
4
5
6
// location 要导航过去的目标位置
// onComplete 导航完成时的回调
// onAbort 导航被终止时的回调
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// 1. 导航被触发
}

它先调用router.match获取目标位置的路由对象。然后调用了confirmTransition方法进行接下来的工作,同时会给它传刚才获得的route,和完成时的回调,还有导航终止时的回调。

confirmTransition方法中会先判断是否要导航去与当前相同的路由,如果是的话就中断导航,并触发错误回调。如果不是相同的路由,我们看下面这段代码:

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
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
// ...

// 首先解析出被更新的路由记录,被失活的路由记录,和被激活的路由记录
const { updated, deactivated, activated } = resolveQueue(
this.current.matched,
route.matched
)

// 准备执行队列中的任务,从这里也可以看出在进入目标路由位置之前,钩子被调用的顺序
const queue: Array<?NavigationGuard> = [].concat(
// 2. 在失活的组件里调用 beforeRouteLeave 守卫
extractLeaveGuards(deactivated),
// 3. 调用全局的 beforeEach 守卫
this.router.beforeHooks,
// 4. 在重用的组件里调用 beforeRouteUpdate 守卫
extractUpdateHooks(updated),
// 5. 在路由配置里调用 beforeEnter
activated.map(m => m.beforeEnter),
// 6. 解析异步路由组件
resolveAsyncComponents(activated)
)

// ...
}

然后将上面的队列传给runQueue函数去执行,runQueue函数的签名如下:

1
2
3
4
// queue 是要执行的任务队列
// queue 中的每个任务会传给 fn,由 fn 去执行,这里给 fn 传的是 iterator
// cb 是当队列中所有任务都执行完后的回调函数
runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function)

一会我们再细说一下iterator函数中做的事情,先看一下当队列中的任务都执行完会回调函数中做的事情,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
runQueue(queue, iterator, () => {
const postEnterCbs = []
const isValid = () => this.current === route
// 7. 在被激活的组件里调用 beforeRouteEnter
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
// 8. 调用全局的 beforeResolve 守卫
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
this.pending = null
onComplete(route)
//...
})
})

可以看到又准备了一个新的队列任务,然后又调用runQueue去执行这些任务。当这个新的队列中的任务都执行完后,如果正在等待的路由没有变,transitionTo方法传过来的onComplete回调就会执行。onComplete的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
this.confirmTransition(
route,
() => {
const prev = this.current
// 9. 触发DOM更新
this.updateRoute(route)
// 在外部传入的 onComplete 此时得到执行,onComplete中才真正执行跳转
onComplete && onComplete(route)
// 10. 导航被确认
this.ensureURL()
// 11. 调用全局的 afterEach 钩子
this. router.afterHooks.forEach(hook => {
hook && hook(route, prev)
})

// ...
},
err => {
// ...
}
)

说一下第9步,我觉得触发DOM更新说是在第9步是比较准确的,虽然官方文档中说触发DOM更新是在第11步,afterEach钩子执行之后。DOM真正被更新确实是发生在afterEach钩子执行之后的,但是触发确实在调用updateRoute时就已经触发了的,来看一下为什么。updateRoute的内容如下:

1
2
3
4
updateRoute (route: Route) {
this.current = route
this.cb && this.cb(route)
}

而此时的cb是什么呢?是在routerinit方法传进来的:

1
2
3
4
5
6
// listen 的参数就是 cb
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})

可以看到在这里给app._route重新赋值了,app就是 Vue 实例,而_route是响应式的,还记得在install函数中我们看见过下面这行代码:

1
Vue.util.defineReactive(this, '_route', this._router.history.current)

既然_route是响应式的,那它被赋值时就会触发Vue的渲染函数观察者去执行渲染了。我们知道watcher都是放在nextTick中执行的,所以更新的过程会晚一点发生,所以afterEach钩子先被调用了。

到这里我们已经看到了官方文档中说的前11步的执行过程了,那第12步是如何发生的呢?

我们先来看一下上面给runQueue的队列中到底都放的什么样的函数。

队列中的任务

可以看到extractLeaveGuardsextractUpdateHooks的执行过程是一样的:

1
2
3
4
5
6
7
function extractLeaveGuards (deactivated: Array<RouteRecord>): Array<?Function> {
return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}

function extractUpdateHooks (updated: Array<RouteRecord>): Array<?Function> {
return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}

extractGuards函数中是将得到的钩子进行展平:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function extractGuards (
records: Array<RouteRecord>,
name: string,
bind: Function,
reverse?: boolean
): Array<?Function> {
// 传给 flatMapComponents 的 fn 参数会被执行,同时也会从 record 中取出 def, instance 等
const guards = flatMapComponents(records, (def, instance, match, key) => {
const guard = extractGuard(def, name)
if (guard) {
// 此时 bind 会被执行,而 bind 就是外面传的 bindGuard
return Array.isArray(guard)
? guard.map(guard => bind(guard, instance, match, key))
: bind(guard, instance, match, key)
}
})
return flatten(reverse ? guards.reverse() : guards)
}

所以extractGuards函数返回的是bindGuard函数执行结果的数组,也就是boundRouteGuard函数数组。

为什么要去执行extractGuard函数呢?

1
2
3
4
5
6
7
8
9
10
function extractGuard (
def: Object | Function,
key: string
): NavigationGuard | Array<NavigationGuard> {
if (typeof def !== 'function') {
// extend now so that global mixins are applied.
def = _Vue.extend(def)
}
return def.options[key]
}

这样做是为了在使用Vue.mixin提供了一些全局的组件内导航守卫,这样就可以与其合并了。

1
2
3
4
5
6
7
8
9
10
Vue.mixin({
beforeRouteUpdate (to, from, next) {
console.log('mixin beforeRouteUpdate')
next()
},
beforeRouteLeave (to, from, next) {
console.log('mixin beforeRouteLeave')
next()
}
})

现在我们可以确定下来第一次传给runQueue的队列中可能出现的函数了:

1
2
3
4
5
6
7
8
9
10
11
12
const queue: Array<?NavigationGuard> = [].concat(
// in-component leave guards
extractLeaveGuards(deactivated), // f boundRouteGuard()
// global before hooks
this.router.beforeHooks, // ƒ (to, from, next)
// in-component update hooks
extractUpdateHooks(updated), // ƒ boundRouteGuard()
// in-config enter guards
activated.map(m => m.beforeEnter), // ƒ beforeEnter(to, from, next)
// async components
resolveAsyncComponents(activated) // ƒ (to, from, next)
)

同样的分析过程,可以找出第2次传给runQueue的队列中的函数:

1
2
3
4
5
// ƒ routeEnterGuard(to, from, next)
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
// ƒ (to, from, next)
const resolveHooks = this.router.resolveHooks
const queue = enterGuards.concat(resolveHooks)

确定了队列中可能出现的函数,再来看一下这些函数是如何执行的。

执行队列中的任务

传给runQueue函数的第2个参数是iterator,在它内部就会取执行队列中的任务。

1
2
3
4
5
6
7
8
9
10
11
12
const iterator = (hook: NavigationGuard, next) => {
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
try {
hook(route, current, (to: any) => {
// ...
}
} catch (e) {
abort(e)
}
}

hook就是队列中的函数,传入的3个参数分别是tofromnext

从上面分析出的函数可以看出当hookboundRouteGuardrouteEnterGuard时还要再进一步执行才能执行到开发者提供的内容。

1
2
3
function boundRouteGuard () {
return guard.apply(instance, arguments)
}

boundRouteGuard函数的作用是在执行钩子时绑定当前的Vue实例,arguments就是tofromnext,所以在beforeRouteUpdatebeforeRouteLeave内是可以直接用this访问到当前Vue实例的。

1
2
3
4
5
6
7
8
9
10
11
12
function routeEnterGuard (to, from, next) {
// 这里 guard 的第3个参数才是传给beforeRouteEnter钩子的next
return guard(to, from, cb => {
if (typeof cb === 'function') {
cbs.push(() => {
// 组件的实例此时可能还没有创建,通过轮询在组件实例存在时再执行函数
poll(cb, match.instances, key, isValid)
})
}
next(cb)
})
}

routeEnterGuard函数中在执行钩子函数时,重新包装了一次传入的next,是为了判断cb也就是开发者在beforeRouteEnter钩子中调用next时传入的值是否为函数。如果是函数会进行轮询,在组件的实例存在时再去执行这个函数,以确保函数内能正确放到组件实例对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function poll (
cb: any, // somehow flow cannot infer this is a function
instances: Object,
key: string,
isValid: () => boolean
) {
if (
instances[key] &&
!instances[key]._isBeingDestroyed // do not reuse being destroyed instance
) {
// 12. 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。
cb(instances[key])
} else if (isValid()) { // 判断当前的路由是否还是之前的目标路由
setTimeout(() => {
poll(cb, instances, key, isValid)
}, 16)
}
}

在轮询到组件实例已经创建了时,就发生了第12步,将实例传给next函数。所以官方文档中也是建议我们在beforeRouteEnter中需要访问实例时像下面这样写:

1
2
3
4
5
beforeRouteEnter (to, from, next) {
next(vm => {
// ...
})
},

对next传参的处理

开发者在钩子函数中调用next函数时可以传不同类型的值,给beforeRouteEnter中的next传函数的情况我们上面已经分析过了,现在看看其他情况。

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
hook(route, current, (to: any) => {
// 中断当前的导航。如果地址栏中的URL发生变化时,会重置到 from 路由对应的地址。
if (to === false) {
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true)
abort(createNavigationAbortedError(current, route))
}
// 导航被终止,该错误会被传递给 router.onError() 注册过的回调。
else if (isError(to)) {
this.ensureURL(true)
abort(to)
}
// 传了位置或位置对象时,会中断当前的导航,跳转到一个新的导航。
else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
// next('/') or next({ path: '/' }) -> redirect
abort(createNavigationRedirectedError(current, route))
if (typeof to === 'object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
}
// 执行队列中的下一个钩子函数。如果全部钩子执行完了,则导航的状态就是 confirmed (确认的)。
else {
next(to)
}
})

以上就是History类主要做的事情了,接下来看HashHistory类的实现。

HashHistory类

HashHistory的构造器方法中主要是确保当前的地址栏中会有#。我们主要看一下History基类中留给子类去实现的几个方法。

setupListeners

setupListeners方法中主要监听了浏览器的 前进/后退 事件,收到通知后做了两件事:

1
2
3
4
5
6
// 正确处理回到上一个页面,或进到一个页面
const eventType = supportsPushState ? 'popstate' : 'hashchange'
window.addEventListener(
eventType,
handleRoutingEvent
)
1
2
3
// 在用户创建 router 时设置了scrollBehavior的话
// 保存下当前的位置,以便在再回到这个页面时能自动滑动到用户希望停留的位置
window.addEventListener('popstate', handlePopState)

同时我们也看到向this.listeners添加了对应的移除监听器的操作:

1
2
3
4
5
6
7
8
9
10
this.listeners.push(() => {
window.removeEventListener(eventType, handleRoutingEvent)
})

export function setupScroll () {
// ...
return () => {
window.removeEventListener('popstate', handlePopState)
}
}

History类中的teardownListeners方法会去调用listeners中的函数,这样监听器就被移除了。

1
2
3
4
5
6
teardownListeners () {
this.listeners.forEach(cleanupListener => {
cleanupListener()
})
this.listeners = []
}

push & replace

这两个方法中都调用了我们上面讲到的transitionTo方法,在onComplete中分别调用了pushHashreplaceHash。来看一下这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path))
} else {
window.location.hash = path
}
}

function replaceHash (path) {
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}

在支持history.pushState的浏览器中优先使用该API,否则直接使用window.location来修改。在getUrl会将完整的url拼接好。

go

1
2
3
go (n: number) {
window.history.go(n)
}

VueRouter类中看到的backforward方法都是调用的go方法。

1
2
3
4
5
6
7
back () {
this.go(-1)
}

forward () {
this.go(1)
}

ensureURL & getCurrentLocation

getHash获取的是#号后的内容,也就是hash值。比如当前的 url 是http://localhost:8080/debug-start/#/foo/123,那getHash返回的就是/foo/123

ensureURL方法提供给外部来设置当前地址栏中的url。

getCurrentLocation方法提供给外部用来获取当前的hash值,也就是当前的位置。

HTML5History类

当我们在创建VueRouter实例时指定的modehistory,就会使用这个类来进行导航处理。与HashHistory类一个明显的差别是在地址栏中不会出现#了。

HashHistory类中对应的方法实现过程都很相似,就不具体展开看了。说一下getLocation方法,它的作用和getHash一样的,比如当前地址栏中的url是http://localhost:8080/debug-start/foo/123,那么getLocation会返回/foo/123

到此,Vue Router 的源码我们几乎就都分析完了,相信你会对它的使用有更深的了解。

本文作者:意林
本文链接:http://shinancao.cn/2020/03/16/Vue-Source-Code-19/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!