Bootstrap

VueRouter源码解读:三大核心模块构成

背景

随着前后端分离的发展,单页面应用的盛行,页面路由控制权便由早期的根据url从后端获取渲染好的页面模板,转向交由前端自主控制。耳熟能详的路由框架有vue-router、react-router, 本质上,这是一种不重载页面,通过监听页面url变化,使用js更替页面元素的技术。前端路由的好处是,不需要每次从服务端取页面,因此能快速响应。缺点是路由切换的页面,不能被浏览器缓存,当通过浏览器前进后退时,页面会重新发起请求。

这篇文章,就来看看vue-router是如何实现页面切换的。最后读完源码,从框架数据数据模型上看核心组成是:路由匹配器(路由配置信息添加、查询、获取)、路由处理器(History监听、变更等)、组件视图容器(RouterView)。

原理

哈希模式

哈希模式就是在浏览器地址带上#,比如:# 后面的值变化是可以通过原生事件 监听到的,因此可以在监听到浏览器地址的hash值变化的时候去变更页面元素。下面几种情况都可以采用该事件监听:

  • 通过window.location方法变更了浏览器地址

  • 通过a标签产生的浏览器地址变化

  • 浏览器前进后退行为

下面直接上代码,模拟这个行为:

// 1. 在控制台先执行这段代码
const body = document.body;
window.addEventListener('hashchange', function(){
  switch(location.hash){
    case '#/p1':
      body.innerHTML = 1;
      return
    case '#/p2':
      body.innerHTML = 2;
      return
    default:
      body.innerHTML = 3;
  }
})
// 2. 然后依次执行以下代码
window.location.hash='#/p1'  // 页面输出: 1
window.location.hash='#/p2'  // 页面输出: 2
window.location.hash='#/p0'  // 页面输出: 3

哈希模式比较简单,按照这个例子,便能够验证了。当然了这只是个玩具。

历史模式

历史模式会比哈希模式来得麻烦些,它是依赖于H5的History接口。下面先弥补下这块知识吧,毕竟平常也常用。

History接口

History 接口允许操作浏览器的曾经在标签页或者框架里访问的会话历史记录。

属性

返回一个整数,该整数表示会话历史中元素的数目,包括当前加载的页。

返回一个表示历史堆栈顶部的状态值。

允许Web应用程序在历史导航上显式地设置默认滚动恢复行为。此属性可以是自动的(auto)或者手动的(manual)。

方法

按指定的名称和URL(如果提供该参数)将数据push进会话历史栈,数据被DOM进行不透明处理。

按指定的数据,名称和URL(如果提供该参数),更新历史栈上最新的入口。

/**
  * @Param 状态对象(可以是能被序列化的任何东西)
  * @Param 标题(可以忽略此参数)
  * @Param URL
  */
history.pushState({id:1},'','1.html')

这两个方法的参数都是一样的, 获取的就是状态值。

重点:onpopstate事件

调用 或者 不会触发 事件.。 事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用方法),此外,a 标签的锚点也会触发该事件。

原理实现

我们需要借助 两个API能够改变浏览器地址而不刷新页面的特性,来实现我们的前端路由。但是这两个方法不能被 事件监听,因此我们要转变下思路,当进行 或者 调用时,去获取浏览器地址然后去变更页面。 代码如下:

// 1. 页面初始化函数
const body = document.body;
function ListenPathChange(){
	switch(location.pathname){
    case '/p1':
      body.innerHTML = 1;
      return;
    case '/p2':
      body.innerHTML = 2;
      return;
    default:
      body.innerHTML =3;
	}
}

// 2. 依次执行如下代码
history.pushState({id:1},'','/p1')
ListenPathChange() // 页面变更为:1
history.pushState({id:2},'','/p2')
ListenPathChange() // 页面变更为:2
history.pushState({id:0},'','/p0')
ListenPathChange() // 页面变更为:3

至此我们便晓得了哈希模式和历史模式,改变页面的玩具玩法,当然也是前端路由的原理。下面我们看看前端路由框架Vue-Router是如何实现这块的。

源码实现

这里以哈希模式的路由,阅读从Vue-Router初始化到在Router-View组件完成渲染的全链路流程,理解它的设计思想和实现。

1. Vue的插件

VueRouter 是通过 完成注册。按照Vue插件的规范,插件可以存在两种形式,一种是函数,另一种是存在 方法的对象,并且在执行插件初始化的时候,会传递Vue,也就方便插件无需引入Vue包。明白这些就很明显了,Vue-Router初始化如下:

export let _Vue
export function install (Vue) {
  _Vue = Vue
}
2. 插件初始化

router实例化后,会注入根组件。代码如下:

const router = new VueRouter({})
const app = new Vue({
  router,
})

路由的初始化都需要做什么事情呢?下面看看源码:

export function install (Vue) {
  if (install.installed && _Vue === Vue) return
  install.installed = true
  _Vue = Vue

  const isDef = v => v !== undefined

  Vue.mixin({
    beforeCreate () {
      // this.$options.router 注入到根组件的路由实例
      if (isDef(this.$options.router)) { 
        // 只有根组件的$options才有router
        this._routerRoot = this
        // 配置的路由信息
        this._router = this.$options.router
        // 路由初始化
        this._router.init(this)
        // **** 这里是定义了个响应式的 _route 属性,值为当前页面路由
        // **** 很重要,vue-router就是靠这玩意取组件完成页面渲染的
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 子组件获取父组件的_routerRoot并在自身注册
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || 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)
}

插件初始化,主要完成了几件事:

3. 创建匹配器

VueRouter核心是根据不同的路由,跳转不同的页面。因此在实例化的过程中,会根据配置的routes创建一个匹配器,方便通过路由路径或者路由名称好匹配到对应的路由详细信息。当然匹配器并不能刷新页面组件。可以把它理解为一个提供路由信息查询、新增和获取的实体

// 路由类
export default class VueRouter {
  constructor (options: RouterOptions = {}) {
    // 其他代码略
    // 路由初始化,创建一个匹配器
  	this.matcher = createMatcher(options.routes || [], this)
  }
  init(){}
}

匹配器具体代码:

export function createMatcher (routes,router){

  // createRouteMap:扁平化用户传入的数据,创建路由映射表,内部通过递归children路由信息,具体可以看源码,这里不介绍
  // pathList : [/,'/path','/path/a']
  // pathMap:{'/':'组件A','/path':'组件B','/path/a':'组件C'}
	const { pathList, pathMap, nameMap } = createRouteMap(routes)
  
  // 动态添加路由配置:将routes拍平添加到pathList这些
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // 匹配路由
  function match(raw,currentRoute){}
  // ...
	return {
    match,
    addRoute,
    getRoutes,
    addRoutes
  }
}
4. 路由监听

插件初始化的时候,执行了路由实例的初始化方法,那这个方法都干了什么呢?猜测一下,能看得出来是调用HashHistory的方法去监听路由信息变化!并且当当前路由发生了变化,会将最新路由值赋值到组件实例 上,又因为当前属性是响应式的,因此将会触发视图的变更。看下代码(抽离了下核心代码):

export default class VueRouter{
	init(app){
    // 省略其他代码
    if (history instanceof HTML5History || history instanceof HashHistory) {
      
      // 这里调用了history去监听路由信息变化
      const setupListeners = routeOrError => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupListeners,
        setupListeners
      )
    }
    
    // 监听路由变化,重新设置组件实例_route的值,这个值做了响应式处理,所以变更后,会触发视图更新
    // 这里算是采用了发布订阅的模式
  	history.listen(route => {
      this.apps.forEach(app => {
        app._route = route
      })
    })
  }
}

setupListeners

下面就通过哈希模式的 监听到hash变化是如何处理路由的。其他push、replace、go等方式都是一样,不多细说。 以下代码是抽离了核心逻辑代码。

export class HashHistory extends History {
  constructor (router, base, fallback) {
    super(router, base)
    // ...
    ensureSlash()
  }
  // 监听路由变化
  setupListeners(){
    const router = this.router
    const handleRoutingEvent = () => {
      const current = this.current
      this.transitionTo(getHash(), route => {
        if (!supportsPushState) {
          replaceHash(route.fullPath)
        }
      })
    }
    // 通过hashchange 监听到路由变化,然后去执行 handleRoutingEvent 变化
    const eventType = supportsPushState ? 'popstate' : 'hashchange'
    window.addEventListener(
      eventType,
      handleRoutingEvent
    )
  }
  push(){}
  replace(){}
  go(){}
}

监听到路由变化会执行一个 方法,具体路由是否跳转,路由守卫应该也就是在此函数内执行了。

5. 路由守卫

监听到路由变化,到最终变更替换为真正的路由是需要经过一系列自定义拦截行为,统称为路由的钩子函数。精简后的核心代码如下。

export class History {
 constructor(){
   this.current = START
 }
 listen (cb: Function) {
    this.cb = cb
 }
 updateRoute (route: Route) {
   this.current = route
   this.cb && this.cb(route)
 } 
 transitionTo(){
   this.confirmTransition(
     route,
     ()=>{
        // 更新路由信息
     		this.updateRoute(route)
     }
   )
 }
 confirmTransition(){
  	const queue = [].concat(
      // in-component leave guards
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      resolveAsyncComponents(activated)
    )
   
     const iterator = (hook, next) => {
      if (this.pending !== route) {
        return abort(createNavigationCancelledError(current, route))
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false) {
            // next(false) -> abort navigation, ensure current URL
            this.ensureURL(true)
            abort(createNavigationAbortedError(current, route))
          } 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)
            }
          } else {
            // confirm transition and pass on the value
            next(to)
          }
        })
      } catch (e) {
        abort(e)
      }
    }

    runQueue(queue, iterator, () => {
      // wait until async components are resolved before
      // extracting in-component enter guards
      const enterGuards = extractEnterGuards(activated)
      const queue = enterGuards.concat(this.router.resolveHooks)
      runQueue(queue, iterator, () => {
        if (this.pending !== route) {
          return abort(createNavigationCancelledError(current, route))
        }
        this.pending = null
        onComplete(route)
        if (this.router.app) {
          this.router.app.$nextTick(() => {
            handleRouteEntered(route)
          })
        }
      })
    })
 }
}

路由守卫相关简易去看下文档,加深理解,这里只梳理下流程,只有路由变更前需经过一系列路由守卫钩子函数处理。当通过时会执行 方法,更新当前路由的值。并且执行了 函数传入的回调函数,这个异步操作的一种形式,该listen函数在VueRouter初始化的时候完成了注册。

6. 页面刷新

VueRouter的视图刷新是在RouterView组件内部完成的切换,这里可能涉及RouterView嵌套RouterView的情况,所以要做层级的处理取组件。抽离了核心代码如下。##

export default {
  name: 'RouterView',
  functional: true,
  props: {
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    // router-view的标记
    data.routerView = true

    // 直接使用父上下文的createElement()函数,方便由router视图呈现的组件可以解析命名插槽
    const h = parent.$createElement
    const name = props.name
    const route = parent.$route // $route 即 _route的值,
    const cache = parent._routerViewCache || (parent._routerViewCache = {})

    // 确定当前视图深度,可能是嵌套多个RouterView
    let depth = 0
    while (parent && parent._routerRoot !== parent) {
      const vnodeData = parent.$vnode ? parent.$vnode.data : {}
      if (vnodeData.routerView) {
        depth++
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth
		
    // 获取到匹配的组件
    const matched = route.matched[depth]
    const component = matched && matched.components[name]

    // 没有匹配到组件或未配置组件,渲染空节点
    if (!matched || !component) {
      cache[name] = null
      return h()
    }

    // cache component
    cache[name] = { component }

    return h(component, data, children)
  }
}

总结

至此便大概梳理了下哈希模式下VueRouter的源码整体流程,发现整体模型上不多,路由匹配器、History、路由组件三者构成了VueRouter的核心组成。当然为了路由安全,在流程链路上又搭配了守卫系统。所以从模型上理解路由源码,就简单,大三核心功能大致如下:

路由匹配器:提供对路由信息的匹配(路径匹配路由信息)、新增(动态路由)、获取三大功能

History:监听、改变页面路由的处理器,并保存当前页面路由current,当为三核心沟通钥匙

路由组件:RouterView为匹配的路由组件渲染切换容器