手写简易版vue-router(history模式)

340 阅读6分钟

VueRouter的实现(history模式)

首先来看一下VueRouter的简单的使用过程来推断一下它大概是一个什么东西👇

1622968349178.png

然后在main.js页面:

1622968424658.png

可以看出来,在引入VueRouter之后,先使用Vue.use注册了这个插件。use方法中可以传入一个包含install方法的类或者是一个函数:当传入类时会调用install方法;当传入函数时会直接执行这个函数。下面new操作符就直接告诉我们VueRouter是一个包含install方法的类。

这边是VueRouter的一个类图,里面包含了它内部的属性(中间的部分)和方法(下面的部分):

1622968605864.png

各个属性的意思:

  • options

    记录传入构造函数的对象;

  • routeMap

    一个对象,记录路由与组件之间的对应关系,它是由路由规则中解析出来的;

  • data

    一个对象,里面有一个current属性,它是用来记录当前路由地址。

    data对象是一个响应式的对象,它可以使用observable方法来创建:当路由发生变化时,会更新对应的组件。

各个方法的意思:

  • Constructor

    构造器;

  • install

    静态方法,它是用来实现Vue的插件机制;

  • init

    用来调用下面的几个方法;

  • initEvent

    用来注册popstate这个事件,监听浏览器历史的变化;

  • createRouteMap

    初始化routeMap属性,将options里面的路由规则按照键值对的形式将路由与组件存入routeMap属性里;

  • initComponents

    创建router-view以及router-link组件。

明白了这属性及方法,就可以来实现这些方法以及VueRouter

准备工作

先创建一个空vue2.x版本的项目,在src文件夹下创建一个vueRouter文件夹里面有index.js这样一个文件用来书写我们的router

这个js文件里面先简单导出默认成员,该成员是一个类,类里面包含install这个静态方法:

// VueRouter
export default class VueRouter {
  static install() {

  }
}

router文件夹下的index.js文件里面VueRouter的引入改成刚刚创建的:

1622975400353.png

这样就可以来慢慢编写自己的代码以满足需求。

注意,由于需要在initComponents方法里面使用模板创建组件,所以需要将项目中runtime onlyvue替换成完全版的(包含compiler)。通过vue-cli创建的项目可以在根目录下创建一个vue.config.js,在导出的成员中配置如下:

module.exports = {
    runtimeCompiler: true
}

或者在使用runtime only版本的vue时,使用render函数替代tamplate模板(在下面创建组件时有示例)。

install方法

在使用use方法时,会向install方法里面传传入两个参数,一个是Vue对象,一个是可选配置对象(此时并不需要,所以可以不用写):

export default class VueRoute {
    static install(Vue) {
        
    }
}

install方法里面要做的事情有这些:

  1. 判断当前插件有没有安装过;

  2. Vue构造函数记录到全局变量中;

    因为当前的install是一个静态方法,在后面VueRouter中的实例方法中还需要使用到传入的Vue的构造函数(创建router-viewrouter-link组件时需要调用Vue.component方法来创建)。

  3. 把创建Vue实例时传入的router对象注入到所有的Vue实例上;

    开发中使用的this.$router是在这个时候注入到实例上的。

实现起来就是这样:

// VueRouter
// 初始化
let _Vue = null

export default class VueRouter {
  static install(Vue) {
    // 1.判断VueRouter是否已经安装

    // 若想判断该插件是否已经被安装,这就需要有个变量来记录该插件安装与否
    // 在js中万物皆对象,所以可以将该变量挂载到这个函数下
    // ⛔不使用局部变量或全局变量的原因:
    //  (1)这个变量肯定不能为局部变量,
    //  (2)因为会有外部的依赖,所以全局变量也不行

    // 若已经安装就直接返回
    if (VueRouter.install.installed) {
      return
    }
    // 没有安装的话就在install方法下增加一个installed属性,值设置为true
    VueRouter.install.installed = true

    // 2.将vue构造函数记录到全局
    _Vue = Vue

    // 3.将创建的Vue实例时传入router注入到Vue实例上

    // 所有的组件都是一个实例,若想要所有的实例共享一个成员,可以将其设置到构造函数的原型上
    // 但是此时无法获取到Vue实例,所以还不可以使用_Vue.prototype.$router = this.$options.router这样的方式
    // 因此我们需要在能够获取到Vue实例的时候使用这个方式
    // 所以可以使用vue中的混入(mixin),给所有的组件混入一个beforeCreate,这样就可以在其中获取到Vue实例
    _Vue.mixin({
      beforeCreate() {
        // 这里有一个问题是:
        // 一个项目中会有很多个组件,并且所有的组件都会执行_Vue.prototype.$router = this.$options.router这句代码
        // 而这句代码实际上只是需要执行一次就行了
        // 所以可以判断一下当前的this是否有$options.router这个属性
        // 因为只有vue中有这个属性,组件里面是没有这个属性的
        if (this.$options.router) {
          // 这里的this指向的是当前的vue实例,所以就可以使用上面的方式来注入router
          _Vue.prototype.$router = this.$options.router
        }
      }
    })
  }
}

constructor

constructornew的时候执行的方法,这里主要就是初始化上面类图中的几个属性:

// 构造器
constructor(options) {
  // 根据之前的类图,构造器里面是用来初始化一些属性的
  // 所以可以在这里将options、routeMap和data进行初始化
  this.options = options
  this.routeMap = {}
  // 由于data是需要响应式的,所以可以使用vue的observable方法来创建对象
  this.data = _Vue.observable({
    // 当前路由,默认是/
    current: '/'
  })
}

createRouteMap

createRouteMap方法会将创建实例时传入的路由规则转换成由路由和组件组成的键值对形式,并将其存在routeMap中:

createRouteMap() {
    // 遍历routes,将path属性作为键、component属性作为值存入routeMap之中  
    this.options.routes.forEach(route => {
        this.routeMap[route.path] = route.component
    })
}

initComponents

initComponents方法会创建router-link以及router-view组件:

router-link

使用带有compilervue创建router-link组件:

initComponents(Vue) {
    Vue.component('router-link', {
        props: {
            to: String
        },
        template: '<a :href="to"><slot/></a>'
    })
}

使用runtime onlyvue创建router-link组件:

initComponents(Vue) {
    Vue.components('router-link', {
        props: {
            to: String
        },
        // h函数是vue传入的函数,它能将其中的结构转换成虚拟dom
        // h函数第一项是选择器,可以为标签名
        // 第二项可给标签添加属性
        // 第三项给标签设置子元素
        render(h) {
          return h('a', {
            attrs: {
              href: this.to
            }
            // this.$slots.default获取默认插槽的内容
          }, [this.$slots.default])
        }
    })
}

init方法

这个时候如果想要看看router-link有没有成功,将其调用一下,同时再把createRouteMap方法也调用一下:

// 在这个方法里统一调用要在初始化时调用的方法
  init() {
    this.createRouteMap()
    this.initComponents(_Vue)
  }

同时在将router注入到vue实例上时调用init方法:

static install(Vue) {
    if (VueRouter.install.installed) {
      return
    }
    VueRouter.install.installed = true

    _Vue = Vue
    
    _Vue.mixin({
      beforeCreate() {
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router

          // 在这里调用初始化方法
          this.$options.router.init()
        }
      }
    })
  }

运行一下项目,可以看到超链接已经能够在页面上展示了:

1623159475205.png

而且在HTML结构中也能看到a标签:

1623159533912.png

router-view

router-view是需要根据当前路由来展示对应的组件,所以可以通过之前routeMapdata里面的current属性来获取到当前要展示的组件,同时通过render函数渲染成虚拟dom

initComponents(Vue) {
    Vue.component('router-link', {
      props: {
        to: {
          type: String,
        }
      },
      render(h) {
        return h('a', {
          attrs: {
            href: this.to
          }
        }, [this.$slots.default])
      }
    })

    // 保存一下当前的this,以便在组件中访问routeMap以及data
    const self = this
    // router-view是需要根据当前路由来展示对应的组件
    Vue.component('router-view', {
      render(h) {
        // 通过routeMap以及data.current取得当前组件,并渲染成虚拟dom
        const component = self.routeMap[self.data.current]
        return h(component)
      }
    })
  }

这个时候刷新一下项目就会发现,对应的组件已经能展示出来了:

1623159702081.png

但是点击一下切换路由的超链接就会发现一个问题:当点击About这个链接想要切换到about页面时页面并不会变化,但是浏览器会有一次刷新。

这是因为点击超链接时会向服务器发送一次请求,但是在像vue这种单页面应用程序中并不需要像服务器发送请求。因此需要再给超链接注册点击事件,阻止这种默认行为,同时再将地址栏中的链接替换成对应的路由。

为了解决这个问题,可以使用浏览器的historypushState方法: pushState

这个方法只会改变浏览器中的地址,组件的切换还需要自己动手。

Vue.component('router-link', {
      props: {
        to: {
          type: String,
        }
      },
      render(h) {
        return h('a', {
          attrs: {
            href: this.to
          },
          // 给超链接注册点击事件,阻止超链接的默认跳转行为,再实现浏览器地址栏路由的切换以及组件的切换
          on: {
            click: this.handleDefaultBehavior
          }
          // this.$slots.default获取默认插槽的内容
        }, [this.$slots.default])
      },
      // 方法都可以写在这里
      methods: {
        // 传入事件对象
        handleDefaultBehavior(e) {
          // 切换浏览器的地址栏
          history.pushState({}, '', this.to)
          // 将data中的current改变成to中的地址,以便组件的切换
          this.$router.data.current = this.to
          e.preventDefault()
        }
      }
    })

initEvent

这个时候主要的功能都已经实现了,点击切换路由时地址栏中的地址以及组件也会随之切换,但是还有个问题就是:当点击浏览器自带的前进后退按钮时,页面并不会随着地址变化。

所以需要给window添加popstate事件监听(popstate),当地址栏中的地址发生变化时,将其地址赋值给data.current

// initEvent是给window添加事件监听popstate,在地址栏发生变化时将地址赋值给data.current
  initEvent() {
    window.addEventListener('popstate', () => {
      this.data.current = window.location.pathname
    })
  }

这样在点击前进后退时,组件也能正常切换了。

代码整理

// VueRouter
// 初始化
let _Vue = null

export default class VueRouter {
  // use调用install方法时,会传入一个Vue的构造函数
  static install(Vue) {
    // 1.判断VueRouter是否已经安装

    // 若想判断该插件是否已经被安装,这就需要有个变量来记录该插件安装与否
    // 在js中万物皆对象,所以可以将该变量挂载到这个函数下
    // ⛔不使用局部变量或全局变量的原因:
    //  (1)这个变量肯定不能为局部变量,
    //  (2)因为会有外部的依赖,所以全局变量也不行

    // 若已经安装就直接返回
    if (VueRouter.install.installed) {
      return
    }
    // 没有安装的话就在install方法下增加一个installed属性,值设置为true
    VueRouter.install.installed = true

    // 2.将vue构造函数记录到全局
    _Vue = Vue

    // 3.将创建的Vue实例时传入router注入到Vue实例上

    // 所有的组件都是一个实例,若想要所有的实例共享一个成员,可以将其设置到构造函数的原型上
    // 但是此时无法获取到Vue实例,所以还不可以使用_Vue.prototype.$router = this.$options.router这样的方式
    // 因此我们需要在能够获取到Vue实例的时候使用这个方式
    // 所以可以使用vue中的混入(mixin),给所有的组件混入一个beforeCreate,这样就可以在其中获取到Vue实例
    _Vue.mixin({
      beforeCreate() {
        // 这里有一个问题是:
        // 一个项目中会有很多个组件,并且所有的组件都会执行_Vue.prototype.$router = this.$options.router这句代码
        // 而这句代码实际上只是需要执行一次就行了
        // 所以可以判断一下当前的this是否有$options.router这个属性
        // 因为只有vue中有这个属性,组件里面是没有这个属性的
        if (this.$options.router) {
          // 这里的this指向的是当前的vue实例,所以就可以使用上面的方式来注入router
          _Vue.prototype.$router = this.$options.router

          // 在这里调用初始化方法
          this.$options.router.init()
        }
      }
    })
  }

  constructor(options) {
    // 根据之前的类图,构造器里面是用来初始化一些属性的
    // 所以可以在这里将options、routeMap和data进行初始化
    this.options = options
    this.routeMap = {}
    // 由于data是需要响应式的,所以可以使用vue的observable方法来创建对象
    this.data = _Vue.observable({
      // 当前路由,默认是/
      current: '/'
    })
  }

  // 在这个方法里统一调用要在初始化时调用的方法
  init() {
    this.createRouteMap()
    this.initComponents(_Vue)
    this.initEvent()
  }

  // createRouteMap方法会将创建实例时传入的路由规则转换成由路由和组件组成的键值对形式,并将其存在routeMap中
  createRouteMap() {
    // 遍历routes,将path属性作为键、component属性作为值存入routeMap之中
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    })
  }

  initComponents(Vue) {
    // 使用component方法创建RouterLink组件
    // router-link组件的本质上就是一个超链接(a标签)
    Vue.component('router-link', {
      props: {
        to: {
          type: String,
        }
      },
      // template: '<a :href="to"><slot/></a>'
      // h函数是vue传入的函数,它能将其中的结构转换成虚拟dom
      // h函数第一项是选择器,可以为标签名
      // 第二项可给标签添加属性
      // 第三项给标签设置子元素
      render(h) {
        return h('a', {
          attrs: {
            href: this.to
          },
          // 给超链接注册点击事件,阻止超链接的默认跳转行为,再实现浏览器地址栏路由的切换以及组件的切换
          on: {
            click: this.handleDefaultBehavior
          }
          // this.$slots.default获取默认插槽的内容
        }, [this.$slots.default])
      },
      // 方法都可以写在这里
      methods: {
        // 传入事件对象
        handleDefaultBehavior(e) {
          // 切换浏览器的地址栏
          history.pushState({}, '', this.to)
          // 将data中的current改变成to中的地址,以便组件的切换
          this.$router.data.current = this.to
          e.preventDefault()
        }
      }
    })

    // 保存一下当前的this,以便在组件中访问routeMap以及data
    const self = this
    // router-view是需要根据当前路由来展示对应的组件
    Vue.component('router-view', {
      render(h) {
        // 通过routeMap以及data.current取得当前组件,并渲染成虚拟dom
        const component = self.routeMap[self.data.current]
        return h(component)
      }
    })
  }

  // initEvent是给window添加事件监听popstate,在地址栏发生变化时将地址赋值给data.current
  initEvent() {
    window.addEventListener('popstate', () => {
      this.data.current = window.location.pathname
    })
  }
}