vue-router原理解析及实现

1,787 阅读5分钟

本文会从通过对vue-router的原理解析,实现一个简易版本的vue-router。

我们在页面中使用vue-router一般是这样的:

    <!--这是App.vue组件-->
    
    <div>
        <div>
            <!--这里放导航-->
            <router-link to="/home">home</router-link>
            <router-link to="/about">about</router-link>
        </div>
        
        <!--这里是导航对应的组件要渲染的地方-->
        <router-view></router-view>
    </div>
  • 当我们点击home导航,<router-view></router-view>里就会展示home组件对应的内容
  • 当我们点击about导航,<router-view></router-view>里就会展示about组件对应的内容

基本原理

<router-link to="/home">home</router-link>
<router-link to="/about">about</router-link>

会被解析为

<a href="#/home">home</a>
<a href="#/about">about</a>

当我们点击<a href="#/home">home</a>时,页面链接的hash就变成了#/home,这会触发hashchange事件,我们通过监听hashchange事件,可以拿到当前页面的hash变为了#/home,然后可以通过/home拿到其对应的组件,再将组件放到<router-view></router-view>组件里渲染出来就可以了。

如何通过/home拿到对应的组件呢,因为我们创建router实例的时候会先创建一个类似下面routes的路由配置对象,通过该对象可以拿到当前路由对应的组件。

const routes = [
    { path: '/home', component: Home },
    { path: '/about', component: About }
  ]

以上就是从路由变化到路由跳转的一个基本过程。

源码实现

我们通过代码来实现上面的这个简易版vue-router

当我们使用vue-router的时候,一般是下面这样使用的:

import Vue from 'vue';
import vueRouter from 'vue-router';

import Home from './components/App';
import Home from './components/Home';
import About from './components/About';

// vue-router是插件,需要使用Vue.use
Vue.use(vueRouter);

// 创建路由配置对象
const routes = [
    { path: '/home', component: Home },
    { path: '/about', component: About }
  ]
  
// 创建 router 实例,然后把routes传进去
const router = new vueRouter({
    routes
})

// 创建Vue实例,把router实例也传进去
new Vue({
    router,
    render: h => h(App)
}).$mount('#app')

需要做的事情:

  • 1、首先vue-router作为一个插件,需用有一个install方法,因为Vue.use(vueRouter)的时候,会去找vueRouter里的install方法,并执行。
  • 2、需要定义两个全局组件router-link 和 router-view
  • 3、我们在组件里会使用this.$router来拿到router实例,所以需要在合适的时机将router实例挂载到Vue.prototype上。
  • 4、需要监听hash的变化,并在变化的时候根据hash更新router-view里的组件。

实现代码如下:

(具体解析请看代码里的注释)

let vue;
class Router {
    constructor({ routes }) {
    
        // 在router实例上定义一个current属性,用来保存页面的hash
        // 并且使用Vue提供的defineReactive方法,让这个属性变成是可响应的,
        // 所有依赖了这个属性的组件会被依赖收集起来,当这个属性变化的时候,依赖它的组件就会更新
        
        // router-view组件里面引用了router实例的current属性,
        // 所以当current变化的时候,router-view组件会重新渲染,
        // 重新渲染的时候,会根据当前的hash拿到其对应的组件并渲染
        vue.util.defineReactive(this, 'current', '/');

        // 监控hash变化,hash变化的时候更新current,从而触发router-view组件的重新渲染
        window.addEventListener('hashchange', this.onHashChange.bind(this));
        window.addEventListener('load', this.onHashChange.bind(this));

        // 创建一个路由映射表,用于让router-view重新渲染的时候,
        // 更方便的可以根据hash拿到对应的要渲染的组件
        this.routesMap = {};
        for (let {path, component} of routes) {
            this.routesMap[path] = component;
        }
    }
    
    // hash变化的时候,将hash设置到current属性上
    onHashChange() {
        this.current = window.location.hash.slice(1);
    }
}

Router.install = function(_vue) {
    vue = _vue;

    // 混入一个beforeCreate钩子函数,将router实例挂载到vue.prototype上,
    // 这样所有vue实例都能通过this.$router拿到路由实例,
    // 为什么要使用混入的方式呢?
    // 因为Vue.use的时候,router实例还没创建,
    // 所以使用混入在beforeCreate的时候,再将router实例挂载到Vue的原型对象上
    vue.mixin({
        beforeCreate() {
            this.$options.router 
            && (vue.prototype.$router = this.$options.router);
        }
    });

    // 创建 router-link 全局组件
    vue.component('router-link', {
        props: ['to'],
        render(h) {
            return h
                (
                    // 创建a标签
                    'a', 
                    
                    // 设置href属性为组件上的to属性
                    { attrs: {href: '#' + this.to} }, 
                    
                    // a标签内容设置为组件默认插槽的内容
                    this.$slots.default
                )
        }
    });
    
    // 创建 router-view 全局组件
    vue.component('router-view', {
        // 在组件内我们可以通过 this.$router 获取路由实例
        // 通过this.$router.current获取当前页面的hash
        // 通过this.$router.routesMap获取路由映射表
        // 所以通过 this.$router.routesMap[this.$router.current] 就能获取该hash对应的组件
        // 因为this.$router.current这个属性是响应式的
        // 所以每当hash变化的时候,current就会变化,就会触发router-view重新执行渲染函数拿到新的hash对应的组件,并渲染
        render(h) {
            let com = this.$router.routesMap[this.$router.current];
            return h(com);
        }
    });
}

export default Router;

以上就是vue-router的基本原理,下面补充下其它功能

路由嵌套

我们在写路由的时候有时候会写嵌套路由,配置对象类似下面这样,我们在/about路由下添加了children属性,保存其子路由:

import Home from './components/Home';
import About from './components/About';
import RouterLearn from './components/RouterLearn';

const routes = [
    { path: '/home', component: Home },
    { 
        path: '/about',
        component: About, 
        // 子路由
        children: [
            {
                path: 'routerLearn',
                component: RouterLearn
            }
        ]
    }
  ]

单文件组件里面会这么引用:

App.vue组件

<template>
    <div>
        <p>这是App组件</p>
        <div>
            <router-link to="/home">home</router-link>
            <router-link to="/about/routerLearn">about/routerLearn</router-link>
        </div>
        <router-view></router-view>
    </div>
</template>

About组件

<template>
    <div>
        这是about组件
        <router-view></router-view>
    </div>
</template>

当我们点击<router-link to="/about/routerLearn">routerLearn</router-link>导航时,页面的hash会变成#/about/routerLearn,所以这个时候要实现的是App.vue组件里的<router-view></router-view>渲染的是About组件,About组件里的<router-view></router-view>里面渲染的是routerLearn组件。

如何让<router-view></router-view>知道自己该渲染的是哪个组件呢,实现方法是:

  • 1、给每个router-view组件计算出自己的深度,最外层的router-view其深度为0,如果router-view渲染的组件里又有router-view,那么这个嵌套的router-view深度就是1,计算方法如下:
    vue.component('router-view', {
        render(h) {
        
            // 如果是router-view组件,就在实例上设置一个标识
            this.routerView = true;

            // 获取当前router-view的深度,初始值为0
            let depth = 0;
            let parent = this.$parent; // 父组件实例
            
            // 向上递归查找父组件,如果找到depth就+1,最终计算出的depth即为该router-view的深度
            while (parent) {
               if (parent.routerView) depth++;
                parent = parent.$parent;
            }

            // 根据当前router-view的深度,从匹配的路由中拿到对应的组件
            let com = this.$router.matched[depth].component;

            return h(com);
        }
    });
  • 2、根据路由配置对象以及当前页面的hash,计算出匹配当前页面hash的路由数组,计算方法如下:
// 路由配置对象是这样的
const routes = [
    { path: '/home', component: Home },
    { 
        path: '/about',
        component: About, 
        // 子路由
        children: [
            {
                path: 'routerLearn',
                component: RouterLearn
            }
        ]
    }
  ]
  
  // 当前的页面hash为 #/about/routerLearn
  let current = '#/about/routerLearn';
  
  // 得到当前hash匹配的路由数组
  // 这里matched会得到 [About路由配置对象, RouterLearn路由配置对象]
  let matched = match(routes);

    // 递归遍历routes,获取当前hash匹配的路由
    function match(routes) {
        for (const route of routes) {
            if (this.current.indexOf(route.path) !== -1) {
                this.matched.push(route);
                if (route.children) {
                    this.match(route.children);
                }
            }
        }
    }
  • 3、计算出router-view的深度,以及拿到matched数组后,就能知道每个router-view该渲染哪个组件了,需要渲染的组件为matched[depth].component

实现了嵌套路由的简易版vue-router代码如下:

let vue;
class Router {
    constructor({ routes }) {
        // 实例上保存一下路由配置对象
        this.routes = routes;

        vue.util.defineReactive(this, 'current', '/');
        
        // 保存匹配的路由数组,并设置为响应式,这样当该数组变化的时候,页面会重新渲染
        vue.util.defineReactive(this, 'matched', []);
        this.onHashChange();

        // 监控url变化,url变化的时候更新current,从而触发router-view组件的重新渲染
        window.addEventListener('hashchange', this.onHashChange.bind(this));
    }

    // hash变化时,重新获取匹配的路由
    onHashChange() {
        this.current = window.location.hash.slice(1);
        this.matched = []; // 保存匹配的路由数组
        this.match();
    }

    // 递归遍历routes,获取当前hash匹配的路由
    match(routes = this.routes) {
        for (const route of routes) {
            if (this.current.indexOf(route.path) !== -1) {
                this.matched.push(route);
                if (route.children) {
                    this.match(route.children);
                }
            }
        }
    }
}

Router.install = function(_vue) {
    vue = _vue;

    vue.mixin({
        beforeCreate() {
            this.$options.router 
            && (vue.prototype.$router = this.$options.router);
        }
    });

    vue.component('router-link', {
        props: ['to'],
        render(h) {
            return h
                (
                    'a', 
                    { attrs: {href: '#' + this.to} }, 
                    this.$slots.default
                )
        }
    });
    vue.component('router-view', {
        render(h) {
            // 如果是router-view组件,就在实例上设置一个标识
            this.routerView = true;

            // 获取当前router-view的深度
            let depth = 0;
            let parent = this.$parent; // 父组件实例
            while (parent) {
               if (parent.routerView) depth++;
                parent = parent.$parent;
            }

            // 根据当前router-view的深度,从匹配的路由中拿到对应的组件,然后渲染
            let com = this.$router.matched[depth].component;

            // let com = this.$router.routesMap[this.$router.current];
            return h(com);
        }
    });
}

export default Router;