模拟 vueRouter 总结

297 阅读3分钟

VueRouter 使用

先让我们使用 vueCli 搭建一个简单的项目,我们选择 vue2 进行学习,快速看一下 VueRouter 的用法。

npm install -g @vue/cli    // 安装 vueCli
vue create hello-world     // 创建项目,创建过程中记得选择 VueRouter
npm run serve              // 启动项目

在创建出来的项目中,用到 VueRouter 的地方就以下几处:

  1. 在 router/index.js 中使用 Vue.use 注册 VueRouter 插件
  2. 在 router/index.js 中书写路由规则 routes,并传入构造函数创建 VueRouter 实例 image.png
  3. 在 main.js 中传入 vueRouter 实例到 Vue 构造函数 image.png
  4. 在 App.vue 中使用 router-link 和 router-view 组件生成页面 image.png

再看看最终的效果: image.png 切换 router-link 时,router-view 对应的组件改变,且浏览器路由地址改变。同时,如果改变浏览器地址会后退,也会改变页面。

模拟 vueRouter

我们要按照上面的 history 模式模拟一个简易的 vueRouter 插件,满足简单的使用需求。

首先,我们根据 vueRouter 的使用,一步一步来分析:

1. 使用 Vue.use 注册 VueRouter 插件

首先。了解一下 Vue.use,它接收一个函数或者对象,如果是一个方法则直接调用,如果是一个对象则调用其 install 方法。install 方法接收 2 个参数,一个是 Vue 的构造函数,一个是可选的选项对象(这里不做设置)。

所以从这句话来看,我们可以将 VueRouter 看做一个有 install 静态方法的类。

class VueRouter {
    static install(Vue) {       
    }
}

我们再想一想作为一个插件,它只需注册一次,不会重复注册的。那我们不妨在 install 方法上添加一个属性表明此组件是否被注册过。

class VueRouter {
    static install(Vue) {     
        if (VueRouter.install.installed) return
        VueRouter.install.installed = true  
    }
}

2. 书写路由规则 routes,并传入构造函数创建 VueRouter 实例

我们可以简单定义一下 VueRouter 的构造函数,参数是包含 routes 路由规则的参数 options。对于 VueRouter 实例,它应该要存储传入的 options ,且它需要一个表示当前路由地址的属性,且这个属性必须是响应式的,因为需要监听当前路由地址的变化,从而使对应的组件也自动更新。默认当前路由地址为 '/'。

我们可以使用 Vue 提供的 observable 方法将传入的对象转换为一个响应式对象。这里要用到 Vue 上提供的方法,所以在 install 中接收 Vue 的时候,应该存为全局变量便于使用。

let _Vue = null
class VueRouter {
    static install(Vue) {     
        if (VueRouter.install.installed) return
        VueRouter.install.installed = true  
        _Vue = Vue
    }
    constructor(options) {
        this.options = options
        this.data = _Vue.observable({ current: '/' })
    }
}

3. 传入 vueRouter 实例到 Vue 构造函数

创建 Vue 根实例时传入 router 实例,会往所有 Vue 实例上添加 $router 属性。

我们仔细分析这句话,有三个重点:

  • 在 VueRouter 类中怎么写会让代码等待 Vue 创建实例的时候再执行?

这要利用 Vue 给我们提供的钩子函数,在创建 Vue 实例的时候,会执行 beforeCreate 钩子函数,我们可以使用 Vue.mixin 进行全局混入,这个会影响注册之后所有创建的 Vue 实例。

  • 怎么判断是否是 Vue 根实例的创建

只有在创建根实例的时候,才会传入 router 选项。

  • 怎么往所有 Vue 实例上添加 $router 属性

往 Vue 的原型上添加,那以后创建的所有 Vue 实例上就会有这个 $router 属性。

class VueRouter {
    static install(Vue) {     
        if (VueRouter.install.installed) return
        VueRouter.install.installed = true  
        _Vue = Vue
        _Vue.mixin({
            beforeCreate(){
                // 这段代码写在 beforeCreate 中,this 的值指向的就是 Vue 实例,那么 this.$options.router 就可以取到传入的 router 选项。
                if(this.$options.router){
                    _Vue.prototype.$router = this.$options.router
                }
            }
        })
    }
    constructor(options) {
        this.options = options
        this.data = _Vue.observable({ current: '/' })
    }
}

4. 使用 router-link 和 router-view 组件生成页面

在 vueRouter 构造函数的最后,可以声明一个 initComponent 函数专门用于初始化 router-link 和 router-view 组件:

class VueRouter {
    constructor(options) {
        ...
        this.initComponent()
    }
    initComponent() {
        _Vue.component("router-link", ...)
        _Vue.component("router-view", ...)
    }
}

router-link 的结构:<router-link to="/">Index</router-link>

  1. 最终要渲染成一个超链接 -- <a>
  2. 接收一个字符串类型的参数 to 作为超链接的地址 -- props
  3. 将标签之间的内容作为超链接的内容渲染出来 -- <slot>
 _Vue.component("router-link", {
     props: { to: String },
     template: "<a :href='to'><slot></slot></a>"
})

router-view 则根据 当前路由地址路由规则 中找到对应显示组件的:

 const route = this.options.routes.find(i => this.data.current === i.path)
 _Vue.component("router-view", route.component)

替换 vueRouter

目前看起来我们已经完成了 vueRouter 的模拟,现在让我们将我们刚创建的 vue 项目中的 vueRouter 替换为我们模拟的代码:

// hello-world\src\router\index.js 文件
// import VueRouter from 'vue-router'
import VueRouter from './my-router'

为了成功运行,我们还需要将在 vue.config.js 文件中添加配置:

module.exports = defineConfig({
  runtimeCompiler: true
})

这是由于我们模拟的 vueRouter 中注册组件时使用了 template,而 runtime-only 版本的 vue 是不包含编译 template 的模块的,所以需要修改配置使用完整版本的 vue。

但是当我们替换之后,会发现,当我们点击 router-link 试图切换 router-view 时,只是改变了浏览器的路由地址,并触发了整个页面的刷新重新向服务器发送请求,而 router-view 并没有改变。这是由于 router-link 暂时只是一个链接,我们需要根据 history 和 hash 模式做出不同的响应。

history 模式

history 模式是基于 HTML5History API

当我们点击 router-link 超链接时,实际就调用了 pushState 这个 API,这个方法会改变浏览器地址栏中的地址,并将这个地址保存到浏览记录中,但不会发送请求,所以这一系列的操作都是在客户端完成的。

  1. 阻止 a 链接的默认行为
  2. 调用 pushState 改变浏览器地址栏中的地址,pushState 接收三个参数,分别是 data, title(网页的标题), url(要转跳的路径),暂时只设置 url 即可。
  3. 修改 current 的值,同步更新 router-view 组件。
initComponent() {
    const self = this
    _Vue.component("router-link", {
        props: { to: String },
        template: "<a href='' @click='handleClick'><slot></slot></a>",
        methods: {
            handleClick(e) {
                e.preventDefault()
                history.pushState({}, "", this.to)
                self.data.current = this.to
            }
        }
    })
}

但是但是哦,这样改了 router-link 之后,router-view 还是没有改变,我们再来看看 router-view 的代码:

const route = this.options.routes.find(i => this.data.current === i.path)
_Vue.component("router-view", route.component)

我们在 vueRouter 的构造函数中直接取到了当前的地址 '/',由此取到了 HomeView,当我们改变 current 时,并没有触发这个组件的改变。我想了一些办法除了使用 render 函数,也没有想到其他好的方法可以当 current 这个响应式数据变化时,同时引起页面的改变:

_Vue.component("router-view", {
  // render渲染函数接收一个 createElement 方法作为第一个参数用来创建 VNode。
  render(h) {
    const route = self.options.routes.find(i => self.data.current === i.path)
    return h(route.component)
  }
})

createElement 接收一个组件作为参数时,会创建出组件的 vnode。在 render 中使用了 current 这个响应式数据,当其变化的时候,会重新执行 render 函数,从而达到切换 router-view 对应组件的作用。

当当当!history 下的路由组件已经处理好啦,切换 router-link 改变 router-view ,以下是全部代码:

initComponent() {
    const self = this
    _Vue.component("router-link", {
        props: { to: String },
        template: "<a href='' @click='handleClick'><slot></slot></a>",
        methods: {
            handleClick(e) {
                e.preventDefault()
                history.pushState({}, "", this.to)
                self.data.current = this.to
            }
        }
    })
    _Vue.component("router-view", {
      render(h) {
        const route = self.options.routes.find(i => self.data.current === i.path)
        return h(route.component)
      }
    })
}

但是,当我们点击浏览器的前进后退按钮的时候,浏览器地址变化了,但是 router-view 的内容却没有变化,这就需要我们在 vueRouter 的构造函数中添加一个 initEvent 方法用于监听 popState 事件。

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

完整代码:

let _Vue = null
export default class VueRouter {
    static install(Vue) {     
        if (VueRouter.install.installed) return
        VueRouter.install.installed = true  
        _Vue = Vue
        _Vue.mixin({
            beforeCreate(){
                if(this.$options.router){
                    _Vue.prototype.$router = this.$options.router
                }
            }
        })
    }
    constructor(options) {
        this.options = options
        this.data = _Vue.observable({ current: '/' })
        this.initComponent()
        this.initEvent()
    }
    initComponent() {
        const self = this
        _Vue.component("router-link", {
            props: { to: String },
            template: "<a href='' @click='handleClick'><slot></slot></a>",
            methods: {
                handleClick(e) {
                    e.preventDefault()
                    history.pushState({}, "", this.to)
                    self.data.current = this.to
                }
            }
        })
        _Vue.component("router-view", {
          render(h) {
            const route = self.options.routes.find(i => self.data.current === i.path)
            return h(route.component)
          }
        })
    }
    initEvent() {
        window.addEventListener("popstate", () => {
          this.data.current = window.location.pathname
        })
    }
}

history 模式: 通过 history.pushState 修改浏览器地址,触发更新; 通过监听 popstate 事件监听浏览器前进或后退,触发更新。

hash 模式

到目前,我们已经简单模拟了 vueRouter 在 history 模式下的代码,且可以正常进行使用。那接下来我们替换成 hash 模式,看下 hash 模式的实现原理:

hash 模式:通过 location.hash 修改 hash ,触发更新; 通过监听 hashchange 事件监听浏览器前进或后退,触发更新。

let _Vue = null
export default class VueRouter {
    static install(Vue) {     
        if (VueRouter.install.installed) return
        VueRouter.install.installed = true  
        _Vue = Vue
        _Vue.mixin({
            beforeCreate(){
                if(this.$options.router){
                    _Vue.prototype.$router = this.$options.router
                }
            }
        })
    }
    constructor(options) {
        this.options = options
        this.data = _Vue.observable({ current: '/' })
        this.initComponent()
        this.initEvent()
    }
    initComponent() {
        const self = this
        _Vue.component("router-link", {
            props: { to: String },
            template: "<a href='' @click='handleClick'><slot></slot></a>",
            methods: {
                handleClick(e) {
                    e.preventDefault()
                    if (self.options.mode === 'history') {
                        history.pushState({}, "", this.to)
                    } else {
                        window.location.hash = this.to
                    }
                    self.data.current = this.to
                }
            }
        })
        _Vue.component("router-view", {
          render(h) {
            const route = self.options.routes.find(i => self.data.current === i.path)
            return h(route.component)
          }
        })
    }
    initEvent() {
        if (this.options.mode === 'history') {
            window.addEventListener('popstate', () => {
                this.data.current = window.location.pathname
            })
        } else {
            window.addEventListener('hashchange', () => {
                this.data.current = window.location.hash.substring(1)
            })
        }
    }
}