手写Vue系列 => 手写 Vue Router

176 阅读6分钟

本人前端小白一枚,只是整理一下自己的学习过程,第一次在掘金发表自己的文章,有问题欢迎大家及时指出

Vue生命周期

在学习vue相关组件,router,响应式原理以及虚拟DOM等时,需要对vue的声明周期有一定的明确

这里首先先对Vue的生命周期进行简单的讲解,对于vue生命周期的描述,网上有很多不错的文章,这里发一个链接,不了解的小伙伴可以参考这个链接对生命周期进行一个具体的学习

segmentfault.com/a/119000001…

vue-router原理实现

vue-router的使用

基本用法

要想了解vue-router的实现原理,需要对vue-router的使用有一定的了解。通常在项目中使用vue-router时,需要对router.js配置文件中对路由的规则进行一系列配置,包括路径、路由名称、子路由规则等等。在配置完成后,对当前的router对象导出,并在main.js中导入

// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from '../views/Index.vue'
// 1. 注册路由插件
Vue.use(VueRouter)

// 路由规则
const routes = [
  {
    path: '/',
    name: 'Index',
    component: Index
  },
  {
    path: '/blog',
    name: 'Blog',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "blog" */ '../views/Blog.vue')
  },
  {
    path: '/photo',
    name: 'Photo',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "photo" */ '../views/Photo.vue')
  }
]
// 2. 创建 router 对象
const router = new VueRouter({
  routes
})

export default router


// main.js

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

Vue.config.productionTip = false

new Vue({
  // 3. 注册 router 对象
  router,
  render: h => h(App)
}).$mount('#app')

这种使用方式是最基本的vue-router的使用方式。当然,也可以通过配置动态路由的方式对url中的参数传递给组件使用

动态路由

// router.js
{
  path: '/detail/:id',
  name: 'Detail',
  // 开启 props,会把 URL 中的参数传递给组件
  // 在组件中通过 props 来接收 URL 参数
  props: true,
  // route level code-splitting
  // this generates a separate chunk (about.[hash].js) for this route
  // which is lazy-loaded when the route is visited.
  component: () => import(/* webpackChunkName: "detail" */ '../views/Detail.vue')
}

上述vue-router的配置中开启了props属性,目的是获取url中的id参数,在传递参数的时候,有两种传递方式:

 	<!-- 方式1: 通过当前路由规则,获取数据 -->
    通过当前路由规则获取:{{ $route.params.id }}

    <br>
    <!-- 方式2:路由规则中开启 props 传参 -->
    通过开启 props 获取:{{ id }}

第一种方式是通过编程式的方式获取,这种方式有一个缺点,就是太过于依赖当前路由,所以一般通过在router.js中定义props属性,随后通过第二种方式传递参数

编程式路由

// 跳转push  <router-link :to="..."> 等同于调用 router.push(...)
this.$router.push('/')
this.$router.push({ name: ''Detail', params: { id: 1} })
// 替换replace  <router-link :to="..." replace>	router.replace(...)
this.$router.replace('/login')
// go 在历史记录中向前或向后进行多少步
this.$router.go(-2)

Hash模式和History模式

Hash模式和History模式是vue-router中的两种模式

Hash模式:

在Hash模式中,可以在url中看到#或者?
Hash模式是基于锚点以及onhashchange事件,通过锚点的值作为路由地址,当锚点变化,调用onhashchange
方法,根据路径决定呈现的内容

History模式
History模式是基于HTML5中的History API实现的
·history.pushState()
·history.replaceState()

pushState与push方法的区别是:调用push的时候路径会发生变化,会向服务器发送请求,调用pushState的
时候,不会向服务器发送请求,只会改变地址栏中的地址,并且将地址记录到历史记录中,可以实现客户端路由

history模式的使用

History模式需要服务器的支持,在单页应用中,服务端不存在某一个地址,会返回404,所以,页面在服务端应该除了静态资源外都返回单页应用的index.html

vue-router的实现原理

在hash模式中,会将url中的**#这个锚点** 后面的内容作为路径地址,监听hashchange事件,根据当前路由地址找到对应组件重新渲染

而history模式中,通过 history.pushState() 方法改变地址栏,并把当前地址记录到浏览器中,同时监听 popstate 事件,获取到浏览器历史操作的变化,只有点击浏览器的前进、后退按钮,或者通过编程式导航的方式,才会触发popstate事件,最后根据当前路由地址找到对应组件重新渲染

实现一个vue-router

重头戏来了,我们需要实现一个vue-router

通过前面的讲解,了解了vue-router的实现原理,首先router是通过Vue.use()导入的,所以,需要实现install方法。随后,vue-router会创建路由对象实例,说明router是一个构造函数或类,包含install方法,这里我们以类的方式去实现。router的构造函数接收一个对象参数,记录地址和组件,使用时,创建vue实例,传入router对象,这就是vue-router的实现原理,下面是vue-router的一个类图,描述了vue-router中的参数,属性和方法

根据类图可以看到:

vue-router中有三个属性,options记录的是创建实例时传入的路由规则的参数。data记录的是当前的路由状态。routeMap记录的是路由地址和组件的对应关系。

install的实现

install默认是一个静态方法,需要通过VueRouter.install()方式去调用,所以这里定义一个静态的install方法,在这个方法里需要做三件事:

  1. 判断是否已经安装

  2. 把构造函数记录到全局变量中

  3. 将创建Vue实例时传入的router对象注入到所有实例中

static install (Vue) {
  // 判断是否已经安装
  if (VueRouter.install.installed) {
    return
  }
  VueRouter.install.installed = true
  // 把构造函数记录到全局变量中
  _Vue = Vue
  // 将创建Vue实例时传入的router对象注入到所有实例中
  // 混入mixin
  _Vue.mixin({
    beforeCreate () {
      // 判断当前对象是组件还是vue实例,是组件的话,this.$options.router不存在,不需要调用
      if (this.$options.router) {
        _Vue.prototype.$router = this.$options.router
        // 通过init方法传入createRouteMap方法和initComponents方法
        this.$options.router.init()
      }
    }
  })
}

vue-router的构造函数

构造函数constructor中传入options,在这个构造函数中,对三个属性进行操作,分别为上述的options、data和routeMap

// 构造函数
  constructor (options) {
    this.options = options
    this.routeMap = {}
    // data 为响应式的对象,通过Vue.observable创建
    this.data = _Vue.observable({
      // 存储当前路由地址,默认为 '/'
      current: '/'
    })
  }

createRouteMap

createRouteMap方法的作用是遍历路由规则解析成键值对的形式传入 routeMap 中

createRouteMap () {
  // 遍历路由规则解析成键值对的形式传入 routeMap 中
  this.options.routes.forEach(route => {
    this.routeMap[route.path] = route.component
  })
}

initComponents

initComponents方法是创建一个vue组件,在这个组件中,对router-link标签中的内容生成对应的a标签。router-link内部的参数to通过组件中的props传入

initComponents (Vue) {
  Vue.component('router-link', {
    props: {
      to: String
    },
    template: '<a :href="to"><slot></slot></a>'
  })
}

init

init方法的作用是将initComponents方法和createRouteMap方法合并到一起,传入到install中,在创建Vue实例时传入的router对象注入到所有实例中时初始化routeMap和router-link

init () {
  this.createRouteMap()
  this.initComponents(_Vue)
}

通过这些代码,可以基本上实现了vue-router的功能,我们可以来测试一下,将之前导入的vue-router切换到我们自己手写的代码路径,在浏览器中测试一下

控制台报错了,页面内容也没有呈现出来,这是因为什么呢?

原因在于,通过vue-cli构建的项目默认是运行时版本,在运行时版本,不支持template模板,需要打包时提前编译,所以这个版本中不存在编译器,不能将template转换成render函数。所以这里我们有两种解决方案

完整版vue

这种方式是通过对vue-cli构建的项目进行配置,通过配置,将当前项目转换成完整版,在完整版中存在编译器,便可以进行编译。

这里需要在项目根路径下新建vue.config.js配置文件,在文件中对 runtimeCompiler 属性进行配置

module.exports = {
    runtimeCompiler: true
}

render

既然运行时版本中没有编译器,那我们就可以将template模板直接通过render函数的方式来实现

initComponents (Vue) {
  Vue.component('router-link', {
    props: {
      to: String
    },
    render (h) {
      // 标签 + dom属性 + 内容
      return h('a', {
        attrs: {
          href: this.to
        }
      }, [this.$slots.default]
      )
    }
    // template: '<a :href="to"><slot></slot></a>'
  })
}

在render函数中,有一个h函数,在返回这个函数时,传入三个参数:

第一个参数为转换的标签;

第二个参数为当前转换标签的属性,一般转换为a标签时,会将a中的href属性值设置为props中的to的值;

第三个参数为当前标签内部的内容。在template中,我们通过slot的方式获取内容,在render函数中,我们通过this.$slots.default的方式获取默认插槽内容。

再次进入浏览器查看:

可以看到问题已经解决了

router-view

在上图可以看到,控制台报错提示没有定义router-view组件,所以这里我们需要实现一个router-view组件

// router-view
// 保存vue-router实例,确保this指向
const self = this
Vue.component('router-view', {
  render (h) {
    const component = self.routeMap[self.data.current]
    return h(component)
  }
})

代码中,同样通过render函数定义,在这个函数中,获取到当前vueRouter对象中的routeMap的值,由于routeMap内部存储的是当前路由的路径和模板,通过这种方式将当前内容作为参数传入到h函数中,h函数会自动对其进行渲染

测试后,发现在每次点击对应a标签时,浏览器都会对服务器进行请求,这是因为a标签默认会发出请求,这在单页面应用中是不应该发生的,所以,我们需要对router-link组件进行修改,对定义的a标签添加监听点击事件

Vue.component('router-link', {
  props: {
    to: String
  },
  render (h) {
    // 标签 + dom属性 + 内容
    return h('a', {
      attrs: {
        href: this.to
      },
      on: {
        click: this.clickHandler
      }
    }, [this.$slots.default]
    )
  },
  methods: {
    clickHandler (e) {
      // 通过history.pushState方法改变地址栏,同时不发送请求
      // 三个参数,data触发popState方法 title:网页标题 url:url地址内容
      history.pushState({}, '', this.to)
      // data是响应式的,所以更改current,会自动更新当前组件的内容
      this.$router.data.current = this.to
      // 阻止默认事件跳转
      e.preventDefault()
    }
  }
  // template: '<a :href="to"><slot></slot></a>'
})

在这里,我定义了一个clickHandler函数,这个函数接收一个e参数,通过e.preventDefault()阻止默认跳转事件,随后,通过history.pushState()函数将地址栏内容进行改变,然后将this.router.data.current中的路径修改为props中的to,由于定义的router.data.current中的路径修改为props中的to,由于定义的router.data属性是响应式的,当内部的current改变,会自动更新当前组件的内容。

当重新定义了router-link和router-view时,再来到浏览器进行查看,发现点击后不会跳转了,同时视图也会正常更新

initEvent

initEvent事件是为了处理点击浏览器前进后退后页面内容的问题,在上面对router-view和router-link进行修改后,浏览器点击不会跳转,但是当我们点击浏览器的前进或者后退按钮时,url发生改变,但是视图并未发生改变,所以,通过initEvent事件解决该问题

// 处理点击浏览器前进后退后页面内容的问题
initEvent () {
  window.addEventListener('popstate', () => {
    this.data.current = window.location.pathname
  })
}

这时,我们点击浏览器的前进后退按钮,视图也会正常更新了,最后不要忘了将initEvent事件放入到init事件中

这样!我们就实现了history模式的vue-router了

完整代码:

// Vue.use() 需要实现install方法

// 创建路由对象实例,说明router是一个构造函数或类,包含install方法

// router的构造函数接收一个对象参数,记录地址和组件

// 创建vue实例,传入router对象
let _Vue = null
export default class VueRouter {
  // install
  static install (Vue) {
    // 判断是否已经安装
    if (VueRouter.install.installed) {
      return
    }
    VueRouter.install.installed = true
    // 把构造函数记录到全局变量中
    _Vue = Vue
    // 将创建Vue实例时传入的router对象注入到所有实例中
    // 混入mixin
    _Vue.mixin({
      beforeCreate () {
        // 判断当前对象是组件还是vue实例,是组件的话,this.$options.router不存在,不需要调用
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router
          this.$options.router.init()
        }
      }
    })
  }

  // 构造函数
  constructor (options) {
    this.options = options
    this.routeMap = {}
    // data 为响应式的对象,通过Vue.observable创建
    this.data = _Vue.observable({
      // 存储当前路由地址,默认为 '/'
      current: '/'
    })
  }

  init () {
    this.createRouteMap()
    this.initComponents(_Vue)
    this.initEvent()
  }

  createRouteMap () {
    // 遍历路由规则解析成键值对的形式传入 routeMap 中
    this.options.routes.forEach(route => {
      this.routeMap[route.path] = route.component
    })
  }

  initComponents (Vue) {
    Vue.component('router-link', {
      props: {
        to: String
      },
      render (h) {
        // 标签 + dom属性 + 内容
        return h('a', {
          attrs: {
            href: this.to
          },
          on: {
            click: this.clickHandler
          }
        }, [this.$slots.default]
        )
      },
      methods: {
        clickHandler (e) {
          // 通过history.pushState方法改变地址栏,同时不发送请求
          // 三个参数,data触发popState方法 title:网页标题 url:url地址内容
          history.pushState({}, '', this.to)
          // data是响应式的,所以更改current,会自动更新当前组件的内容
          this.$router.data.current = this.to
          // 阻止默认事件跳转
          e.preventDefault()
        }
      }
      // template: '<a :href="to"><slot></slot></a>'
    })
    // router-view
    // 保存vue-router实例,确保this指向
    const self = this
    Vue.component('router-view', {
      render (h) {
        const component = self.routeMap[self.data.current]
        return h(component)
      }
    })
  }

  // 处理点击浏览器前进后退后页面内容的问题
  initEvent () {
    window.addEventListener('popstate', () => {
      this.data.current = window.location.pathname
    })
  }
}

以上就是我对vue-router的手写的代码,有问题欢迎大佬指出,欢迎讨论,欢迎批评改正