Vue Router实现原理

Vuevue-router

通过模拟一个Vue Router我们了解其内部原理,我们模拟history模式,hash模式可以自己实现,差异很小。

# 用到的Vue前置知识

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

# 原理回顾

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

# Hash模式

  • URL中#后面的内容作为路径地址,可以直接使用location.url切换浏览器中的地址。如果是#后面的东西改变,浏览器不会重新请求服务器,但是会记录到历史中。
  • 监听 hashchange 事件,hash改变后会触发hashchange 事件,在hashchange中记录当前路由地址
  • 根据当前路由地址找到对应组件重新渲染

# History模式

  • 通过history.pushStat()方法改变地址栏,并把当前地址记录到访问历史中,并不会真正跳转到指定路由,浏览器也不会向服务器发请求。
  • 监听popstate事件,可以监听浏览器操作历史的变化。在其回调函数中可以记录改变的地址,

注意:

但是调用pushStat() 或者 replaceState()方法的时候并不会触发该事件,在点击浏览器前进和后退按钮的时候,或者调用back和forward方法的时候,该事件才会被触发。

  • 地址改变后,根据当前路由地址找到对应组件重新渲染

# 实现分析

根据如何使用推断如何实现:

# 注册插件

注册插件使用Vue.use(VueRouter)方法

Vue.use(VueRouter) 如果传入的是一个函数,会直接调用函数,如果是一个对象,会调用对象的install方法

故需要定义一个对象,并添加install方法。

# 初始化实例

const router = new VueRouter({
    routers: [
        {name: 'home', path: '/', component: homeComponent }
    ]
})

1
2
3
4
5
6

故使用类方法,需要传入一个对象,对象里面是路由规则,主要记录路由和对应的组件名称,并且要返回一个router对象。

# 类的构想

可以画类图

  • VueRouter
  • Property(属性)
    • options(创建类的时候传入的参数)
    • data(pbject,响应式对象,Vue.observable())
      • current:记录当前路由
    • routeMap(object,记录路由和组件的对应关系)
  • Methods(方法)
    • Constructor(options):VueRouter —— 类方法
    • install(Vue):void —— 静态方法(实现Vue的插件机制)
    • init():void —— 类方法(初始化,调用下面三个方法的)
    • initEvent():void —— 类方法(用来注册popState事件,监听浏览器历史的变化)
    • createRouterMap():void —— 类方法(初始化routerMap属性)
    • initComponents(Vue):viod —— 类方法(创建router-link和router-view组件)

# 实现步骤

下载模板,在src/下面添加文件夹vueRouter/index.js

# 实现install静态方法

let _Vue = null
export default class VueRouter {
   /**
   * install静态方法
   * @param {Object} Vue 第一个参数是Vue构造函数,第二个参数是可选选项对象,这里不需要暂不传递
   *
   * 实现步骤
   * 1. 判断当前插件是否已经被安装,如果已安装就不必再安装
   * 2. 把Vue的构造函数记录到全局对象中,其他实例方法用
   * 3. 把创建Vue实例时传入的router对象注入到所有vue实例上,this.$router可以直接使用
   */
  static install (Vue) {
    // 判断当前插件是否已经被安装,已经安装返回,没有安装就设置标记为true
    if (VueRouter.install.installed) {
      return
    }
    VueRouter.install.installed = true
    // Vue的构造函数抛到全局
    _Vue = Vue
    // 把options.router对象注入到所有vue实例上

    // 不能写下面的代码,因为静态方法是VueRouter调用,this指的是VueRouter而不是其实例。
    // _Vue.prototype.$router = this.$options.router
    // 如果想指向其实例对象,这里用到混入,给所有的实例混入一个选项,设置一个beforeCreate方法,在函数中可以获取VueRouter实例
    _Vue.mixin({
      beforeCreate () {
        // 所有的Vue实例中都有,将来执行的时候会执行很多次。判断如果里面有router属性就执行,没有就不执行。
        if (this.$options.router) {
          // this指的是VueRouter,VueRouter是Vue的实例,$options就是Vue的options
          /**
           * new Vue({
              router,
              render: h => h(App)
            }).$mount('#app')
            上面挂在了new VueRouter后的实例对象,所以这里的router就是VueRouter的实例对象
           */
          Vue.prototype.$router = this.$options.router
          this.$options.router.init()
        }
      }
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

# 实现VueRouter构造函数

let _Vue = null
export default class VueRouter {
    static install (Vue) {
    ...
    }
    
    constructor (options) {
        // 创建类的时候传入的参数
        this.options = options
        // 记录路由和组件的对应关系
        this.routeMap = {}
        // 响应式对象
        /**
         * Vue.observable()
         * 这个方法的作用是用来创建响应式对象
         * 这个方法创建的响应式对象可以直接用在渲染函数或者计算属性里面
         */
        this.data = _Vue.observable({
          // 存放当前的路由地址,默认情况是/ 根目录
          current: '/'
        })
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 实现createRouterMap方法

/**
* 作用是将传入的路由规则转换成键值对存到routeMap中,键是地址,值是对应组件
*/
createRouteMap() {
    // 遍历所有路由规则
    this.options.routes.forEach(route => {
        this.routeMap[route.path] = route.component
    })
}
1
2
3
4
5
6
7
8
9

# 实现initComponents方法

/**
  * 
  * @param {*} Vue Vue构造函数
  * 可以不传因为全局有Vue构造函数,这里传是为了减少和外部的依赖
  * 
  * 要创建两个组件,router-link和router-view
  * 
  * router-link需要接收参数to,超链接的地址,内容在标签之间,要把内容渲染到a标签中
  */
  initComponents (Vue) {
    // 创建组件
    Vue.component('router-link', {
      // 接受外部的组件,用props
      props: {
        // 参数名to,类型字符串
        to: String
      },
      // 模板就是a标签,href要绑定to,所以前面加冒号,中间的内容是动态的,这里弄一个插槽
      template: '<a :href="to"><slot></slot</a>'
    })
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# init方法

let _Vue = null
export default class VueRouter {
  ...
  // 把两个包含在init方法里方便调用
  init () {
    this.createRouteMap()
    // 想初始化组件里面传入全局的_Vue构造对象
    this.initComponents(_Vue)
  }
}
1
2
3
4
5
6
7
8
9
10

# router-view组件的实现

router-view相当于占位符,内部要根据当前路由地址获取到路由组件并渲染到对应位置。

initComponents (Vue) {
    // 创建router-link组件
    Vue.component('router-link', {
      ...
    })

    const self = this
    // 创建router-view组件
    Vue.component('router-view',{
      // 获取当前路由地址对应的路由组件
      // 参数h,作用是创建虚拟DOM
      render(h) {
        // 1. 先找到当前路由地址 self.data.current
        // render函数里面的this并不是VueRouter的实例对象,initComponents的this是VueRouter的实例对象,所以用self存储
        // 2.根据路由地址在routerMap中找对应路由组件
        const component = self.routeMap[self.data.current]
        // 3.用h函数把component路由组件转化成虚拟DOM直接返回
        return h(component)
      }
    })
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 阻止向浏览器发请求

完成上面的操作,刷新浏览器点击超链接,发现浏览器在刷新向服务端发请求,我们单页应用是不希望向服务端发请求的。所以这里需要在router-link中修改:

  • 阻止浏览器默认操作跳转链接向服务器发请求
  • 将地址栏改成要跳转的地址 —— pushState()
  • 将当前地址改为要跳转的地址 —— data.current
// index.js
  initComponents (Vue) {
    Vue.component('router-link', {
      props: {
        to: String
      },
      render (h) {
        return h('a', {
          attrs: {
            href: this.to
          },
          // on中给DOM对象注册事件
          on: {
            // 点击事件,注册不加括号,加括号是调用。
            click: this.clickHandler
          }
        }, [
          // 获取默认插槽
          this.$slots.default
        ])
      },
      // 给组件添加方法
      methods: {
        clickHandler (e) {
          /**
           * 1. 修改地址栏中的地址
           * 第一个参数是data(以后触发popstate的时候,传给事件的事件对象,这里没有用到所以是空对象)
           * 第二个参数是title,网页的标题(暂时不设置)
           * 第三个参数是地址url(跳转地址,在to里面)
           */
          history.pushState({}, '', this.to)
          // 2. 将跳转地址赋值给当前对象,因为data是响应式对象,当它改变之后会重新加载对应组件并重新渲染
          this.$router.data.current = this.to
          // 3. 阻止浏览器默认事件,这里指跳转
          e.preventDefault()
        }
      }
    })

    const self = this
    Vue.component('router-view', {
      ...
    })
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 实现initEvent方法

功能已经可以点击,但是当我们点击浏览器前进后退的时候,地址栏的地址发生了变化,但是组件并没有跟着渲染。

当地址栏中的地址发生变化的时候,可以触发popstate事件,我们要监听这个事件并加载地址栏中地址对应的组件 —— popState()

// 用来注册popState事件
initEvent () {
    window.addEventListener('popstate', () => {
      // 把当前地址栏中的地址取出来,只要pathname部分,作为路由地址存储到this.data.current中,箭头函数不会改变this,this就是VueRouter的实例对象
      this.data.current = window.location.pathname
    })
}

init () {
    this.createRouteMap()
    this.initComponents(_Vue)
    // 初始化的时候调用
    this.initEvent()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 遇到vue版本不支持template的问题

# Vue的构建版本

  • 运行时版:不支持template模板,需要打包的时候提前编译,把模板转换成render函数
  • 完整版:包含运行时和编译器,体积比运行时版本大10K左右,编译器的作用程序运行的时候把模板转换成render函数,性能比运行时差。

vue-cli项目默认使用的是运行版本的vue,因为其效率更高

# 解决问题

  1. 使用完整版本的Vue

vue-cli官方文档配置参考,创建vue.config.js文件,runtimeCompiler默认是false

// vue.config.js
module.exports = {
  runtimeCompiler: true
}
1
2
3
4
  1. 使用运行时版本的Vue,自己写render函数

我们在使用单文件组件的时候,一直在写template模板,可以正常工作。因为在打包的时候将单文件的tempalte模板编译成了render函数,这叫预编译。

initComponents (Vue) {
    Vue.component('router-link', {
      props: {
        to: String
      },
      // 模板就是a标签,href要绑定to,所以前面加冒号,中间的内容是动态的,这里弄一个插槽
      // template: '<a :href="to"><slot></slot></a>'
      // h函数的作用是帮我们创建虚拟DOM,render函数调用h函数并把结果返回
      render (h) {
        // h函数可以接收三个参数
        // 第一个参数:创建元素对应选择器,可以使用标签选择器,a
        // 第二个参数:可以给选择器添加一些属性,
        // 第三个参数:是生成元素的子元素,数组形式,当前a标签的内部结构是slot,需要通过代码的形式获取slot,这个slot没有命名所以是默认的
        return h('a', {
          // DOM对象的属性在attr中写
          attrs: {
            href: this.to
          }
        }, [
          // 获取默认插槽
          this.$slots.default
        ])
      }
    })
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
更新时间: 2022-01-11 10:46