vue源码学习--手写vue-router

要实现一个vue-router,首先就要清楚vue-router是一个vue的插件,而vue注册插件是有一套自己的流程的。

首先要认识vue.use这个函数。在vue官方插件的使用过程中我们会发现,诸如vue-router,vuex之类的插件都调用了一次use函数。

那vue.use到底干了什么事情呢。
先来看看vue.use的源码

 Vue.use = function (plugin: Function | Object) {
  // plugin 插件一般来说是一个实例对象
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = [])) // 创建一个数组,用来存储已经安装的插件,避免重复安装
    if (installedPlugins.indexOf(plugin) > -1) { // 此处进行判断,如果已经安装,直接返回
      return this
    }
    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this) // 将this也就是Vue添加的数组中的最前边
    if (typeof plugin.install === 'function') { // 执行插件的 install 方法,将Vue拿到插件中
      plugin.install.apply(plugin, args)
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin) // 将安装好的插件放入数组中保存起来
    return this
  }

根据上面源码的简单阅读我们可以了解到,这个函数接受一个参数plugin,也就是我们传入的插件。他的类型是一个函数或者一个对象。

在第一行里他去获取了vue原型对象上的installedPlugins 属性,这个应该就是存储当前vue实例注册的插件的数组。然后他又判断了一次当前传入的这个插件是否存在于这个数组中,如果存在则直接retuen(这也就是为什么vue的插件重复注册只生效一次的原因。)

下面的代码就是去调用这个插件了,如果传入的是一个函数,则直接调用。如果传入的是对象则调用这个对象的install方法。最后将插件push进数组。

以上就是vue.use的源码了。知道了这些我们就可以开始去写我们自己的插件了。

在使用官方提供的router插件的过程中我可以知道,vue-router这个文件暴露出来的肯定是一个对象,所以肯定是有一个install函数,以及一个名为router的构造函数或者类。

这样我们插件的基本轮廓就出来了

let Vue

class myRouter {
    
}
//vue在调用install的时候会传入一个vue构造函数,方便我们调用vue的各种api
const install = function (_vue) {
      Vue = _vue
}

export default {
    myRouter,
    install
}

其次创建一个router.js存放路由表以及其他一些对插件的操作。

import vue from 'vue'
import MyRouter from '../myRouter'
import home from '../components/home'
import about from '../components/about'

vue.use(MyRouter)

const routers = [
    {
        path: '/',
        component: home
    },
    {
        path:'/about',
        component:about
    }
]
export default new MyRouter.myRouter({
    routers,
})

然后在main.js里引入这个router.js,将构造出来的router对象传入vue的options里。

import Vue from 'vue'
import App from './App.vue'
import router from './router/index'

Vue.config.productionTip = false

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

接着去写我们的插件,在使用过程中可以发现,在任何组件中我们都可以通过$router这个对象拿到router的内容。这一步肯定是要放到install函数里去做的。这一步很简单,直接将构造出来的router对象挂载到vue构造函数的原型对象上就行了。但是有一个问题,我们在插件中如何拿到构建出来的router这个对象呢。

仔细回想一下,官方有提示过,插件的对象必须传入跟实例的配置对象中才能通过this拿到。

new Vue({
  router,//也就是这一步
  render: h => h(App),
}).$mount('#app')

知道了这个就可以去找办法了。什么东西能在跟实例初始化的时候被调用呢。

vue提供了一个api叫做mixin,相信大家都不陌生。vue.mixin--全局混入器。这个api会在每个组件初始化的时候都执行一次。那有了它我们就可以轻松的拿到传入的router并且挂载到vue原型对象上了。

const install = function (_vue) {
    Vue = _vue
    /**
     * 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
     * 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
     * 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
     */
    Vue.mixin({
        beforeCreate() {
            //可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上

            //这里为挂载到每个组件上
            // if (this.$options && this.$options.router) {
            //     this.$router = this.$options.router
            // } else {
            //     this.$router = this.$parent && this.$parent.$router
            // }
            //这里为挂载到构造函数的原型对象上,$options就是new vuew时传入的对象
            if (this.$options && this.$options.router) {
                Vue.prototype.$router = this.$options.router
            }
        },
    })
}

到这一步未知,就已经完成了一个插件的基本内容。

接下来继续去分析,vue-router的两个关键组件router-link router-view。
第一个会被渲染成a标签,点击渲染指定的组件。第二个就是一个视图容器,渲染对应的组件。

const install = function (_vue) {
    Vue = _vue
    /**
     * 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
     * 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
     * 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
     */
    Vue.mixin({
        beforeCreate() {
            //可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上

            //这里为挂载到每个组件上
            // if (this.$options && this.$options.router) {
            //     this.$router = this.$options.router
            // } else {
            //     this.$router = this.$parent && this.$parent.$router
            // }
            //这里为挂载到构造函数的原型对象上
            if (this.$options && this.$options.router) {
                Vue.prototype.$router = this.$options.router
            }
        },
    })
    // Vue.property.$router = _vue.$options.router

    //注册全局组件 router-link  router-view
    //Vue.component函数创建一个组件,第一个参数为组件名称,第二个参数为组件的配置项。注意render函数必须return。
    Vue.component('router-link', {
        props: {
            to: {
                type: String,
                require: true//表示必传
            }
        },
        render(createElement) {
            /**
             * createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
             * 第二个参数为一个配置项
             */
            return createElement('a', {
                attrs: {
                    href:this.to,
                },
            }, this.$slots.default[0].text)
        }
    })
    Vue.component('router-view', {
        render(createElement) {
            return createElement('div',{},'我是router-view')
        }
    })
}

这样两个组件就创建好了。

现在我们去实现myRouter这个类。

class myRouter {
    constructor(options = {}) {
        // this.current = '/'  //当前路由  ,非响应式
        Vue.util.defineReactive(this, 'current', '/')
        this.routers = options.routers //传入的路由表
        this.query = {}//初始化query参数
        this.params = {}//初始化params参数
        this.mode = options.mode || 'hash' //路由模式
        this.init()//初始化函数
    }

    init() {
        if (this.mode === 'hash') {
            //监听网页初始化,给current附上初值,因为拿到的哈希时带#号的,而用户传入的路由表是不带#号的,所以要去掉#号
            window.addEventListener('load', () => {
                location.hash = '/'
                this.current = location.hash.slice(1)
            })
            //添加哈希路由监听函数
            window.addEventListener('hashchange', () => {
                this.current = location.hash.slice(1)
            })
        } else {
            console.log(123123)
            //网页初始化的时候给current初值为'/'
            window.addEventListener('load', () => {
                this.current = '/'
            })
            //添加history路由的监听函数
            window.addEventListener('popstate', (e) => {
                this.current = location.pathname
            })
        }
    }
    //
    // push(url) {
    //     location.hash = url
    // }
}

类里面需要创建一个current属性,表示为当前url。后续我们就需要通过这个属性去渲染指定的组件。

mode表示路由模式,前端的路由模式分为hash和history两种。这个后面再细说。
在constructor构造器中可以拿到用户传入的路由表。
也就是这个玩意儿


image.png

现在有了当前路由,也有了路由表,是不是就可以在router-view里去渲染和当前路由对应的组件了。
所以要改写一下router-view组件

    Vue.component('router-view', {
        render(createElement) {
            // current必须是响应式的才会在发生变化的时候触发render函数
            let current = this.$router.current //获取当前路由
            let routers = this.$router.routers
           //拿到当前路由去路由表里查找对应的组件然后渲染
            let component = routers.find(d => d.path === current)
            return createElement(component.component)
        }
    })

上面在创建current的时候用到了Vue.util.defineReactive这个api。因为只有current时响应式的才会在他改变的时候去触发router-view的render函数,去重新渲染视图。

至此我们的vue-router基本上就已经做完了,能实现基本的功能了。
init函数就是根据当前路由模式去监听相应的路由事件。在网页初始化的时候给一个默认值。

接下来了解一下前端路由hash模式和history模式的区别。

在浏览器输入一个url的时候会向服务器发送一个http请求,请求新的内容。这样做会有种种弊端。
所以衍生出了前端路由。前端路由就是只js监听当前路由的变化然后去执行某些指定的操作。
而要做到这一点首先必不可少的就是当url发生变化了不能刷新浏览器。hash路由就完美的做到了这一点。

hash路由其实指的就是hash值(#后面的内容)。这一块内容发生变化是不会出发浏览器的更新操作的。通过这一点前端就可以做到修改路由执行js,和服务器无关联了。

hash模式下,需要用到的api有location.hash,hashchange。前者是直接修改当前哈希值,也就是我们的url值。后者则是在哈希值发生变化的时候触发

我们通过赋值相应的hash值,就可以做到修改浏览器地址栏的路由,通过将修改过后的值赋给current,就可以触发router-view的render函数,渲染新的组件。这就是vue-router的核心原理。

hash路由非常的好用且易懂,但是有一个缺点,那就是路由上会带#号,这样的路由看上去非常的不美观。所以后来又衍生出了history模式。

history模式:利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法。(需要特定浏览器支持)
这两个方法应用于浏览器的历史记录栈,在当前已有的 back()、forward()、go() 方法的基础之上,这两个方法提供了对历史记录进行修改的功能。当这两个方法执行修改时,只能改变当前地址栏的 URL,但浏览器不会向后端发送请求,也不会触发popstate事件的执行

因此可以说,hash 模式和 history 模式都属于浏览器自身的特性,Vue-Router 只是利用了这两个特性(通过调用浏览器提供的接口)来实现前端路由。

通过pushState和replaceState修改url并不会触发popstate监听的执行,同时也不会触发网页的重载。只要做到了这一点我们就可以顺理成章和hash类似的前端路由了。

在使用hsah的时候我们可以直接给location.hash赋值,但是在history模式下我们必须使用pushState和replaceState来修改当前的url。

pushState()方法,接收三个参数

a state object, a title (which is currentlyignored), and (optionally) a URL

state对象保存的是被pushState页面的信息的一个拷贝,也就是说以后你要用到的信息,都可以放到这个对象中。

url是可选的,负责改变浏览器的地址栏中显示的url,如果没有指定url,你点击前进后退按钮页面还是会变化,只是浏览器的地址栏上显示的url会一直保持不变。

replaceState()方法,与pushState方法相同,主要用于改变当前历史记录中记录的当前页面的state对象和url信息。

onpopstate事件,每次点击浏览器的前进和后退按钮,就会触发window的Onpopstate事件。

最后使用history.state获取当前所在页面的state对象,也就是在上面pushState中保存的。

pushState、replaceState 的区别
pushState()可以创建历史,可以配合popstate事件,而replaceState()则是替换掉当前的URL,不会产生历史。

由此我们的router-link就不能像上面那样写了,为了兼容两种模式,我们得组织a标签的自动跳转,从而自己去修改url的值。

Vue.component('router-link', {
        data() {
            return {
                name: 'router-link'
            }
        },
        props: {
            to: {
                type: String,
                require: true//表示必传
            }
        },
        methods:{
          pushUrl(e){
              if (this.$router.mode == 'hash') {
                  location.hash = this.to
              } else {
                  history.pushState(null, '', this.to)
                  this.$router.current = this.to
              }
              e.preventDefault()
          }
        },
        render(createElement) {
            /**
             * createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
             * 第二个参数为一个配置项
             */
            return createElement('a', {
                attrs: {
                    href:this.to,
                },
                on:{
                    click:this.pushUrl
                }
            }, this.$slots.default[0].text)
        }
    })

如果为hash模式,直接给location.hash赋值,这样就会触发hashchange函数,从而给current赋值,这样就即修改了url又重新渲染了视图。

如果为history模式,则需要通过history.pushState函数添加一个路由历史,并且手动将current 的值修改。因为pushState是不会触发popstate的监听的。只有浏览器的前进后退才会触发。我们添加了历史之后同时也兼容了浏览器的前进后退动作。因为我们将所到之处的路由都添加进了路由栈里。

最后总结一下:

vue-router的实现是利用了浏览器的hash和history两种路由模式。
hash就是url#号后面的值,通过location.hash可以直接修改,通过hashchange方法可以监听到他的变化。
history模式下路由是正常的路由不带#号,他提供了pushState和replaceState两个api去修改url的值并且不会触发网页重载,不会向服务器发送请求。但是这两个方法不会触发popstate函数。而浏览器的前进后退可以触发。所以这种模式有一个弊端,刷新网页之后,浏览器会像服务器请求资源,而我们push进去的路由是由前端自定义的,可能在服务器上并不存在这个资源目录。所以会出现404。这个时候就需要后端通过nginx配置重定向到首页了。

vue-router就是通过监听路由的变化,然后拿到当前路由值去路由表里找到对应的组件最后渲染到router-view中。
最后附上完整的自己写的vue-router源码

let Vue

class myRouter {
    constructor(options = {}) {
        // this.current = '/'  //当前路由  ,非响应式
        Vue.util.defineReactive(this, 'current', '/')
        this.routers = options.routers //传入的路由表
        this.query = {}//初始化query参数
        this.params = {}//初始化params参数
        this.mode = options.mode || 'hash' //路由模式
        this.init()//初始化函数
    }

    init() {
        if (this.mode === 'hash') {
            //监听网页初始化,给current附上初值,因为拿到的哈希时带#号的,而用户传入的路由表是不带#号的,所以要去掉#号
            window.addEventListener('load', () => {
                location.hash = '/'
                this.current = location.hash.slice(1)
            })
            //添加哈希路由监听函数
            window.addEventListener('hashchange', () => {
                this.current = location.hash.slice(1)
            })
        } else {
            console.log(123123)
            //网页初始化的时候给current初值为'/'
            window.addEventListener('load', () => {
                this.current = '/'
            })
            //添加history路由的监听函数
            window.addEventListener('popstate', (e) => {
                this.current = location.pathname
            })
        }
    }
    //
    // push(url) {
    //     location.hash = url
    // }
}

const install = function (_vue) {
    Vue = _vue
    /**
     * 将用户传入的router挂载到组件实例上,但是此处我们是拿不到用户传入的router的。
     * 而全局混入器就能帮助我们拿到每个组件实例,因为他会在每个组件实例化的时候被执行。
     * 我们的跟组件同样也会触发mixin,而routers是在跟组建的配置项传入的,所以就可以拿到了。
     */
    Vue.mixin({
        beforeCreate() {
            //可以选择给每个组件都挂载一个,也可以选择直接挂载到vue构造函数的原型对象上

            //这里为挂载到每个组件上
            // if (this.$options && this.$options.router) {
            //     this.$router = this.$options.router
            // } else {
            //     this.$router = this.$parent && this.$parent.$router
            // }

            //这里为挂载到构造函数的原型对象上
            if (this.$options && this.$options.router) {
                Vue.prototype.$router = this.$options.router
            }
        },
    })
    // Vue.property.$router = _vue.$options.router

    //注册全局组件 router-link  router-view
    //Vue.component函数创建一个组件,第一个参数为组件名称,第二个参数为组件的配置项。注意render函数必须return。
    Vue.component('router-link', {
        data() {
            return {
                name: 'router-link'
            }
        },
        props: {
            to: {
                type: String,
                require: true//表示必传
            }
        },
        methods:{
          pushUrl(e){
              if (this.$router.mode == 'hash') {
                  location.hash = this.to
              } else {
                  history.pushState(null, '', this.to)
                  this.$router.current = this.to
              }
              e.preventDefault()
          }
        },
        render(createElement) {
            /**
             * createElement函数也就是h函数,用于创建dom。第一个参数为创建的dom名称,即标签名
             * 第二个参数为一个配置项
             */
            return createElement('a', {
                attrs: {
                    href:this.to,
                },
                on:{
                    click:this.pushUrl
                }
            }, this.$slots.default[0].text)
        }
    })
    Vue.component('router-view', {
        render(createElement) {
            // current必须是响应式的才会在发生变化的时候触发render函数
            let current = this.$router.current //获取当前路由
            let routers = this.$router.routers
            let component = routers.find(d => d.path === current)
            return createElement(component.component)
        }
    })
}

export default {
    myRouter,
    install
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容