如何实现一个简版的vue-router

526 阅读3分钟

个人觉得学习一门框架,想要快速掌握其底层原理最好的办法就是去实现一个简版的、功能一样的东西,那我们今天就来学习一下vue-router的底层原理,并尝试着手写一个简版的vue-router。

平时我们是这么使用路由的

code.png

此时我们会思考

  1. vue-router这个模块内部做了什么?
  2. Vue.use(VueRouter)是做什么的?

先把答案放在这里:

第一问:
1. vue-router 实现了一个install方法,因为vue-router也是一个插件,既然是插件,想要通过Vue.use()的方式进行注册,就得有一个install方法。
2. vue-router 实现并声明了两个全局组件(router-view和router-link),还有一些其他的实例方法,如:$router.push()等

第二问:Vue.use()会调用传入插件中的install方法,去注册vue-router。

接下来我们就根据答案,去实现vue-router中的相关功能。

为了让大家更好的理解,本文更多的是代码与注释的结合,毕竟这个东西用文本的方式讲述不太容易讲清楚,还是代码来的最实在。

一、创建构造函数

首先,创建VueRouter构造函数

// router/vue-router.js

let Vue; // 用于保存Vue实例
class VueRouter {
  constructor(options) {
    // 此处的this.$options是指向VueRouter实例的,值为new时传入的参数
    this.$options = options;
  }
}

此处的this.$options是指向VueRouter实例的,值为new时传入的参数,如下图指向,我们后面会用到其中的routes集合

image.png

二、创建install方法

创建install方法的目的是为了让Vue.use()调用此方法,去注册vue-router,并且实现相关功能

// router/vue-router.js

// Vue.use会调用该方法,注册VueRouter,其中参数_Vue是Vue.use调用时传入的,即Vue实例
VueRouter.install = function(_Vue) {
  Vue = _Vue;  // 将Vue实例保存到全局,方便其他地方使用

  // 挂载$router属性到Vue原型上
  mountRouter();

  // 实现router-link组件
  mountRouterLink();

  // 实现router-view组件
  mountRouterView();
};

此处,我们能够获取到Vue.use(vueRouter)传进来的Vue实例,我们将其保存到全局,这样其他地方就能很方便的使用到Vue实例了,切记,此处只是在初始化阶段,所以还无法获取到router实例。

接下来我们要在install中实现以下三个方法:

  1. 挂载$router属性到Vue原型上(mountRouter)
  2. 实现router-link组件(mountRouterLink)
  3. 实现router-view组件(mountRouterView)

1. 挂载$router属性到Vue原型上

为什么要挂载$router属性?是因为我们在通过router-view组件渲染路由时,要用到路由器对象里的信息,要根据不同的路由地址渲染不同的路由页面内容。

image.png

挂载$router属性到Vue原型上的实现方法如下:

// router/vue-router.js

function mountRouter() {
  // 全局混入目的:延迟下面逻辑到router创建完毕并且附加到选项上时才执行
  Vue.mixin({
    // 该钩子在每个组件创建实例时都会被调用,所以会被执行多次
    beforeCreate() {
      // 1.只有当执行到根实例时,才能获取到router属性,因为只有根实例才有该选项
      // 2.所以此处我们要进行相关判断,如果发现$option上有router属性,就证明vue-router已经挂载完成
      if (this.$options.router) {        
        // 此处的this.$options是指向的main.js中的new Vue实例的
        // 我们将其挂载到之前全局声明的Vue变量上,这样其他地方就能使用router实例上的信息了
        Vue.prototype.$router = this.$options.router; 
      }
    }
  });
}

由于当Vue.use()注册vue-router时,我们还无法在此处的Vue上获取到完整的router实例,所以我们这里使用全局混入的方式,去延迟获取main.js中的new Vue那个实例上的router实例,因为只要执行到main.js中的new Vue()那一步时,我们的vue-router已经挂载完成,就能获取到router实例。此时我们可以打印下this.$option加以验证

image.png

2. 实现router-link组件

我们平时会通过router-link进行路由间跳转,router-link的底层实现,实际上就是a标签,只不过它是通过锚点(#)的形式进行跳转的,实际上页面并没有改变,只是通过渲染不同的路由页面达到跳转的效果,实现方法如下:

// router/vue-router.js

function mountRouterLink() {
  // 注册全局组件 router-link
  Vue.component("router-link", {
    props: {
      // to就是<router-link to="xxx"></router-link>上的属性,接收传进来的路由地址
      to: {
        type: String,
        required: true
      }
    },
    render(h) {
      // 这里是通过vue中的render函数来进行渲染的,为什么不使用template模板的方式来进行渲染呢
      // 因为此处没有编译器,我们知道要想在vue中使用模板的方式,必须得有编译器才行
      // 不过此处除了通过render函数来渲染,也可以使用jsx的方式来实现
      // 即:return <a href={'#'+this.to}>{this.$slots.default}</a>
      return h(
        "a", {
          attrs: {
            href: "#" + this.to
          }
        },
        this.$slots.default
      );
    }
  });
}

我们试着在页面中使用一下自己写的router-link组件,看看有没有达到预期的效果

image.png

image.png

image.png

我们可以看到,我们通过点击不同路由,路由地址确实发生了相应的变化。那么问题来了,如何实现点击不同路由,渲染对应的路由页面内容呢?接下来就来实现最重要的router-view组件。

3. 实现router-view组件

我们都知道router-view的作用是根据路由地址,渲染不同的路由页面内容,那么它底层是怎么实现的?实现方式如下

// router/vue-router.js

function mountRouterView() {
  // 注册全局组件 router-view
  Vue.component("router-view", {
    // 一样是通过render函数进行相关渲染
    render(h) {
    
      // 当前定位的路由名称
      let component = null;
      
      // 将当前定位的路由名称,在VueRouter的实例上进行查找与对比
      const route = this.$router.$options.routes.find(
        // this.$router.current指当前定位的路由名称
        route => route.path === this.$router.current
      );
      
      // 如果当前定位的路由在之前声明的routes列表里有
      if (route) {
        component = route.component;
      }
      
      // 那么就将对应的组件页面返回,并且通过render函数渲染到页面上
      return h(component);
    }
  });
}

这里问题就来了,这里的当前定位的路由名称该怎么获取呢?我们是不是可以通过监听url地址栏的变化,然后进行截取相关路由字段,是不是就能获取到当前定位的路由了呢?

没错,接下来就在VueRouter构造函数中来实现监听吧

// router/vue-router.js

let Vue; // 用于保存Vue实例
class VueRouter {
  constructor(options) {
    // 此处的this.$options是指向VueRouter实例的,值为new时传入的参数
    this.$options = options;
    
    // 监听hash变化
    window.addEventListener("hashchange", () => {
      // current就是变化后的路由名称
      let current = window.location.hash.slice(1);
    });
  }
}

但是问题又来了,我虽然获取到了变化后的路由名称,但是怎么能够在监听到路由变化的同时,去动态渲染对应的路由页面内容呢?有的同学就会想,直接调用之前的mountRouterView方法,这样就能实现动态渲染了,但是有没有想到一个问题,如果每次调用mountRouterView方法,是不是就每次都创建了一个router-view组件呢,我们知道全局只能有一个router-view组件,很显然这种方法是不可行的。

我们可不可以这样,利用vue的双向绑定这个特性去实现,我们将这个路由名称变量加上双向绑定特性,当该值发生改变时,我们的mountRouterView方法中的render函数是不是就可以动态的进行渲染了(这里render函数有个内置的特性,就是只要监听到router实例上有发生任何改变,都会重新进行渲染)?

我们继续在VueRouter构造函数中去实现

// router/vue-router.js

let Vue; // 用于保存Vue实例
class VueRouter {
  constructor(options) {
    // 此处的this.$options是指向VueRouter实例的,值为new时传入的参数
    this.$options = options;
    
    // 把current作为响应式数据,指当前所在路由名称
    // 将来发生变化,router-view的render函数能够再次执行
    const initial = window.location.hash.slice(1) || "/";
    // 将一个变量添加双向绑定特性
    Vue.util.defineReactive(this, "current", initial);
    
    // 监听hash变化
    window.addEventListener("hashchange", () => {
      // 这里的current值一发生改变,$router实例上的current也会发生改变
      // 进而使router-view的render函数能够再次执行
      this.current = window.location.hash.slice(1);
    });
  }
}

只要有了双向绑定特性的current属性发生了变化,router-view就会重新进行渲染新的路由页面内容,以达到页面跳转的效果。我们来试试效果吧

image.png

image.png

image.png

这样,我们就基本实现了vue-router的功能,是不是觉得很神奇,原来vue-router的底层原理如此简单。当然更多的路由实例方法我这里也没有去实现,有兴趣的同学,可以自己去尝试实现一下。

好了,以上就是如何实现一个简版的vue-router,当然,跟原版的vue-router比起来肯定还是有差异的,有些地方可能讲的不对,还请大家做出指正,谢谢大家!