浅显易懂的vue-router源码解析(二)

基础回顾

上篇文章已经详细介绍了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 }); //重定向到登录页面
})

其他主要的导航守卫参数也由tofromnext组成,用法和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是跳转路径,onCompleteonAbort分别代表跳转成功或失败的回调函数.

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分别对应了上述的三种情况.如果tofalse或者Error类型终止跳转.如果to是一个object对象或者字符串,就使用pushreplace执行跳转.如果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,fromnext就分别由下面的route,current和匿名函数传入.

router.beforeEachnext执行,hook第三个参数匿名函数就会执行.匿名函数根据to的数据类型做出相应的判断,当to为空时,匿名函数执行了iterator函数的第二个参数next.

而这个nextrunQueue里面的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和待跳转路由对象routematched属性.

上一篇文章已经详细介绍过,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源码如下,通过对函数内currentnext参数的计算得出下面三条数据.

  • 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,fromnext,这部分逻辑由源码自己完成.

flatMapComponents包裹的回调函数里,def就是上面配置对应的 component参数.当它是一个函数时就被当做异步组件来处理.我们可以看一下defwebpack编译后的样子(代码如下).

  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"));
  }

程序再往下定义了两个函数resolvereject,将它们传入到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回调函数里,开始执行下一阶段的导航守卫逻辑.第二阶段会先收集beforeRouteEnterbeforeResolve钩子再执行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重塑了guardnext方法.

上面介绍过beforeRouteEnter的使用案例,它的next是可以传递一个函数并且能通过参数拿到组件实例的.guardnext就会对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进行变换以及触发页面渲染.

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值