VueRouter原理与实现

我们在平常的 Vue 项目开发中,应该都是用过 VueRouterVuex 这两个重要的插件。今天就主要研究一下 VueRouter 的是原理是什么,然后自己简单实现一个基础版本的 VueRouterVueRouter源码地址

原理

VueRouter的流程

VueRouter 源码中工作流程大致以下几步:

  1. url 发生变化
  2. 触发 url 监听事件
  3. 赋值 router 中的 current变量
  4. 监听 current 变量发生变化。
  5. 获取 current 对应的组件
  6. 渲染组件

看过流程也就明白了 VueRouterVue 能实现单页面应用的核心。不同的路由切换,本质上就是不同的组件切换,最后渲染在页面上。

  • url 监听事件
    VueRouter 的模式有两种 hashhistory。默认是 hash 模式。
    两个模式的获取值以及对应的监听事件是不是一样的:

    模式 获取方式 监听事件 表示形式
    hash location.hash hashchange http://localhost/#/index
    history location.pathname popstate http://localhost/index

    实现

    明白了基本的原理之后,就可以下手了。当然只是明白原理还不够,还需要明白 Vue 的几个工具概念。

    前置技术介绍

    主要介绍使用的几个主要的技术, Vue 插件, Vue 混入 (mixin), 渲染函数。对这些都熟悉的大佬可以跳过。

    Vue 插件

  • 简介
    前面说过 VueRouterVue 的核心插件之一。那我们就得知道什么是 Vue 插件,在 Vue 官方文档中有相关介绍:具体介绍移步 这里

    插件通常用来为 Vue 添加全局功能。插件的功能范围没有严格的限制——一般有下面几种:

    1. 添加全局方法或者 property。如:vue-custom-element
    2. 添加全局资源:指令/过滤器/过渡等。如 vue-touch
    3. 通过全局混入来添加一些组件选项。如 vue-router
    4. 添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。
    5. 一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如 vue-router
  • 开发插件
    Vue 插件应该暴露一个 install 方法。这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象:

    MyPlugin.install = function (Vue, options) {
    // 1. 添加全局方法或 property
    Vue.myGlobalMethod = function () {
    // 逻辑...
    }

    // 2. 添加全局资源
    Vue.directive('my-directive', {
    bind (el, binding, vnode, oldVnode) {
    // 逻辑...
    }
    ...
    })

    // 3. 注入组件选项
    Vue.mixin({
    created: function () {
    // 逻辑...
    }
    ...
    })

    // 4. 添加实例方法
    Vue.prototype.$myMethod = function (methodOptions) {
    // 逻辑...
    }
    }
  • 使用插件
    通过全局方法 Vue.use() 使用插件。它需要在你调用 new Vue() 启动应用之前完成:

    // 调用 MyPlugin.install(Vue)
    Vue.use(MyPlugin)

    new Vue({
    // ...组件选项
    })

    Vue 混入 (mixin)

  • 简介
    在上面的 Vue 插件开发中,install 里面有个 vue.mixin 方法。这个是 Vue 提供的一个全局Api , 通过混入,可以把方法和变量混入 Vue的示例中,具体看文档 这里

  • 使用
    混入的对象以及属性的内容是和 Vue 组件一致,可以混入数据,方法,甚至是生命周期。而对于同名的属性和方法,Vue 会进行合并,后出现的属性会覆盖之前的属性和方法。 全局混入会影响每一个之后创建的 Vue 实例。

    MyPlugin.install = function(vue) {
    vue.mixin({
    data() {
    return {globalMixinData: '这是 mixin 混入数据'}
    },
    // 混入生命周期
    created() {
    console.log(this)
    console.log('I am globalMixin created');
    }
    })
    }


    //使用拆件
    Vue.use(MyPlugin)

    new Vue({
    render: h => h(App),
    }).$mount('#app')

    main.js 中引入 MyPlugin 然后在 App.vue 中引用 {{globalMixinData}} 即可看到

    图中执行两次 console.log(I am globalMixin created) 是因为 new VueApp.vue 都会执行一次生命周期。 通过 console.log(this) 是能看到哪个执行的生命周期。

    渲染函数 (h == createElement)

  • 简介

    渲染函数的由来是因为 Vue 使用了 虚拟DOM,更新操作 虚拟DOM 比操作真正的 DOM 要更快,而且更节省资源。 渲染函数 createElement 实际上返回的是一个 VNode 虚拟节点,最后 Vue 再生成真实的 DOM 显示在页面上。

  • 参数

    // @returns {VNode}
    createElement(
    // {String | Object | Function}
    // 一个 HTML 标签名、组件选项对象,或者
    // resolve 了上述任何一种的一个 async 函数。必填项。
    'div',

    // {Object}
    // 一个与模板中 attribute 对应的数据对象。可选。
    {
    // (详情见下一节)
    },

    // {String | Array}
    // 子级虚拟节点 (VNodes),由 `createElement()` 构建而成,
    // 也可以使用字符串来生成“文本虚拟节点”。可选。
    [
    '先写一些文字',
    createElement('h1', '一则头条'),
    createElement(MyComponent, {
    props: {
    someProp: 'foobar'
    }
    })
    ]
    )

    第二个参数内容部分如下: 详细参数

     {
    // 普通的 HTML attribute
    attrs: {
    id: 'foo'
    },
    // 组件 prop
    props: {
    myProp: 'bar'
    },
    // 事件监听器在 `on` 内,
    // 但不再支持如 `v-on:keyup.enter` 这样的修饰器。
    // 需要在处理函数中手动检查 keyCode。
    on: {
    click: this.clickHandler
    },
    // 仅用于组件,用于监听原生事件,而不是组件内部使用
    // `vm.$emit` 触发的事件。
    }
  • 示例
    比如创建一个全局组件 viewer 里面的内容为 <div id='viewers'>我是div</div>, 在 main.js 中添加代码如下:

    Vue.component('viewer',{
    render(h){
    const tag = 'div'
    const config = {attrs:{id:'viewers'}}
    return h(tag,config,'我是div')
    }
    })

    结果如下

    基础版实现

    了解前面的知识之后,就能真正开始写了。

  • history 路径监听类
    因为 VueRouter 是分 hashhistory 两种模式,那么我们分别创建两种类:
    HashHistory

    /**
    * hash类型
    */
    class HashHistory {
    constructor() {
    this.current = null;
    this.initListener()
    }

    initListener (){
    location.hash ? '' : location.hash = '/';
    window.addEventListener('load', () => {
    // 页面加载的时候初始化,存储hash值到history的current上,并且去掉开头的#
    this.current = location.hash.slice('1');
    });
    window.addEventListener('hashchange', () => {
    // hash改变的时候更新history的current
    this.current = location.hash.slice('1');
    })
    }
    }

    HTML5History

    /**
    * history模式
    */

    class HTML5History {
    constructor() {
    this.current = null;
    this.initListener()
    }

    initListener (){
    // 如果url没有pathname,给一个默认的根目录pathname
    location.pathname ? '' : location.pathname = '/';
    window.addEventListener('load', () => {
    // 页面加载的时候初始化,存储pathname值到history的current上
    this.current = location.pathname;
    });
    window.addEventListener('popstate', () => {
    // pathname改变的时候更新history的current
    this.current = location.pathname;
    })
    }
    }

    这两个类相同,主要做了两件事, 一是记录当前的 路径地址 current ,一个是创建监听路径变化事件。 initListener()。只是两个监听的事件名不一样。

  • CustomRouter 路由类

    //定义路由类
    class CustomRouter {
    constructor(options) {
    this.mode = options.mode || 'hash';
    this.routers = options.routers || [];
    // 将数组结构的routes转化成一个更好查找的对象
    this.routesMap = this.mapRoutes(this.routers);
    this.init();
    }

    // 初始化history
    init() {
    if (this.mode === 'hash') {
    this.history = new HashHistory()
    } else {
    this.history = new HTML5History()
    }
    }

    /*
    将 [{path: '/', component: Hello}]
    转化为 {'/': Hello} 方便查找
    */
    mapRoutes(routes) {
    return routes.reduce((res, current) => {
    res[current.path] = current.component;
    return res;
    }, {})
    }
    }

    CustomRouter 有两个参数:1. mode 类型,默认为 hash 2. routers 路由数组。因为是以插件的形式给 Vue 因此,还需要提供一个 install 方法

    // 添加install属性,用来执行插件
    CustomRouter.install = function (vue) {
    vue.mixin({
    beforeCreate() {
    // 获取new Vue时传入的参数
    if (this.$options && this.$options.router) {
    this._root = this;
    this._router = this.$options.router;
    // 监听current, defineReactive(obj, key, val)不传第三个参数,第三个参数默认是obj[key]
    // 第三个参数传了也会被监听,效果相当于,第一个参数的子级
    vue.util.defineReactive(this, 'current', this._router.history);
    // vue.set(this, 'current', this._router.history);

    } else {
    // 如果不是根组件,就往上找
    this._root = this.$parent && this.$parent._root || this ;
    }

    // 暴露一个只读的$router
    Object.defineProperty(this, '$router', {
    get() {
    return this._root._router;
    }
    })
    }
    });


    // 注册 router-link组件,进行路由跳转
    vue.component('router-link', {
    props:['to'],
    render(h) {
    const tag = 'a' // a 标签
    const config = {
    attrs:{
    href:this._self._root._router.mode =='hash'? '#'+this.to: this.to // 根据路由mode 设置不同的 href 属性
    }
    }
    return h(tag,config,this.$slots.default);
    }
    })

    // 注册router-view组件,这个组件根据current不同会render不同的组件
    vue.component('router-view', {
    render(h) {
    //获取当前的path路径
    const current = this._self._root._router.history.current;
    //获取转换后的路由对象 {`path`:`component`} 组合
    const routesMap = this._self._root._router.routesMap;
    //根据path 获取对应的组件
    const component = routesMap[current];
    //渲染组件
    return h(component);
    }
    })

    }

    代码中使用了 Vue.mixin 在生命周期 beforeCreate 来混入路由对象,同时暴露出一个 $router 来获取路由对象,然后利用了 渲染函数 注册了两个全局组件 router-viewrouter-link

    有同学会注意到有个 vue.util.defineReactive(this, 'current', this._router.history); 。这行代码主要是让 current 属性为响应式。 Vue.util 是没有在文档写出的。源码中可以看到定义

    Demo使用

    有了自定义路由之后,就可以使用了。

  • 先写两个路由数组,定义路由。

    import CustomRouter from "../customRouter";
    import Vue from 'vue'

    import HelloWorld from '../components/HelloWorld.vue'
    import Test from '../components/Test'

    //使用路由插件
    Vue.use(CustomRouter)

    const routers = [
    {
    path:'/home',
    component: HelloWorld
    },
    {
    path: '/test',
    component: Test
    }
    ]
    const router = new CustomRouter({
    mode:'history', //默认为 hash
    routers
    })
    export default router
  • main.js 引用,然后在 App.vue 使用

    <template>
    <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <div class="router-link-box">
    <router-link to="/home">Hello World</router-link>
    <router-link to="/test">Test</router-link>
    </div>
    <router-view></router-view>
    </div>
    </template>
  • hash 模式结果:

  • history 模式结果:

    总结

    OK,总算是写完了。 VueRouter 的核心原理如果仔细看完上面的,应该是明白的。上面的代码可以在 github 获取。地址

    音乐小憩