Vue-Router 原理实现

128 阅读3分钟

Vue-Router 原理实现

vue-router 的基础使用

1. Vue.use 注册路由插件:

router.js 文件

import VueRouter from 'vue-router'

Vue.use(VueRouter)

Vue.use 用于安装插件。

Vue.use(plugin) 用法:

  • 如果 plugin 是一个对象,必须提供 install 方法;
  • 如果 plugin 是一个函数,它会被作为 install 方法。

install 方法调用时,会将 Vue 作为参数传入。

Vue.use(plugin) 作用:

  1. 检查插件是否安装,如果安装了就不再安装;
  2. 如果没有安装,调用 plugin 的 install 方法,并传入 Vue 实例;

2. 创建 router 对象:

router.js 文件

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: "about" */ '../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: "about" */ '../views/Photo.vue')
    }
]

const router = new VueRouter({
    routes
})

routes:路由规则,每个对象都是路由规则。

3. 注册 router 对象:

main.js 文件

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

Vue.config.productionTip = false

new Vue({
    // 3. 注册 router 对象, 会注入 $route(路由规则,提供路由的数据:路径、参数),$router(路由对象,VueRouter 对象,提供路由相关的方法:push、replace 等,也有一个 currentRoute 属性用于获取当前路由信息 path、params、query 等)
    router,
    render: h => h(App)
}).$mount('#app')

注册 router 对象, 会注入 $route(路由规则,提供路由的数据:路径、参数),$router(路由对象,VueRouter 对象,提供路由相关的方法:push、replace 等,也有一个 currentRoute 属性用于获取当前路由信息 path、params、query 等)

4. 创建路由组件 <router-view>

App.vue 文件

<template>
  <div id="app">
    <div>
      <img src="@/assets/logo.png" alt="" />
    </div>
    <div id="nav">
      <router-link to="/">Index</router-link> |
      <router-link to="/blog">Blog</router-link> |
      <router-link to="/photo">Photo</router-link>
    </div>
    <!-- 4. 创建路由组件<router-view> -->
    <router-view />
  </div>
</template>

5. 创建路由组件 <router-link>

App.vue 文件

<template>
  <div id="app">
    <div>
      <img src="@/assets/logo.png" alt="" />
    </div>
    <div id="nav">
      <!-- 5. 创建路由组件 <router-link> -->
      <router-link to="/">Index</router-link> |
      <router-link to="/blog">Blog</router-link> |
      <router-link to="/photo">Photo</router-link>
    </div>
    <router-view />
  </div>
</template>

动态路由

const routes = [
    {
        // id 在此处为一个占位符,真正使用时,会动态传入
        path: '/detail/:id',
        name: 'Detail',
        // 开启 props,会把 URL 中的参数传递给组件,在组件中通过 props 来接受 URL 参数
        props: true,
        // 当页面不是必要的时候,可以使用页面懒加载的方式,当访问到此地址的时候,页面才会被加载
        component: ()=>import('../views/Detail.vue')
    }
]

1. 通过当前路由规则,获取数据:

<template>
    <div>
        通过当前路由规则获取:{{ $route.params.id }}
    </div>
</template>

2. 路由规则中开启 props 传参:

<template>
    <div>
        通过开启 props 获取:{{ id }}
    </div>
</template>
export default {
    name: 'Detail',
    props: ['id']
}

嵌套路由

  1. 提取相同部分到新的组件:
<template>
    <div>
        <img width="25%" src="@/assets/logo.png"></img>
    </div>
    <div>
        // 对应的页面的组件会替换掉 router-view 这个组件的位置
        <router-view></router-view>
    </div>
    <div>Footer</div>
</template>

对应的页面的组件会替换掉 router-view 这个组件的位置。

  1. 嵌套路由的路由规则配置:
import Layout from '@/component/Layout.vue'
import Index from '@/views/Index.vue'
import Login from '@/views/Login.vue'

const routes = [
    {
        name: 'login',
        path: '/login',
        component: Login,
    },
    {
        // 父页面路径
        path: '/',
        component: Layout,
        children: [
            {
                name: 'index',
                // 子页面路径
                path: '',
                component: Index,
            },
            {
                name: 'detail',
                // 子页面路径
                path: 'detail/:id',
                props: true,
                component: () => import('@/views/Detail.vue')
            },
        ],
    }
]

在使用的时候,嵌套路由会将父子页面路径合并,然后先加载父组件,再加载子组件,然后合并到一起渲染。

子组件中的 path 可以是相对路径,也可以是绝对路径。

编程式导航

  1. push 方法:
  • 指定跳转的路由路径进行跳转;
  • 指定跳转的路由名称进行跳转;
// 指定跳转的路由路径进行跳转
this.$router.push('/')

// 指定跳转的路由名称进行跳转
this.$router.push({ name: 'index' })
  1. replace 方法:
  • 与 push 方法类似,均可跳转到指定的路径,但是 replace 方法不会记录新增本次历史,而是把最新的一条历史替换为此次跳转的历史;
this.$router.replace('/login')
  1. push 方法指定路由参数:
  • params 指定的路由参数,页面刷新时无法再次获取;
  • query 指定的路由参数,页面刷新时也可以再次获取;
this.$router.push({ name: 'Detail', params: { id: 1 } }) 
this.$router.push({ name: 'Detail', query: { id: 1} })
  1. go 方法:
  • go 方法是跳转到历史中的某一次访问的页面;
  • 若为负数,表示返回,值为-1,效果与 $router.back() 方法一致;
  • 若为正数,表示前进;
this.$router.go(-1)

Hash 和 History 模式区别

都是客户端路由的使用方式,当路由地址发生变化的时候,不会向服务器发送请求。使用 js 监听路由地址的变化,然后根据不同的地址,渲染不同的页面组件。如果需要服务器端内容,则需要发送 ajax 请求进行获取。

1. 表现形式的区别:

2. 原理的区别:

  1. 原理:
    • Hash 模式:基于 URL 中的锚点和 onhashchange 事件实现的;
    • History 模式:基于 H5 中的 History API 实现的;
  2. 区别:
    • hash 模式兼容性更好,IE8 以后兼容;history 模式(使用到了 h5 的 API:history.pushState() history.replaceState()) IE10 以后才支持;

    • history 模式和 hash 模式都是客户端修改 URL,所以性能相差不大;

    • history 模式,如果没有对应的路由的规则,可能会发起一个真正原请求,需要后端来处理,如果不处理,就会产生 404;

History 模式的使用

  • History 需要服务器的支持;
  • 单页应用中,如果服务端不存在 www.testurl.com/login 这样的地址会返回找不到改页面;
  • 在服务端应该除了静态资源外都返回单页应用的 index.html;
  • history.push 和 history.pushState 方法的区别是 push 方法会在路径发生变化的同时向服务端发送请求,而 pushState 方法不会向服务器发送请求,只会改变浏览器地址栏中的地址,然后记录到历史记录中。

History 模式 - Node.js 服务器配置

const path = require('path');

// 导入 express
const express = require('express');
const app = express()

// 导入处理 history 模式的模块
const history = require('connect-history-api-fallback');

// 注册处理 history 模式的中间件
app.use(history())

// 处理静态资源的中间件,网站根目录 ../web
app.use(express.static(path.join(__dirname, '../web')));

app.listen(3000, () => {
  console.log('服务器开启,端口:3000');
});

History 模式 - nginx 服务器配置

  • 从官网上下载 nginx 的压缩包;
  • 把压缩包解压(目录中不要包含中文及特殊字符);
  • 打开命令行,切换到 nginx 所在目录;

nginx 相关命令:

# 启动
start nginx

# 重启
nginx -s reload

# 停止
nginx -s stop

nginx 配置 vue history 模式:

  1. 打包 vue 项目,并将打包好的文件复制到 nginx 映射的目录中(nginx 默认目录是 html 目录);
  2. 启动 nginx(启动的默认端口是 80 端口,若被占用,需要修改 nginx.conf 配置文件);
  3. 配置 nginx.conf 文件(localtion 中增加 try_files uriuri uri/ /index.html );
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
 
    keepalive_timeout  65;

    server {
        listen       10086;
        server_name  localhost;

        location / {
            root   html;
            index  index.html index.htm;
            try_files $uri $uri/ /index.html;
        }
    }
}

Vue Router 实现原理

Vue 前置知识

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

Hash 模式

  • URL 中 # 后面的内容只作为路径地址;(可以直接通过 location.url 切换路由地址,如果只改变了路径中 # 后面的内容,浏览器不会向服务器请求这个地址,只会把这个地址记录到浏览器的访问历史当中)
  • 监听 hashchange 事件;(hash 改变以后会触发 hashchange 事件)
  • 根据当前路由地址找到对应组件重新渲染;

History 模式

  • 通过 history.pushState() 方法改变地址栏;(该方法只改变地址栏,并把地址记录到浏览器访问历史当中,并不会真正跳转到该地址,即不会向服务器发送请求)
  • 监听 popstate 事件;(可以监听到浏览器地址的变化 ,在 popstate 事件处理函数中,可以记录改变后的地址;【注】当调用 pushState() 和 replaceState() 方法的时候,并不会触发 popstate 事件,当点击浏览器前进和后退按钮的时候(即:当历史状态被激活的时候),或者调用 history.back() 方法时才会触发该事件)
  • 根据当前路由地址找到对应组件重新渲染;

Vue Router 模拟实现

核心代码

// router/index.js

// 注册插件
Vue.use(VueRouter)

// 创建路由对象
const router = new VueRouter({
    routers: [
        { name: 'home', path: '/', component: homeComponent }
    ]
})

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

Vue Router 类图

classDiagram
class VueRouter {
    + options
    + data
    + routeMap

    + Constructor(Options): VueRouter
    _ install(Vue): void
    + init(): void
    + initEvent(): void
    + initRouteMap(): void
    + initComponents(Vue): void
}

install

let _Vue = null;

export default class VueRouter {
  static install(Vue) {
    console.log("install: ", Vue);
    // 1. 判断当前插件是否已经被安装;
    if (VueRouter.install.installed) {
      return;
    }
    VueRouter.install.installed = true;

    // 2. 把 Vue 构造函数记录到全局变量;
    _Vue = Vue;

    // 3. 把创建 Vue 实例时候传入的 router 对象注入到每一个 Vue 实例上;(即挂载到构造函数的原型上)
    // _Vue.prototype.$router = this.$options.router;
    // 混入
    _Vue.mixin({
      beforeCreate() {
        console.log("beforeCreate: ", this.$options.router);
        if (this.$options.router) {
          _Vue.prototype.$router = this.$options.router;
          this.$options.router.init();
        }
      },
    });
  }
}

constructor

let _Vue = null;

export default class VueRouter {
...

  constructor(options) {
    console.log("constructor");
    this.options = options;
    this.routeMap = {};
    this.data = _Vue.observable({
      current: "/",
    });
  }
}

initRouteMap

let _Vue = null;

export default class VueRouter {
...
  
  /**
   * 解析 optins 中的路由规则
   */
  initRouteMap() {
    console.log("initRouteMap");
    // 遍历所有路由规则,把路由规则解析成键值对的形式,并存储到 routeMap 中;
    this.options.routes.forEach((route) => {
      this.routeMap[route.path] = route.component;
    });
  }
}

initComponents(router-link)

let _Vue = null;

export default class VueRouter {
...

  /**
   * 创建 router-view 和 router-link 组件
   *
   * @param { Object } Vue Vue 的构造函数
   */
  initComponents(Vue) {
    console.log("initComponents");
    Vue.component("router-link", {
      props: {
        to: String,
      },
      template: '<a :href="to"><slot></slot></a>',
    });
  }
}

init

let _Vue = null;

export default class VueRouter {
...

  init() {
    this.initRouteMap();
    this.initComponents(_Vue);
  }
}

Vue 的构建版本

此时使用我们编写的 vuerouter 会出现如下问题

image.png

错误提示:你使用的是 Vue 的纯运行时构建,模板编译器不可用。要么预先将模板编译成渲染函数,要么使用包含编译器的构建。

Vue 的构建版本:

  • 运行时版:不支持 template 模板,需要打包的时候提前编译,把 template 模板编译成 render 函数,运行时版再通过 render 函数创建虚拟 dom 然后再渲染到视图;
  • 完整版:包含运行时和编译器,体积比运行时版大 10K 左右,编译器版作用是在程序运行的时候把模板转换成 render 函数,因此性能不及运行时版;

vue-cli 创建的项目,默认使用运行时版的 Vue,因此会产生此错误;

完整版的 Vue

runtimeCompiler

  • Type: boolean

  • Default: false

    是否使用包含运行时编译器的 Vue 构建版本。设置为 true 后你就可以在 Vue 组件中使用 template 选项了,但是这会让你的应用额外增加 10kb 左右。

    更多细节可查阅:Runtime + Compiler vs. Runtime only

vue.config.js

const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  runtimeCompiler: true,
});

运行时版本的 Vue

不带编译器的 Vue 不支持组件中的 template 选项。因为编译器的作用是把 template 编译成 render 函数,所以在运行时版本的 Vue 中,我们的组件可以直接写 render 函数,可见使用运行时版本的 Vue 解决此问题的重点落在 render 函数的写法;

/vuerouter(自定义 vue-router)/index.js

let _Vue = null;

export default class VueRouter {
...
  /**
   * 创建 router-view 和 router-link 组件
   *
   * @param { Object } Vue Vue 的构造函数
   */
  initComponents(Vue) {
    console.log("initComponents");
    Vue.component("router-link", {
      props: {
        to: String,
      },
      // template: '<a :href="to"><slot></slot></a>',
      render(h) {
        // h 是一个函数,其作用是创建虚拟 dom,h 函数由 Vue 传入
        // 第一个参数:选择器;
        // 第二个参数:设置属性,通过 attrs;
        // 第三个参数:设置标签间的内容,此处通过 this.$slots.default 获取默认插槽的方式获取;
        return h(
          "a",
          {
            attrs: {
              href: this.to,
            },
          },
          [this.$slots.default]
        );
      },
    });
  }
...
}

initComponents(router-view)

let _Vue = null;

export default class VueRouter {
...

  /**
   * 创建 router-view 和 router-link 组件
   *
   * @param { Object } Vue Vue 的构造函数
   */
  initComponents(Vue) {
    console.log("initComponents");
    Vue.component("router-link", {
      props: {
        to: String,
      },
      // template: '<a :href="to"><slot></slot></a>',
      render(h) {
        // h 是一个函数,其作用是创建虚拟 dom,h 函数由 Vue 传入
        // 第一个参数:选择器;
        // 第二个参数:设置属性,通过 attrs;
        // 第三个参数:设置标签间的内容,此处通过 this.$slots.default 获取默认插槽的方式获取;
        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];
        // h 函数也可以直接将路由组件转化为虚拟 dom;
        return h(component);
      },
    });
  }

...
}

initEvent

问题背景:

按照我们现有的实现,当我们点击浏览器前进和后退按钮的时候,会发现页面没有重新加载当前地址栏中地址对应的组件。

解决思路:

把当前地址栏地址(路由路径部分)取出,并把改地址作为当前路由地址,存储到 this.data.current 中。

let _Vue = null;

export default class VueRouter {
...

  /**
   * 用于包装 initRouteMap 方法和 initComponents 方法,便于调用
   */
  init() {
    this.initRouteMap();
    this.initComponents(_Vue);
    this.initEvent();
  }
  
  initEvent() {
    window.addEventListener("popstate", () => {
      // 把当前地址栏地址(路由路径部分)取出,并把改地址作为当前路由地址,存储到 this.data.current 中
      this.data.current = window.location.pathname;
    });
  }
}