vue-router源码解析

126 阅读7分钟

什么是前端路由?

在单页面应用中,前端路由是描述url和UI之间的映射关系;url的变化显示对应的UI,无需刷新页面;

如何实现前端路由?

  1. url的变化不进行刷新页面如何显示对应的UI?
  2. 如何监听到url的变化?

通过H5的history和hash两种方式可以实现以上两个问题;

hash路由

hash路由也是锚点,带有#符号的url就是hash路由,#后面的就是hash;改变hash值是不会引起页面的刷新;

改变hash的3种方式:

  1. 浏览器的前进后退
  2. a标签中的跳转
  3. 通过window.location改变hash的值;

以上三种方式可以通过监听hashchange来监听到hash的变化,从而可以修改对应的UI进行显示; location.hash可以获取到当前的hash值

history路由

history是操作浏览器会话历史的对象,url中没有携带#符号;通过history的api修改路由是不会引起页面的刷新;

history的API:

  1. pushState(state,title,url)方法:添加一条历史记录,并且不刷新页面;state:状态对象,在popState事件触发的时候会把state作为参数传递给回调函数;title:新页面的标题;url:新的路由,必须和当前路由为同域,浏览器的地址栏会变为此地址;
  2. replaceState(state,title,url)方法:替换当前的历史记录,不会刷新页面;参数和pushState是一样的;
  3. popState事件: 当历史记录发生变化的时候会触发此事件;pushState和repalceState不会触发此事件;
  4. go方法:向前或向后跳转指定的页数,参数为负数就是向后跳转,参数为正数向前跳转,参数为0表示当前页面;
  5. back方法:向后跳转,和操作浏览器后退的按钮一样;
  6. forward方法:向前跳转,和操作浏览器的前进按钮一样;
  7. length属性: 获取当前历史堆栈中页面的数量;
  8. state属性:获取pushState或repalceState方法传递过来的状态;

改变history路由的三种方式

  1. 浏览器的前进后退按钮,可以触发popState事件
  2. pushState或replaceState方法不会触发popState事件,可以重写这两种方法达到监听的目的;
  3. go,back,forward方法会触发popState事件;

location.pathname可以获取到history的路由值;go,back,forward也可以触发hashchange事件;

Vue-router的实现

vue-router的使用

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const router = new VueRouter({
  mode: 'history',
  [
      {
        path: '/bank',
        name: 'bank',
        component: import('@/views/Bank.vue'),
      },
      {
        path: '/success',
        name: 'success',
        component: import('@/views/Success.vue'),
      },
  ]
})
new Vue({
    router,
    render: h => h(App)
})

从使用上可以看出VueRouter应该是一个类,并且在类上具有install方法;因为通过vue.use作为插件使用;

class VueRouter {
    constructor () {}
}
VueRouter.install = function () {}

在install中主要能够让所有的组件都获取到VueRouter的实例,并且给vue上添加route和router属性;进行初始的操作;

export let Vue

export default function install (_Vue) {
  Vue = _Vue

  // 所有的组件都会执行mixin方法  
  // 为什么是beforeCreate而不是created呢?因为如果是在created操作的话,$options已经初始化好了。
  Vue.mixin({
    beforeCreate () {
      // 根组件
      if (this.$options.router) {
        // 创建一个属性存储当前实例
        this._routerRoot = this
        // 把当前的路由实例存储到当前实例的属性上
        this._router = this.$options.router
        // 进行初始化
        this._router.init(Vue)
        // 把当前的路由信息存储到route上  响应式定义_route属性,保证_route发生变化时,
        // 组件(router-view)会重新渲染
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else { // 子组件
        // 把父级的实例存储到子级的属性上
        this._routerRoot = this.$parent && this.$parent._routerRoot 
      }
    }
  })

  Object.defineProperty(Vue.prototype, '$router', {
    get () {
      return this._routerRoot?._router
    }
  })

  Object.defineProperty(Vue.prototype, '$route', {
    get () {
      return this._routerRoot?._route
    }
  })
}

通过mixin把beforeCreate生命周期钩子混入到每个组件中;在根组件中把当前的实例和路由实例存储到对应的属性上,并且执行init进行初始化操作,给当前的实例上定义_route属性,并且是响应式的;在子组件中通过$parent._routerRoot获取到根组件的实例并且保存到_routerRoot属性上,这样所有的后代组件都可以通过_routerRoot属性获取到根组件的实例,从而就可以获取到路由的实例;在vue的原型上通过代理的形式添加了$router路由的实例和$route当前路由的信息两个属性;

继续分析VueRouter类;

const router = new VueRouter({
  mode: 'history',
  [
      {
        path: '/bank',
        name: 'bank',
        component: import('@/views/Bank.vue'),
        children: [
            {
                path: '/bank/a',
                name: 'bankA',
                component: import('@/views/bankA.vue'),
              },
        ]
      },
  ],
})

传递了两个参数,分别为模式和路由信息,那么在构造器中肯定处理了这两个属性

class VueRouter {
  constructor (options) {
    // 获取到vue中的路由信息
    let routes = options.routes || []
    // 扁平化处理路由数据,并且返回添加和获取路由信息的方法
    this.matcher = createMatcher(routes)
    // 获取当前的路由模式默认是hash
    const mode = options.mode || 'hash'
    // 存放beforeEach钩子
    this.beforeEachHooks = []
    // 不同模式创建不同的实例
    if (mode === 'history') {
      this.history = new H5History(this)
    } else {
      this.history = new HashHistory(this)
    }
  }
}

构造器中获取到路由信息,通过createMatcher进行扁平化处理转换数据结构;获取到mode属性,通过mode属性判断是history还是hash,从而创建不同的类;

export default function createMatcher (routes) {
  const pathMap = createRouteMap(routes)
  function addRoutes (routes) {
    createRouteMap(routes, pathMap)
  }
  function addRoute (routes) {
    createRouteMap([routes], pathMap)
  }
  function match (location) {
    return pathMap[location]
  }

  return {
    addRoutes,
    addRoute,
    match,
  }
}

createMatcher函数内部,通过createRouteMap处理了routes并且返回一个对象;定义了三个方法,分别为addRoutes添加多条路由信息,addRoute添加一条路由信息,match通过路由路径获取到对应的路由信息;addRoutes和addRoute方法就是动态的添加路由;

export default function createRouteMap (routes) {
  let pathMap = pathMap || {}
  routes.forEach(element => {
    addRouteRecord(element, pathMap)
  })
  return pathMap
}

createRouteMap函数内部,遍历路由信息,通过addRouteRecord处理每一条路由信息;最后返回一个对象;

function addRouteRecord (route, pathMap, parentRecord) {
  let path = parentRecord ? `${parentRecord.path === '/' ? '/' : parentRecord.path + '/'}` : '/'
  let record = {
    ...route,
    parent: parentRecord,
    path: path,
  }
  if (!pathMap[path]) {
    pathMap[path] = record
  }
  route.children && route.children.forEach(childRoute => {
    addRouteRecord(childRoute, pathMap, record)
  })
}

addRouteRecord函数,通过父级的parentRecord,解析当前路由的路径,path为带有父级以及祖先级的路径,定义当前的路由信息指定父级和path,通过path作为key存储到pathMap对象中;递归处理children属性,最终的pathMap为如下:

{
    'a/b/': 'a/b/',
    parent: {
        'a/': 'a/'
        ...
    },
    ....
}

以上就是路由信息的处理;处理的目的就是把路由信息进行扁平化处理,找到父级路由信息,找出对应的完成的路径,这样就可以通过地址栏中的路径找到对应的路由信息;下面接着分析根据模式的不同创建对应的实例;

if (mode === 'history') {
  this.history = new H5History(this)
} else {
  this.history = new HashHistory(this)
}

H5History类的实现

// 处理首次进入页面的路由
function ensureSlash () {
  if (window.location.hash) {
    return
  }
  window.location.hash = '/'
}

class H5History extends Base {
  constructor (router) {
    super(router)

    ensureSlash()
  }

  setupListener () {
    // 监听路由的变化
    window.addEventListener('popstate', () => {
      this.transitionTo(window.location.pathname)
    })
  }
  // 获取当前的路径
  getCurrentLocation () {
    return window.location.pathname
  }
  push(location) {
    return this.transitionTo(location, () => {
      // 修改路由
      window.history.pushState({},'',location)
    })
  }
}

H5History类中主要实现了对history路由变化的监听的方法setupListener,获取当前路由信息的方法getCurrentLocation,和路由跳转的方法push;H5History继承至Base类;

export default class Base {
  constructor (router) {
    this.router = router
    // 当前路由的全部路径
    this.current = createRoute(null, {
      path: '/'
    })
  }
  // 
  transitionTo (location, listener) {
    // 通过路径获取到对应的路由信息
    let record = this.router.match(location)
    // 把当前路由转成{matched:[父级路由信息,当前路由信息],path:'/a/b'}的形式
    const route = createRoute(record, { path: location })
    // 判断是否重复进入同一个路由
    if (location === this.current.path && route.matched.length === this.current.matched.length) {
      return
    }
    // 存储对应的生命周期钩子
    let queue = [].concat(this.router.beforeEachHooks)
    // 执行钩子
    runQueue(queue, this.current, route, () => {
      // 保存当前路由信息
      this.current = route
      // 执行修改vue上route的方法进行修改
      this.cb && this.cb(route)
      listener && listener()
    })
  }
  // 保存cb
  listen (cb) {
    this.cb = cb
  }
} 


function createRoute (record, location) {
  const matched = []
  while (record) {
    matched.unshift(record)
    record = record.parent
  }
  
  return {
    ...location,
    matched,
  }
}
// 执行生命周期钩子数组
function runQueue(queue, from ,to, cb){
  function next (index) {
    // 如果钩子数组执行完毕就执行cb回调
    if (index >= queue.length) {
      return cb()
    }
    // 获取到钩子
    let hook = queue[index]
    // 执行钩子,传递上个路由当前路由和next回调
    hook(from, to, () => next(index+1))
  }
  next(0)
}

Base类中有两个方法分别为transitionTo和listen,transitionTo方法主要是通过路径获取到当前的路由信息,把父子路由存放到一个数组中;执行生命周期钩子,修改vue上的route属性,执行回调函数;listen方法主要是保存修改vue上的route属性的方法;


function getHash () {
  return window.location.hash.slice(1)
}
class HashHistory extends Base {
  constructor (router) {
    super(router)
  }
  setupListener () {
    window.addEventListener('hashchange', () => {
      this.transitionTo(getHash())
    })
  }
  // 获取当前的路径
  getCurrentLocation () {
    return getHash()
  }
  push(location) {
    return this.transitionTo(location, () => {
      window.location.hash = location
    })
  }
}

Hash类也是提供了两个方法,通过hashchange监听hash的变化,通过location.hash获取到路由;通过push修改路由;
history和hash都是通过push方法实现路由的跳转,并且其中都是调用了base中的transitionTo方法; 以上就是history和hash类的实现,下面继续分析VueRouter类的init方法;

// 初始化
init (app) {
    let history = this.history
    // 根据路径匹配对应的组件进行渲染,之后进行监听路由的变化
    history.transitionTo(history.getCurrentLocation(), () => {
      // 监听路由
      history.setupListener()
    }) 

    // 路由变化之后重新给_route设置值
    history.listen((newRoute) => {
      app._route = newRoute
    })
}

init方法是在install中的beforeCreate钩子中调用的,主要目的就是调用base类的transitionTo方法在页面首次加载的时候,获取到当前页面的路由,加载对应的组件,调用base的listen方法保存修改vue中的_route属性;

VueRouter中的match,go,back,forward和push方法

 // 匹配路由并且返回路由的信息
match (location) {
    return this.matcher.match(location)
}
push (location) { // 跳转路由
    return this.history.push(location)
}
go (n) {
    window.history.go(n)
}

back () {
    this.go(-1)
}

forward () {
    this.go(1)
}

push方法就是调用对应实例上的push方法,go,back,forward方法调用了history的go方法,hashchange是可以监听到history的go方法修改路由;

Base类,History类和Hash类和VueRouter类之间的关系图

image.png

以上实现了路由的跳转,下面实现路由的组件router-link和router-view

router-link

Vue.component('router-link', {
  props: {
    to: {
      type: String
    },
    tag: {
      type: String
    }
  },
  methods: {
    handler () {
      this.$router.push(this.to)
    }
  },
  render () {
    const tag = this.tag
    return <tag onClick={this.handler}>{this.$slots.default}</tag>
  }
})

router-link很简单,通过render渲染元素,点击事件通过$router实例上的push进行跳转路由

routerView

export default {
  functional: true, // 函数组件
  render (h, { parent, data }) {
    // 标记当前组件为routerView
    data.routerView = true
    // 获取到父级的路由信息
    let route = parent.$route
    // 当前路由的索引
    let depth = 0
    // 循环遍历获取父级
    while(parent){
      // 判断父级上是否有routerView,有索引就加1
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++
      }
      parent = parent.$parent
    }
    //  通过索引获取到当前的路由信息
    let record = route.matched[depth]
    if (!record) {
      return h()
    }
    // 渲染组件
    return h(record.component, data)
  },
}

routerView组件是函数组件;render中给data上添加routerView属性,表示当前组件为routerView组件,获取到$route当前路由信息,向上循环遍历判断父级的data中是否带有routerView属性,带有表示上层具有routerView组件,因此给depth加1,所有的上层都遍历结束,就能找到有几层是routerView组件,因此可以通过depth获取到当前路由对应的组件,从而通过h函数进行渲染;

在Base类中的transitionTo方法中通过createRoute函数处理当前的路由,并且返回以下格式的路由数据;

// a路由下有b路由,b路由下有c路由,并且a和b对应的组件下都有各自的routerView
{
    matched: [{path:'a/',...},{path:'a/b',...},{path:'a/b/c/',...}],
    path: 'a/b/c',
    ...
}

当前跳转到c路由,需要渲染c的组件,因此通过parent.$vnode.data.routerView一层一层的向上找,发现b组件有此属性,Depth加1此时为1,发现a组件也有此属性,depth加1此时为2;通过matched[2]就可以获取到当前c的路由信息;

总结:

  1. 浏览器的前进后退可以通过hashchange或popstate事件监听到
  2. 当调用路由提供的push方法进行路由的修改时,如果是hash模式则通过location.hash修改;history模式通过window.history.pushState修改;
  3. 当路由变化的时候都会去执行Base类提供的transitionTo方法,通过当前的路由获取到对应的路由信息;把路由信息的父子关系存储到一个数组中;修改vue实例上的route属性;route为响应式的;
  4. 修改vue实例上的route属性的时候,routerView组件中就会通过路由信息找到对应的组件,进行会重新渲染对应的组件;

以上1和2都是修改路由,3和4是当路由变化的时候渲染对应的组件

面试题:

如何实现路由权限?

通过路由钩子加上动态路由

导航守卫,从一个路由跳转到另一个路由发生了什么?

  1. 离开当前路由执行组件的beforeRouteLeave守卫
  2. 进入下一个路由之前会触发全局的beforeEach前置守卫
  3. 路由参数变化会执行组件的beforeRouteUpdate守卫
  4. 执行路由独享beforeEnter守卫
  5. 执行组件的beforeRouteEnter守卫
  6. 执行全局的beforeResolve解析守卫
  7. 执行全局的afterEach后置钩子

image.png

hash和history模式的区别?

  1. hash模式中的url携带#号,而history不存在;
  2. hash模式不利于seo搜索

vue-router3和vue-router4的区别?

  1. 原理上:vue-router3中支持ie9,所以hash模式是真正的hash相关的api;而router4中不再支持ie9,因此它里面的hash模式,都是修改地址栏上的url显示为hash模式,而内部实现还是history的api实现的;
export function createWebHashHistory(base?: string): RouterHistory {
// 基础路径处理 
base = location.host ? base || location.pathname + location.search : '' 
// allow the user to provide a `#` in the middle: `/base/#/app` 
// base会再追加一个#符号 
if (!base.includes('#')) base += '#' 
// 看这里----- 
return createWebHistory(base) }
  1. 使用上:创建的方式不同,在setup中没有this,因此需要引入router;router4中没有组件的beforeRouteEnter钩子了;

路由导航守卫和Vue实例生命周期钩子函数的执行顺序?

先路由导航守卫再生命周期钩子