基础回顾
上篇文章已经详细介绍了vue-router
的整体运作流程,想了解上篇文章具体内容可以点击这里.上一篇留下了导航守卫(俗称钩子)没有展开分析.本篇文章将着重研究导航守卫的实现原理.
在学习源码之前,我们先对导航守卫的API
做一个基本回顾,源码做的大部分事情就是为了实现这些API
.
以全局前置守卫router.beforeEach
为案例讲解其用法,其他导航守卫依次类推.
全局前置守卫是在实际中使用非常多的API
.每一次页面的跳转都会执行router.beforeEach
包裹的函数(代码如下).
to
是将要进入的目标路由对象,from
是当前导航正要离开的路由.
next
函数作用非常强大.它可以决定是放行到下一级守卫还是中间截断直接跳转到其他页面.
-
next(false)
或者next(error)
表示中断当前的导航. -
next({ path: '/' })
或者next({ path: '/', replace: true })
表示跳转或重定向到path
路径. -
next()
表示当前导航守卫放行,进入下一个钩子.
const router = new VueRouter({ ... })
router.beforeEach((to, from, next) => {
// ...
next({ path: '/login', replace: true }); //重定向到登录页面
})
复制代码
其他主要的导航守卫参数也由to
、from
和next
组成,用法和router.beforeEach
一样,只是执行的时机不同.
-
beforeRouteLeave
:导航即将离开某个页面组件时,组件内定义的beforeRouteLeave
钩子会触发. -
beforeRouteUpdate
:对于一个带有动态参数的路径/foo/:id
,在/foo/1
和/foo/2
之间跳转的时候,由于会渲染同样的Foo
组件,因此Foo
组件内定义的beforeRouteUpdate
钩子会触发. -
beforeEnter
:在开发者编写的路由配置里面添加的钩子函数.它会在进入某页面组件之前执行. -
异步路由组件: 异步加载组件
const Foo = () => import(/* webpackChunkName: "group-foo" */ './Foo.vue')
. -
beforeRouteEnter
:它会在进入某页面组件之前执行,与beforeEnter
守卫相比,除了执行时机不同,它还定义在组件内.
这几个导航守卫的执行顺序依次如下.上一个导航守卫函数内调用next()
便可触发下一个导航守卫继续执行.
beforeRouteLeave // 在失活的组件里调用
beforeEach // 全局定义
beforeRouteUpdate // 在重用的组件里调用
beforeEnter // 在路由配置里调用
解析异步路由组件
beforeRouteEnter // 在被激活的组件里调用
复制代码
调用场景
按照上一篇文章所讲,vue-router
最终执行的跳转都是调用下面transitionTo
函数.
location
是跳转路径,onComplete
和onAbort
分别代表跳转成功或失败的回调函数.
this.confirmTransition
里面包含了导航守卫的逻辑,导航守卫会对跳转路径进行层层控制,只有通过了所有导航守卫才会执行this.confirmTransition
第二个参数,象征着导航成功的回调函数.
History.prototype.transitionTo = function transitionTo (
location,
onComplete,
onAbort
) {
var this$1 = this;
var route = this.router.match(location, this.current);
this.confirmTransition(
route,
function () {
this$1.updateRoute(route);
onComplete && onComplete(route);
...
},
function (err) {
if (onAbort) {
onAbort(err);
}
...
}
);
};
复制代码
我们可以先试着猜想一下this.confirmTransition
是如何对待访问路由进行层层拦截的?
导航守卫的基本概念已经知晓,它是开发者自己定义的拦截函数.现在假设我们已经将所有定义的导航守卫全部收集起来存到了queue
数组.那我们怎么去实现那种在导航守卫里运行next()
就能跳到下一个导航钩子呢?
next实现
全局前置守卫router.beforeEach
在上面已经介绍过,我们这次主要想要研究一下函数里面的next
是如何实现的(代码如下).
-
next()
函数如果什么都不传表示放行直接进入下一个导航钩子. -
如果
next
里面填入的是一个对象,对象里面只包含path
属性时,导航会push
到该路径.对象要是除了path
外,还存在replace:true
,那么导航会重定向到path
路径. -
最后
next(false)
传递的参数是一个false
或者Error
实例时,导航就会终止本次跳转操作,接下来的钩子链条也会停止往下执行.
router.beforeEach((to, from, next) => {
if (!user_info) { //没有登录跳到登录页面
next({ path:"/login" });
} else {
//登录过了直接放行
next();
}
})
复制代码
从上面对next()
携带的参数分析,next
内部会对不同参数类型做不同的处理,我们看下源码是如何处理参数的(代码如下).
当next
执行后,下面的匿名函数就会被调用.to
分别对应了上述的三种情况.如果to
为false
或者Error
类型终止跳转.如果to
是一个object
对象或者字符串,就使用push
或replace
执行跳转.如果to
为空直接执行next
放行.
function (to) {
if (to === false || isError(to)) {
abort(to); // 终止本次跳转操作
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
abort();
if (typeof to === 'object' && to.replace) {
this$1.replace(to);
} else {
this$1.push(to);
}
} else {
// confirm transition and pass on the value
next(to);
}
复制代码
我们可以看到在这个匿名函数里面,最后一个else
里也包含一个next
,这个next
一调用才是真正的触发下一个导航钩子.
现在的问题是应该设计一个什么样的机制才能满足这样的一种链式调用.首先我们要先把所有开发者定义的导航钩子先收集起来,按照执行顺序的先后存储在一个数组里面.比如queue = [fn1,fn2,fn3,fn4]
.假设fn1
对应着beforeRouteLeave
钩子,fn2
对应着beforeEach
钩子.
然后设置起始索引index = 0
,让数组按照索引取出函数执行.那在fn1
里面为什么调用next
就可以触发fn2
执行呢?那时因为当index = 0
执行fn1
时,queue [index+1]
的执行机制被包裹成函数参数传入到了fn1
里面来控制.因此fn1
内部调用next
函数就会触发fn2
函数的执行.源码实现如下.
function runQueue (queue, fn, cb) {
var step = function (index) {
if (index >= queue.length) {
cb();
} else {
if (queue[index]) {
fn(queue[index], function () {
step(index + 1);
});
} else {
step(index + 1);
}
}
};
step(0);
}
复制代码
queue
存储着所有导航钩子函数,第一次执行取出queue
的第一个函数放入fn
中执行,在fn
的第二个参数放置了一个匿名函数,这个匿名函数正是导航守卫里面调用next()
最终能触发下一个导航钩子执行的原因.
我们接下来看下fn
的源码,fn
就是下面的iterator
函数.
hook
参数对应着从上面queue
中取出的钩子函数,在调用hook
时传入了三个参数.route
是下一个路由对象,current
为当前路由对象,而第三个函数正是我们在上面介绍的对to
参数处理的匿名函数.
假设hook
对应着全局前置守卫 router.beforeEach((to, from, next) => { ... }
,那么路由守卫的三个参数to
,from
和next
就分别由下面的route
,current
和匿名函数传入.
当router.beforeEach
的next
执行,hook
第三个参数匿名函数就会执行.匿名函数根据to
的数据类型做出相应的判断,当to
为空时,匿名函数执行了iterator
函数的第二个参数next
.
而这个next
是runQueue
里面的function () { step(index + 1) }
作为参数传进来的.最终就会触发下一个导航钩子的执行.
var iterator = function (hook, next) {
if (this$1.pending !== route) {
return abort()
}
try {
hook(route, current, function (to) {
if (to === false || isError(to)) {
abort(to);
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
abort();
if (typeof to === 'object' && to.replace) {
this$1.replace(to);
} else {
this$1.push(to);
}
} else {
next(to);
}
});
} catch (e) {
abort(e);
}
};
复制代码
整体流程
现在我们来回顾一下整个执行流程.导航执行跳转时调用transitionTo(location)
,然后this.router.match
将跳转路径location
转化成待跳转的路由对象route
.
this.confirmTransition
开始走导航守卫的逻辑,只有所有导航守卫对待访问的路由对象route
全部放行了才会进入成功的回调函数.
History.prototype.transitionTo = function transitionTo (
location,
onComplete,
onAbort
) {
var route = this.router.match(location, this.current);
this.confirmTransition(
route,
function () {
//成功的回调函数
},
function (err) {
//失败回调
}
);
};
复制代码
this.confirmTransition
内部首先是将所有导航钩子按照执行顺序收集起来放入一个数组queue
,另外准备好待访问路由对象route
和当前路由对象current
.
route
是执行this.confirmTransition
传递进来的参数,current
是从History
实例中获取的,存储着当前的路由对象.
History.prototype.confirmTransition = function confirmTransition (route, onComplete, onAbort) {
var current = this.current;
...
}
复制代码
应用初始化时current
被赋予了一个固定值(如下),path
指向了根路径.只有当导航成功调用上面onComplete
函数时,才会将待跳转的路由对象route
赋值给当前路由对象current
.
current = {
fullPath: "/",
hash: "",
matched: [],
meta: {},
name: null,
params: {},
path: "/",
query: {}
}
复制代码
this.confirmTransition
的核心代码如下所示.程序收集完queue
数组后,就开始执行runQueue
函数。runQueue
执行后,它会依次取出queue
里面的钩子函数放入到iterator
里面执行.当queue
数组里面的所有钩子函数都放行时,就会执行runQueue
第三个参数,相当于全部通过,进入成功的回调函数.
在回调函数里面又执行了一次runQueue
,这次是将beforeRouteEnter
和全局定义的beforeResolve
收集起来赋值给queue
,再执行一遍上述的流程.等到queue
里面的导航钩子执行完毕后,执行成功的回调函数onComplete
.走到这一步表示所有导航守卫全部通过,该路径允许跳转.
History.prototype.confirmTransition = function confirmTransition (route, onComplete, onAbort) {
var current = this.current;
var queue = ... // 收集导航钩子
var iterator = function (hook, next) {
...
}
runQueue(queue, iterator, function () {
// 成功的回调函数,执行到这里说明 queue 里面的所有导航钩子都通过了
var queue = ...; //收集组件内部的 beforeRouteEnter 钩子函数 和 全局的 beforeResolve 钩子
runQueue(queue, iterator, function () {
...
onComplete(route);
...
});
}
复制代码
导航守卫收集
上面已经将导航守卫的执行逻辑梳理了一遍,但怎么去收集queue
并没有展开,接下来深入研究一下queue
的收集过程.
我们继续看this.confirmTransition
源码.resolveQueue
函数传入了当前路径对象current
和待跳转路由对象route
的matched
属性.
上一篇文章已经详细介绍过,matched
的数据结构形似[{path:"/login",meta:{},name:"login",components:组件对象 }]
,通过matched
可以拿到页面组件的参数.
History.prototype.confirmTransition = function confirmTransition (route, onComplete, onAbort) {
var current = this.current;
const { updated,deactivated, activated } = resolveQueue(
this.current.matched,
route.matched
);
var queue = [].concat(
// 组件内的 beforeRouteLeave 钩子
extractLeaveGuards(deactivated),
// 全局的beforeEach钩子
this.router.beforeHooks,
// 组件内的 beforeRouteUpdate 钩子
extractUpdateHooks(updated),
// 这是在配置路由表添加的路由守卫
activated.map(function (m) { return m.beforeEnter; }),
// 异步组件
resolveAsyncComponents(activated)
);
var iterator = function (hook, next) { ... }
runQueue(queue, iterator, function () { ... }
复制代码
resolveQueue
函数执行完毕后返回updated
,deactivated
, activated
三个参数,这三个参数将用于后面queue
数据收集.
resolveQueue
源码如下,通过对函数内current
和next
参数的计算得出下面三条数据.
- update:从新路由中取出与当前路由重合的部分
- activated:从新路由中取出比当前路由多出的部分,没有多出的就为空数组
- deactivated:当前路由拥有而新路由没有的部分.
function resolveQueue (
current, //当前路由匹配的页面组件数组
next // 即将要跳转的路由匹配的页面组件数组
) {
var i;
var max = Math.max(current.length, next.length);
for (i = 0; i < max; i++) {
if (current[i] !== next[i]) {
break
}
}
return {
updated: next.slice(0, i), //更新数组
activated: next.slice(i), // 激活数组
deactivated: current.slice(i) // 失活数组
}
}
复制代码
拿到上述三条数据之后,就可以利用这三条数据收集所有的导航守卫钩子.
var queue = [].concat(
// 组件内的 beforeRouteLeave 钩子
extractLeaveGuards(deactivated),
// 全局的beforeEach钩子
this.router.beforeHooks,
// 组件内的 beforeRouteUpdate 钩子
extractUpdateHooks(updated),
// 这是在配置路由表添加的路由守卫
activated.map(function (m) { return m.beforeEnter; }),
// 异步组件
resolveAsyncComponents(activated)
);
复制代码
根据导航守卫的执行顺序,按照beforeRouteLeave
,beforeEach
,beforeRouteUpdate
,beforeEnter
和路由守卫的顺序依次收集.
beforeRouteLeave
现在以beforeRouteLeave
为例,观察其执行过程. flatMapComponents
函数遍历records
,取出里面每个页面的component
对象def
,再放入右侧回调函数中执行.
extractGuard
函数会从def
里面取出beforeRouteLeave
定义的函数并以数组的形式返回.简而言之,就是失活的页面组件里取出beforeRouteLeave
定义的函数并以数组的形式返回.
extractLeaveGuards(deactivated);//获取`beforeRouteLeave``守卫
//内部执行了``extractGuards``函数
function extractLeaveGuards (deactivated) {
return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}
function extractGuards (
records,
name,
bind,
reverse
) {
//遍历records,取出里面每个component对象,再丢入右侧回调执行.
// def对应每个组件对象,instance = {},key = "default"
var guards = flatMapComponents(records, function (def, instance, match, key) {
var guard = extractGuard(def, name);//name对应导航守卫函数名,返回的guard全转成数组
if (guard) { //取出配置中的路由守卫函数
return Array.isArray(guard)
? guard.map(function (guard) { return bind(guard, instance, match, key); })
: bind(guard, instance, match, key)
}
}); // guard将定义的所有路由函数收集起来,并改变了上下文对象.最后将guards数组返回
return flatten(reverse ? guards.reverse() : guards)
}
复制代码
beforeEach
router.beforeEach
可直接从 this.router.beforeHooks
中获取.
beforeRouteUpdate
beforeRouteUpdate
收集过程与上面类似,它的获取代码如下.
function extractUpdateHooks (updated) {
return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}
复制代码
beforeEnter
路由配置表中的路由进入守卫beforeEnter
可以直接从activated
激活的路由对象中获取.
activated.map(function (m) { return m.beforeEnter; }),
复制代码
异步组件
异步组件的路由配置方式如下,component
的值是一个函数,里面使用了webpack
的动态引入方法.
{
path: '/search',
name: 'search',
component: () =>
import(/* webpackChunkName: "search" */ '../views/Search/Search.vue'), // 搜索页面组件
}
复制代码
resolveAsyncComponents
是处理异步组件的核心函数(代码如下).resolveAsyncComponents
返回的函数仍然符合导航守卫的参数格式,包含了to
,from
和next
,这部分逻辑由源码自己完成.
在flatMapComponents
包裹的回调函数里,def
就是上面配置对应的 component
参数.当它是一个函数时就被当做异步组件来处理.我们可以看一下def
被webpack
编译后的样子(代码如下).
def = function component() { // webpack转化的代码部分
return __webpack_require__.e(/*! import() | search*/ "search").then(__webpack_require__.bind(null, /*! ../views/Search/Search.vue */ "./src/views/Search/Search.vue"));
}
复制代码
程序再往下定义了两个函数resolve
和reject
,将它们传入到def
中执行后,应用就开始异步请求组件内容.
请求成功之后就会调用resolve
函数,并将异步请求得到的组件对象传递给resolvedDef
参数.新获取的组件对象resolvedDef
再赋值给match.components.default
存储起来便完成了该组件的异步加载.当所有异步组件都加载完毕后开始执行next()
进入下一个导航守卫.
function resolveAsyncComponents (matched) {
return function (to, from, next) {
var hasAsync = false;
var pending = 0;
var error = null;
//处理异步组件
flatMapComponents(matched, function (def, _, match, key) {
if (typeof def === 'function' && def.cid === undefined) {
hasAsync = true; // 异步组件
pending++; //有几个异步组件在加载
var resolve = once(function (resolvedDef) {
// resolvedDef为异步加载完毕的组件对象
if (isESModule(resolvedDef)) {
resolvedDef = resolvedDef.default;
}
// save resolved on async factory in case it's used elsewhere
// vue.extend创建的是vue的构造函数
def.resolved = typeof resolvedDef === 'function'
? resolvedDef
: _Vue.extend(resolvedDef);
//match是从matched中取出来的路由对象
//将新获取的组件对象覆盖原来default值(key为'default')
match.components[key] = resolvedDef;
pending--;
if (pending <= 0) {
next();
}
});
//请求失败时调用
var reject = once(function (reason) {
var msg = "Failed to resolve async component " + key + ": " + reason;
process.env.NODE_ENV !== 'production' && warn(false, msg);
if (!error) {
error = isError(reason)
? reason
: new Error(msg);
next(error);
}
});
var res;
try {
// 获得一个promise
res = def(resolve, reject);
} catch (e) {
reject(e);
}
if (res) {
if (typeof res.then === 'function') {
//调用promise.then方法
res.then(resolve, reject);
} else {
// new syntax in Vue 2.3
var comp = res.component;
if (comp && typeof comp.then === 'function') {
comp.then(resolve, reject);
}
}
}
}
});
if (!hasAsync) { next(); }
}
}
复制代码
beforeRouteEnter
beforeRouteEnter
是定义在组件内部的导航守卫,但是它跟之前的导航守卫不太一样.beforeRouteEnter
函数体内无法获取组件实例,但是next
的回调函数里能通过参数vm
拿到组件实例.
{
data(){
return {};
}
beforeRouteEnter (to, from, next) {
//在这里无法获取组件实例
...
next(vm => {
// 通过 `vm` 访问组件实例
})
},
methods:{
}
}
复制代码
我们再看下runQueue
源码部分(代码如下).源码中执行完第一轮的导航守卫后,便进入到runQueue
回调函数里,开始执行下一阶段的导航守卫逻辑.第二阶段会先收集beforeRouteEnter
和beforeResolve
钩子再执行runQueue
,此过程中extractEnterGuards
正是收集beforeRouteEnter
钩子的处理函数.
runQueue(queue, iterator, function () {
var postEnterCbs = [];
var enterGuards = extractEnterGuards(activated, postEnterCbs, isValid);
var queue = enterGuards.concat(this$1.router.resolveHooks);
runQueue(queue, iterator, function () {
onComplete(route);
if (this$1.router.app) {
this$1.router.app.$nextTick(function () {
postEnterCbs.forEach(function (cb) {
cb();
});
});
}
});
});
复制代码
extractEnterGuards
源码如下.extractGuards
函数第三个参数和之前的导航守卫不一样,它里面调用bindEnterGuard
函数.guard
就是从组件内部取出的beforeRouteEnter
函数.
beforeRouteEnter
函数被取出来后并没有直接返回,而是利用bindEnterGuard
包了一层.bindEnterGuard
返回的函数才是真正返回给queue
数组的函数.
当上一个导航守卫调用next()
后,线程就会慢慢执行到routeEnterGuard
函数内.routeEnterGuard
函数内部会直接调用guard
,上面提到过guard
其实就是收集的beforeRouteEnter
函数.关键点就在于routeEnterGuard
重塑了guard
的next
方法.
上面介绍过beforeRouteEnter
的使用案例,它的next
是可以传递一个函数并且能通过参数拿到组件实例的.guard
的next
就会对cb
进行判断,如果它是一个函数,那正是我们分析的这种情况.我们要把组件实例想办法要传给cb
.
此时有个棘手的问题,线程跑到此处该组件实例还未创建.所以先用cbs
(上面定义的空数组postEnterCbs
)将cb
包裹成一个匿名函数存储起来.
function extractEnterGuards (
activated,
cbs,
isValid
) {
return extractGuards(
activated,
'beforeRouteEnter',
function (guard, _, match, key) {
return bindEnterGuard(guard, match, key, cbs, isValid)
}
)
}
function bindEnterGuard (
guard,
match,
key,
cbs,
isValid
) {
return function routeEnterGuard (to, from, next) {
return guard(to, from, function (cb) {
if (typeof cb === 'function') {
cbs.push(function () {
poll(cb, match.instances, key, isValid);
});
}
next(cb);
})
}
}
function poll (
cb, // 在beforeRouteEnter使用next包裹的回调函数
instances,
key, // default
isValid
) {
//instances是$nexttick执行后已经创建好的vue实例{default:vue实例}
if (
instances[key] &&
!instances[key]._isBeingDestroyed // do not reuse being destroyed instance
) {
cb(instances[key]); // 把组件传过去,这就是this的来源,key就是字符串``default``
} else if (isValid()) { // 它这里会监听如果组价没创建完毕,等一会再执行
// 这个isValid就是在上面runQueue定义的
// 下面两个只有路由跳转完毕了才相等
// var isValid = function () { return this$1.current === route; };
setTimeout(function () {
poll(cb, instances, key, isValid);
}, 16);
}
}
复制代码
我们再看runQueue
函数,等到this$1.router.app.$nextTick
执行完毕之后,程序开始遍历postEnterCbs
数组并执行cb
.此时上面routeEnterGuard
函数里面的match.instances
对应的实例对象已经创建完毕了,接下来poll(cb, match.instances, key, isValid)
便开始执行.
从这里可以看出next(vm=>{ ... })
包裹的回调函数是在this$1.router.app.$nextTick
之后触发的,而vm
就是从上面poll
函数里传过去的.
runQueue(queue, iterator, function () {
...
runQueue(queue, iterator, function () {
...
onComplete(route);
if (this$1.router.app) {
this$1.router.app.$nextTick(function () {
//这就是之前收集的cbs
postEnterCbs.forEach(function (cb) {
cb();
});
});
}
});
});
复制代码
尾言
至此已经将主要的几个导航守卫的收集和守卫之间的链式调用介绍完毕.当某个跳转路径通过了所有的导航守卫,接下来应用开始执行onComplete
回调函数,正式开始对url
进行变换以及触发页面渲染.