[路飞]_vue2源码_如何实现一个vue-router

138 阅读6分钟

「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战

前言

学习源码能让我们更加深入的了解工具的原理,拓宽我们的知识面,也便于我们在使用中排查问题。

需求分析

今天的目标是实现一个简易版的vue-router。那我们先做一下需求分析,今天希望这个插件能有如下的功能。

  1. 实现router-view组件
  2. 实现router-link组件
  3. 根据当前hash动态的修改router-view展示的内容

那么至于路由的一些钩子,以及嵌套路由这些,我们暂不考虑,先实现一个MVP(最小化可行产品(Minimum Viable Product))。

环境搭建

这步没什么要求,大家随意找一个vue2的项目即可,我直接用vue-cli初始化了一个最简单的项目,结构

安装vue-cli

npm install -g @vue/cli

初始化项目

vue create vue-router-demo

项目结构

image.png

初始化vue-router

vue add router

我们选hash模式,这里输入N image.png

这样目录结构如下,我们看下router下面的index.js

image.png

启动项目

npm run serve

image.png

修改文件

这个时候router都是配好的,我们新建一个文件夹miniRouter,在下面新建一个index.js,将router下面的index.js拷贝过来,修改里面的vueRouter为MiniRouter,并新建一个同级的MiniRouter.js文件

image.png

修改main.js里面对router的引用为miniRouter

image.png

好了,这个时候,准备工作就已经都做好了。

MiniRouter实现

主要框架

  1. 我们使用MiniRouter首先是import MiniRouter from './MiniRouter'。这说明,我们MiniRouter文件一定要export default MiniRouter

  2. MiniRouter使用方式如下,那么显然,MiniRouter一定是一个类

    new MiniRouter({ routes })

  3. 我们有使用Vue.use去注册MiniRouter,那么我们一定要实现一个MiniRouter类的静态方法install。(这里不明白的小伙伴们自行去百度一下,Vue.use做了些什么)

那么基本的MiniRouter.js结构如下

// MiniRouter.js
class MiniRouter {

}

MiniRouter.install = function() {

}

export default MiniRouter;

router-link实现

首先,我们知道router-link最终是渲染成了一个a标签,那么我们肯定要去注册一个router-link组件,然后渲染成一个a标签。

全局注册组件需要使用Vue.component,那么这个Vue从哪里来呢,我们在MiniRouter里面引入vue吗?

当然不用,这样会加大MiniRouter.js的体积。Vue.use调用插件install方法的时候,会传入一个Vue构造函数。我们这里只需要稍加操作将这个Vue构造函数保存下来即可。

然后去初始化router-link组件。

// 提前申明一个Vue变量用于后序保存Vue构造函数
// 这里class里也能正常使用Vue
// 因为我们在路由index.js里先执行的Vue.use(MiniRouter),后执行的new MiniRouter
// 所以install方法是在class的constructor之前执行的
let Vue;

class MiniRouter {

}

MiniRouter.install = function(_Vue) {
    Vue = _Vue;
    Vue.component('router-link',{
        // 这里可以去获取到用户使用router-link标签时传入的属性to
        props: {
            to: {
                type: String,
                require: true
            }
        },
        render(h) {
            return h('a',{
                attrs: {
                    // 将组件的to写入到a标签的href属性里
                    href: '#' + this.to
                }
                // 通过this.$slots.default获取当前router-link组件默认插槽内的文本内容
            }, this.$slots.default)
        }
    })
}

export default MiniRouter;

页面展示如下

可以看到Home和About文案都渲染出来了,href也有了,也可以点击 了。暂不用管router-view的报错,这个还没注册呢。

image.png

router-view实现

router-view组件里面,我们需要去根据当前url的hash,去路由表里匹配到对应的组件,然后渲染出来。那么这里几个关键因素如下

  1. url的hash如何动态获取?保存到哪里?
  2. 路由表去哪拿?
  3. 如何将组件渲染出来?
  4. 点击标签跳转后hash变了,如何让router-view组件响应式的渲染?

我们来一一解答

获取hash并保存

首选,获取hash很简单,直接window.location.hash.slice(1),即可获取到hash的'#'之后的内容。

由于我们是在install方法里面的Vue.component里要使用这个hash,在Vue.component里面,我们可以通过this访问到组件实例,那么组件实例也是vue实例,vue实例则可以访问到Vue构造函数原型上挂载的内容。

那我们就可以曲线救国,先把这个当前hash存放到MiniRouter实例上,这一步在MiniRouter构造函数里即可实现。然后再将MiniRouter实例挂载到Vue构造函数的原型上,这一步后面细说。

class MiniRouter {
    constructor(){
        this.current = window.location.hash.slice(1);
    }
}
挂载MiniRouter实例到Vue构造函数上

接上一步,我们需要将MiniRouter实例挂载到Vue构造函数的原型上,这样router-view组件实例就能获取到这个hash了。MiniRouter实例是在new Vue的时候传进去的,所以我们可以通过this.$options.router获取到MiniRouter实例,即如下:

Vue.prototype.$router = this.$options.router;

那么这里又有个问题,我们在什么时候执行这段代码呢?在install里直接执行吗?

答案是当然不行。因为执行install方法的时候,vue根实例都还没创建,上哪拿$options?所以我们要等vue根实例创建完成,或者至少在创建过程中也行对吧,因为我们知道new一个构造函数的过程中,构造函数内部的this是指向这个新对象的。

这里有个办法,使用Vue.mixin将这段代码混入到beforeCreate钩子中,就是vue实例即将创建完成的时候,这时候我们可以通过this.$options.router拿到路由实例。这里我们还要判断一下,只有根实例创建的时候我们才这样做,因为只有根实例才有router属性,就是new Vue时候传进来的router,我们可以判断当前实例是否含有router属性来判断,。

那么我们现在的代码长这样

class MiniRouter {
    constructor(){
        this.current = window.location.hash.slice(1);
    }
}

MiniRouter.install = function(_Vue) {
    Vue = _Vue;

    Vue.mixin({
        beforeCreate(){
            if(this.$options.router){
                Vue.prototype.$router = this.$options.router;
            }
        }
    })
    
    Vue.component('router-link',{
        // 这里可以去获取到用户使用router-link标签时传入的属性to
        ...
    })
}
获取路由表

我们的路由表是在路由实例创建的时候传进去的,就是MiniRouter文件夹下面index.js里面的这段代码

const router = new MiniRouter({
  routes
})

那么我们可以在MiniRouter类的construtor里面通过options获取到路由表。我们获取到路由表之后,直接将它挂载到路由实例上,方便后序使用。

class MiniRouter {
    constructor(options){
        this.$options = options;
        this.current = window.location.hash.slice(1);
    }
}

由于我们之前已经将MiniRouter路由实例挂载到了Vue构函数的原型上了,那么所有组件实例都能通过this.$router去原型上获取到路由实例,进而也可以获取到路由实例里面的路由表,

那么通过这些操作,我们就成功获取到路由表了,即this.$router.routes

监听hash变化

这个容易,我们可以通过addEventListener去监听hashchange事件,我们获取到hash后,去修改路由实例的current属性即可

class MiniRouter {
    constructor(options){
        this.$options = options;
        this.current = window.location.hash.slice(1);

        window.addEventListener('hashchange',()=>{
            this.current = window.location.hash.slice(1);
        })
    }
}

router-view的组件渲染

这里有个技巧,刚才我们在渲染router-link的时候,使用render函数的h函数,即createElement方法创建了a标签。

其实h函数可以直接传入组件模板并渲染出来,大家可以试一下

let Vue;
// 在MiniRouter.js最上面引入入Home组件
import Home from '../views/Home'

...
MiniRouter.install = function(_Vue) {
    Vue = _Vue;

    ...
    
    Vue.component('router-view',{
        render(h) {
            // 这里直接传入Home组件模板
            return h(Home)
        }
    })
}

那么这就给了我们一个思路了,我们可以在注册router-view组件的时候,通过路由实例里面存放的当前hash去路由表里匹配出应该渲染的组件模板,然后传入h函数即可。

class MiniRouter {
    constructor(options){
        this.$options = options;
        this.current = window.location.hash.slice(1);

        window.addEventListener('hashchange',()=>{
            this.current = window.location.hash.slice(1);
        })
    }
}

MiniRouter.install = function(_Vue) {
    Vue = _Vue;

    ...
    
    Vue.component('router-view',{
        render(h) {
            let component = null;
            // 这里this是当前组件实例
            // this.$router是Vue构造函数原型上的路由实例
            let current = this.$router.current;
            // this.$router.$options是我们上面在MiniRouter类的constructor里面绑在路由实例上的
            let route = this.$router.$options.routes.find((route)=>{
                return route.path === current
            })
            component = route.component;
            return h(component)
        }
    })
}

刷新页面,组件已经出来了!!!

但是

点击Home和About,hash能变化,可页面不能切换。。。

我们进入下一个任务,go

image.png

当前hash的响应式处理

我们之所以点击Home和About,组件不能切换,是因为,Vue.component去注册router-view的render函数,只执行一次,执行完之后,它就不会再执行了。

那我们希望,只要current变化,router-view组件就能重新渲染一遍,听到这个是不是很耳熟,这不就是vue的数据响应式么。

我们只需要把路由实例的current属性变成响应式数据,那么所有使用current的组件都被vue自动的当做依赖收集起来,当current变化的时候,vue会去通知所有的依赖更新。

那我们用什么方法将属性变成响应式数据呢?

Object.defineProperty可以吗?

不行,因为这只能给该属性加一个数据劫持,就是拦截它的读取,但是我们需要的是修改后能触发依赖的更新,它做不到。

那Vue.set可以吗?众所周知它可以为对象添加响应式属性

但是不行,因为Vue.set(object, propertyName, value),需要object本来就是响应式的。

其实vue提供了一个很好用的创建响应式数据的方法,Vue.util.defineReactive,用该方法可以给某个对象注册一个响应式属性,那我们试一试,修改一下MiniRouter的constructor方法

class MiniRouter {
    constructor(options){
        this.$options = options;

        Vue.util.defineReactive(this, 'current', window.location.hash.slice(1))
        window.addEventListener('hashchange',()=>{
            this.current = window.location.hash.slice(1);
        })
    }
}

你们再试一下,点击Home和About看能不能切换!!

总结

今天实现的这个MiniRouter,是一个简之又简的简版,旨在大概跑一遍vue-router是如何实现组件注册,响应式路由切换的。真正的vue-router比这个复杂的多。

大家有兴趣可以跟着敲一遍,感受一下。

完成代码如下

// MiniRouter.js
let Vue;

class MiniRouter {
    constructor(options){
        this.$options = options;

        Vue.util.defineReactive(this, 'current', window.location.hash.slice(1))
        window.addEventListener('hashchange',()=>{
            this.current = window.location.hash.slice(1);
        })
    }
}

MiniRouter.install = function(_Vue) {
    Vue = _Vue;

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

    Vue.component('router-link',{
        // 这里可以去获取到用户使用router-link标签时传入的属性to
        props: {
            to: {
                type: String,
                require: true
            }
        },
        render(h) {
            return h('a',{
                attrs: {
                    // 将组件的to写入到a标签的href属性里
                    href: '#' + this.to
                }
                // 通过this.$slots.default获取当前组件默认插槽内的文本内容
            }, this.$slots.default)
        }
    })

    Vue.component('router-view',{
        render(h) {
            let component = null;
            let current = this.$router.current;
            let route = this.$router.$options.routes.find((route)=>{
                return route.path === current
            })
            component = route.component;
            return h(component)
        }
    })
}

export default MiniRouter;