手动实现vue-router

所需的前置知识:

  • 插件
  • 混入
  • Vue.observable()
  • 插槽
  • render函数
  • 运行时和完整版的Vue

实现原理

Vue Router是前端路由,当路径切换时在浏览器判断当前路径,并加载对应组件

Hash模式

  • URL中#(HashTAG)后面的内容是路径地址
  • 监听hashchange事件
  • 根据当前路由地址找到对应组件并重新渲染

History 模式

  • 通过history.pushState()方法改变地址栏(这一操作只改变地址栏并记录历史,并不真正跳转)
  • 监听popstate事件
  • 根据当前路由地址找到对应组件并重新渲染

回顾核心代码

// router/index.js
// 注册插件
Vue.use(VueRouter)
// 创建路由对象
const router = new VueRouter({
  routes: [
    { name: 'home', path: '/', component: homeComponent }
  ]
})

// main.js
// 创建Vue示例, 注册router对象
new Vue({
  router,
  render: h =>h(App)
}).$mount('#app')

类图:

类图

接下来要实现的就是这个VueRouter

属性:

  1. options:记录构造函数中传入的对象
  2. data:{current}:需要一个响应式的对象,以便地址变化的时候路由可以响应式的更新
  3. routeMap:用于记录路由地址和组件的对应关系,会将路由规则解析到routeMap上

方法:

  1. Constructor(Options):VueRouter:构造函数
  2. _install(Vue):void:用于实现Vue的插件机制
  3. init():void:用于调用其他方法
  4. initEvent():void:用于注册popstate事件
  5. createRouteMap():void:初始化routeMap对象,建立路由组建关系
  6. initComponents(Vue):void:用于创建<router-view>,<router-link>组件

手写实现

install方法

install方法是Vue插件机制的关键,在vue插件系统的官方文档中讲述了插件的开发方法,其实主要的部分就是install方法需要做的事情。在这里引用一部分内容:

:notebook_with_decorative_cover:官方文档

Vue.js 的插件应该暴露一个 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) {
   // 逻辑...
 }
}

整理一下我们的手写实现思路:

  1. 判断当前插件是否已经被安装(Vue的插件只能安装一次)
  2. 添加全局资源
  3. 注入组件选项
  4. 添加实例方法

判断安装状态

显然我们需要一个变量来记录插件是否被安装了,局部变量显然无法记录状态;全局变量会引入外部依赖,显然也不妥,所以鉴于install方法是一个静态方法,其上可以带一个属性。这个属性会长期保持,并可随时访问,很适合这种需求,所以我们只需要:

export default class VueRouter{
  /**
   * Vue.use的时候调用的函数,传入Vue的构造实例, 和可选的选项
   * @param {*} Vue Vue的构造实例
   * @param {*} options 选项对象(可选)
   */
  static install(Vue, options){
    // 1.判断当前插件是否已经被安装
    if (VueRouter.install.installed) return;
    VueRouter.install.installed = true;
    // 2.把Vue构造函数记录到全局变量
    // 3.把创建Vue实例时候传入的router对象注入到Vue实例上
  }
}

注入组件选项

虽然在use的时候已经传入了Vue的构造实例了,但是我们不能通过prototype直接在所有的Vue实例上挂载实例方法。原因是install是静态方法,调用的时候其this指向的是VueRouter类,而不是Vue实例。所以此处必须使用Vue.mixin()来进行注入,这样每一个Vue实例在指定的生命周期钩子被触发的时候都会执行混入的内容,从而达到在每一个Vue实例上注入的效果:

let _Vue;
export default class VueRouter {
  /**
   * Vue.use的时候调用的函数,传入Vue的构造实例, 和可选的选项
   * @param {*} Vue Vue的构造实例
   * @param {*} options 选项对象(可选)
   */
  static install(Vue, options) {
    // 1.判断当前插件是否已经被安装
    if (VueRouter.install.installed) return;
    VueRouter.install.installed = true;
    // 2.把Vue构造函数记录到全局变量(组件内全局,以方便以下逻辑调用)
    _Vue = Vue
    // 3.把创建Vue实例时候传入的router对象注入到Vue实例上
    _Vue.mixin({
      beforeCreate() {
        // 只需要给vue实例挂载,而不需要给组件挂载,组件是没有$options的
        if (this.$options.router) {
          // 此时this的指向就不再是VueRouter了,而是实例本身了
          _Vue.prototype.$router = this.$options.router
        }
      }
    })
  }
}

构造函数

构造函数只需要初始化三个属性,并且将data设为响应式,Vue为我们提供了Vue.observable()方法,可以直接将其转化为响应式的对象,所以构造函数我们可以如下实现:

constructor(options) {
    this.options = options;
    this.routerMap = {};
    this.data = _Vue.observable({
      // 当前地址,默认为根
      current: '/'
    })
  }

createRouteMap方法

这一方法可以将Options中传入的路由规则转化为路由键值对的形式,键是路由的地址,值是对应的组件。这样路由变化时,就可以快速的找到对应的组件并将其渲染出来。

  createRouteMap(){
    // 遍历所有的路由规则解析并构造键值对
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    });
  }

initComponents方法和init方法

顾名思义,这个函数用于初始化跟路由相关的两个组件:<router-link>,<router-view>。<router-link>组件接受一个字符串类型的参数to并且标签中的内容会被渲染成a标签。与此同时我们还可以使用init方法将之前的createRouteMap和这一方法包装一下,方便统一调用。

至此完整代码如下

let _Vue;
export default class VueRouter {
  /**
   * Vue.use的时候调用的函数,传入Vue的构造实例, 和可选的选项
   * @param {*} Vue Vue的构造实例
   * @param {*} options 选项对象(可选)
   */
  
// eslint-disable-next-line no-unused-vars
  static install(Vue, options) {
    // 1.判断当前插件是否已经被安装
    if (VueRouter.install.installed) return;
    VueRouter.install.installed = true;
    // 2.把Vue构造函数记录到全局变量(组件内全局,以方便以下逻辑调用)
    _Vue = Vue
    // 3.把创建Vue实例时候传入的router对象注入到Vue实例上
    _Vue.mixin({
      beforeCreate() {
        // 只需要给vue实例挂载,而不需要给组件挂载,组件是没有$options的
        if (this.$options.router) {
          // 此时this的指向就不再是VueRouter了,而是实例本身了
          _Vue.prototype.$router = this.$options.router
          this.$options.router.init()
        }
      }
    })
  }
  constructor(options) {
    this.options = options;
    this.routeMap = {};
    this.data = _Vue.observable({
      // 当前地址,默认为根
      current: '/'
    })
  }
  init(){
    this.createRouteMap();
    this.initComponents(_Vue)
  }
  createRouteMap(){
    // 遍历所有的路由规则解析并构造键值对
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    });
  }
  initComponents(Vue){
    Vue.component('router-link', {
      props:{
        to: String,
      },
      template:'<a :href="to"><slot></slot></a>'
    })
  }
}

到这一步,路由就可以正常跳转了(虽然还不能显示),其实核心工作就是进行了一步解析,一步混入挂载,一步声明router-link(实际上就是个a标签)。如果你熟悉vue的源码或原理,对render中的h函数必然不会感到陌生了。但是其实这个时候的跳转不是我们想要的跳转,我们想要的跳转并不是真正的跳转,而是只改变地址栏和渲染的组件,所以我们必须进一步阻止a标签的默认行为,并调用pushStateapi来改变地址栏:

initComponents(Vue) {
    Vue.component('router-link', {
      props: {
        to: String,
      },
      render(h) {
        return h('a', {
          attrs: {
            href: this.to
          },
          on: {
            click: this.clickHandler
          }
        }, [this.$slots.default])
      },
      methods: {
        clickHandler(e) {
          history.pushState({}, '', this.to);
          this.$router.data.current = this.to
          e.preventDefault()
        }
      }
    })
  }

经过一番修改,我们成功的阻止了默认行为,并且通过api改变了地址栏内容,修改了current响应式参数的值。不过这个时候组件还不能被渲染,因为我们还没有声明router-view组件。render函数也可以直接帮助我们渲染一个组件。既然current是响应式的,当current发生变化的时候,渲染的内容自然而然的就会发生变化了。唯一的问题在于,在render函数中,this的指向是指向该实例内部的this而不是VueRouter对象的,所以我们不可以在这里直接使用this。带有router-view组件的完整代码如下:

let _Vue;
export default class VueRouter {
  /**
   * Vue.use的时候调用的函数,传入Vue的构造实例, 和可选的选项
   * @param {*} Vue Vue的构造实例
   * @param {*} options 选项对象(可选)
   */

  // eslint-disable-next-line no-unused-vars
  static install(Vue, options) {
    // 1.判断当前插件是否已经被安装
    if (VueRouter.install.installed) return;
    VueRouter.install.installed = true;
    // 2.把Vue构造函数记录到全局变量(组件内全局,以方便以下逻辑调用)
    _Vue = Vue
    // 3.把创建Vue实例时候传入的router对象注入到Vue实例上
    _Vue.mixin({
      beforeCreate() {
        // 只需要给vue实例挂载,而不需要给组件挂载,组件是没有$options的
        if (this.$options.router) {
          // 此时this的指向就不再是VueRouter了,而是实例本身了
          _Vue.prototype.$router = this.$options.router
          this.$options.router.init()
        }
      }
    })
  }
  constructor(options) {
    this.options = options;
    this.routeMap = {};
    this.data = _Vue.observable({
      // 当前地址,默认为根
      current: '/'
    })
  }
  init() {
    this.createRouteMap();
    this.initComponents(_Vue)
  }
  createRouteMap() {
    // 遍历所有的路由规则解析并构造键值对
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    });
  }
  initComponents(Vue) {
    Vue.component('router-link', {
      props: {
        to: String,
      },
      render(h) {
        return h('a', {
          attrs: {
            href: this.to
          },
          on: {
            click: this.clickHandler
          }
        }, [this.$slots.default])
      },
      methods: {
        clickHandler(e) {
          history.pushState({}, '', this.to);
          this.$router.data.current = this.to
          e.preventDefault()
        }
      }
    })
    const self = this;
    Vue.component('router-view', {
      render(h){
        const component = self.routeMap[self.data.current];
        return h(component)
      }
    })
  }
}

现在我们就完美的实现了所有功能。除了最后一个问题——当我们点击浏览器后退时会怎么样,当然什么都不会发生。因为地址栏虽然发生了变化,但是current没有发生变化,current没有发生变化组件就不会变化,所以什么都不会发生,而如果我们希望组件也变化,那么便需要监听popstate事件。

initEvent方法

这一部分很简单,我们只需要添加一个全局的popstate的事件监听,并将地址赋值给current即可:

initEvent(){
    window.addEventListener('popstate', ()=>{
        this.data.current = window.location.pathname
    })
}

当然,不要忘记在init()函数中调用一下这个初始化函数,这样我们才算是真正的自己实现了vue-router。

完整代码参见github

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

推荐阅读更多精彩内容