超燃|从0到1手把手带你实现一款Vue-Router

写在开头

无论是日常业务还是面试过程中,相信大家对于前端路由这个话题或多或少都有自己的应用和理解。

或许你从未了解过 Vue-Router 底层源码实现,又或许你仅仅知道它是基于一系列 hashchange 、 popstate 事件劫持路径变化从而动态根据 js 内容渲染页面。

别担心,我会在这篇文章中跟随 Vue-Router 的源码,用最通俗易懂的方式从零到一带你打造一款属于你自己的 前端路由框架 。

从此无论是面试过程还是日常应用中,在理解了文章中的思路与代码之后,我相信在任何场景下只要提及前端路由相关话题,你都可以做到真正的游刃有余。

在开始实现之前

在开始实现之前我稍稍笔者自己稍微有一些心里话想要为大伙儿唠叨唠叨。

任何学习的过程都是枯燥且乏味的,但这恰恰是一种成长。

我们需要明白简单的东西背后一定不简单,就像我们家里拧开水龙头有自来水或者插上插座就有电,但后面供应自来水和电力的那套东西极其复杂

文章中的确会有一些东西的确会很枯燥晦涩,但是这正是我写这篇文章的初衷,希望在加固自身对于知识的理解程度上同时为大家带来一份更加通俗易懂的源码解读文章。

相信我,在你真的理解它之后。你会觉得它的核心思路无非也不过如此。

文章的完整代码我已经放在了这个地址中,强烈建议大家可以对照代码来阅读文章。

为什么选择 Vue-Router

市面上存在很多关于前端路由的优秀框架,比如 React-Router、Vue-Router 等等之类。

我之所以选择 Vue-Router 的原因和大家展开前端路由的话题主要有两点:

  • 首先,Vue 在国内的用户体量是非常巨大的。

国内大部分前端开发者对于 Vue 这个框架多少都会有了解,基于国内 Vue 的用户体量所以我选择 Vue-Router 这款优秀框架。

  • 其次,Vue-Router 的 Api 更加友好。

在进行路由分析时,我主要犹豫在 Vue-Router 和 React-Router 这两款优秀的框架之中,相较于 React-Router 我个人认为 Vue-Router 对外暴露的 API 更加利于用户。

所以自然而言,Vue-Router 的实现会相较于 React-Router 稍显复杂一些正是基于这个原因我想通过 Vue-Router 带大家彻底搞懂所谓前端路由的核心原理与实现。

  • 选择 vue-router 而非 vue-router-next

之所以选择稍微比较旧版本的 vue-router,是希望更多人可以参与到文章之中。但是并不代表就 vue-router 已经过时没有必要学习,针对于 next 前后版本它们的实现原理相差无几。

当然,无论是 React-Router 还是 Vue-Router 亦或是其他任何路由框架,他们的核心实现原理都是大同小异的。

你可以对照 [email protected] 源码来参考,文章中的代码会删除服务端渲染部分仅保留前端路由逻辑,和源码有部分出入。

目录结构

工欲善其事,必先利其器。在开始首先让我们首先来创建基础的目录结构吧。

image.png

这里我利用 vue-cli 创建了一个基础的 vue 项目模板,接下来让我们为他稍加修改。

路由配置表

首先我们需要在 router/index.js 下拥有这样的一份路由嵌套配置列表:

image.png

填充文件内容

同时我们需要创建对应的 about、home 文件目录以及内容:

image.png

具体的文件内容非常简单:

  • About.vue、Home.vue 两个页面中分别存在两个 router-link 标签永远跳转对应的各自子路由页面。

  • about-children、home-children 两个文件夹存放对应嵌套子路由的文件目录,对应的文件内容特别简单。比如 home-children/home1.vue 中它的内容如下:

<template>
  <div>
    <h3>Home 1 Page!</h3>
  </div>
</template>
复制代码

类似 home2、about1、about2 也是同样的内容,不同的仅仅是 h3 标签中展示的内容是各自的文件名称。

你可以点击这里查看具体目录和展现效果 CodeSanBox

更改 Vue-Router 库

此时,基础的目录结构已经搭建完毕,让我们来看看页面展现效果:

QQ20220103-162130-HD.gif

看起来很简单吧,随着我们点击页面上的 router-link 标签页面路由变化从而渲染对应的组件

接下来,我们就要一步一步来实现属于我们自己的 vue-router。

安装逻辑

首先我们来看看,Vue-Router 在项目中是如何应用的:

// router/index.js
import VueRouter from 'vue-router';
// ...

Vue.use(VueRouter);

// ...

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});
// ...

export default router
复制代码

VueRouter 类基础结构

首先这里我们通过 Vue.use(VueRouter) 调用 Vue 的 use() 方法来安装注册 VueRouter 插件。

那么就让我们先从 vue-router 的安装逻辑说起吧。

首先让我们现在项目的src目录下创建一个文件夹以及两个文件:

  • src/vue-router 目录,用来存放我们自己实现的 vue-router 库。

  • src/vue-router/index.js 入口文件。

  • src/vue-router/install.js 安装注册 VueRouter 的注册方式。

让我们先来看看 src/vue-router/index.js ,在使用时我们通过 new VueRouter 的方式进行调用,由此可知入口文件中需要导出一个基础的 VueRouter 类对象:

class VueRouter {
  constructor() {
    // do something
  }
}

export default VueRouter
复制代码

install 方法

Vue.use() API

此时再来让我们会到刚才新建的 install.js 中吧。

在 Vue 中如果你需要注册一款全局 Vue Plugin ,需要通过调用 Vue.use(Plugin) API。

稍微复习下,关于 Vue.use(Plugin) 的 Plugin,注册的 Plugin 需要满足:

  • 当需要注册的 Plugin 是一个如果是一个对象,那么这个对象上必须存在 install 的方法。

  • 如果注册的 Plugin 是一个函数,那么这个函数上必须存在一个静态 install 方法。

当我们通过 Vue.use() 调用时,会调用对应注册插件的 install 方法,同时传入 Vue 构造函数对象作为参数。

在搞明白了 Vue.use() 方法之后,让我们来一步一步填充 install.js 中的逻辑吧:

install 方法基础逻辑

// vue-router/install.js
let _Vue;

export default function install(Vue) {
  // 如果已经安装过了
  if (install.installed && _Vue === Vue) return

  // 首次调用Vue.use(VueRouter)时,将install.installed变为true
  install.installed = true

  // 保存传入的Vue 提供给别的模块使用Vue
  _Vue = Vue

  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        // 如果当前options存在router对象 表示该实例是根对象
        this._rootRouter = this
        this._rootRouter._router = this.$options.router
      } else {
        // 非根组件实例
        this._rootRouter = this.$parent && this.$parent._rootRouter
      }
    },
  })

  // 注册组件
  Vue.component('router-link', Link)
  Vue.component('router-view', View)

  // 定义原型$router对象
  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._rootRouter._router
    }
  })

  // 定义原型$route对象 
  // to do ...
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return {}
    }
  })

}
复制代码

首先我们先来完成一下最基础的 install.js,目前代码中的每一行我都已经进行了详细的注释,后续剩余未完成的逻辑,我会带你逐步为该方法补充相应的逻辑。

这里 install.js 中有一些逻辑我想刻意强调下:

  1. _Vue 变量

首先,在 install 方法中我们提到过 Vue.use() 时会传入当前 Vue 的构造函数对象此时我们利用 _Vue 保存外部传入的 Vue ,这样可以提供给我们自己库中的任意模块获得当前版本的 Vue 对象。

  1. Vue.mixins(...)

在 install 方法中我们利用了 Vue.mixins API 为每一个通过该 Vue 创建的实例对象注入了一段 beforeCreate 的逻辑。

也许有朋友对于 beforeCreate 中做的事情不是很理解,最初看到源码这里我也稍微有点绕。但是没关系,让我为你稍微解读下这段逻辑:

首先在项目的入口文件中,通常是 main.js 中,我们会这样使用 router 实例对象:

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

Vue.config.productionTip = false

// 创建一个根Vue实例对象 同时传入创建好的router实例对象
new Vue({
  router,
  render: h => h(App)
}).$mount('#app')
复制代码

我们在项目的入口文件中,如果使用到了 vue-router ,通常会将初始化后的 router 对象传入到 new Vue 的参数中去,此时在根组件实例上我们可以通过 this.$options.router 来获取到创建的 router 实例。

在 install 中我们为每一个组件实例通过 mixin 注入了一个 beforeCreate 钩子,在每个组件实例创建之前我们进行判断:

  • 如果该组件是根组件实例对象 this.$options 上存在 router 对象上, 此时该组件是根组件对象。

我们在根组件实例对象上定义了一个 _rootRouter 对象,为自身实例对象。

同时为自身实例上定义了一个 _router 属性,它的值即是我们外部传入的 router 实例对象 this.$options.router

  • 如果该组件非根组件对象,同样我们为该组件定义了一个名为 _rootRouter 的属性。

Vue 组件的创建过程是从父组件到子组件的过程,简单来说也就是每次组件渲染时首先会执行根组件混入的 beforeCreate 逻辑,之后在执行子组件的 beforeCreate 逻辑。

也就是说每次子组件创建时会在自身挂载一个 _rootRouter 属性,它会指向根组件实例上对象,你可以将这个过程想象成为一个圣诞树。

你也许会好奇为什么我们需要这样做,别着急我们来看后边的这段代码:

 //...

 // 定义原型$router对象
  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._rootRouter._router
    }
  })
 
 // ...
复制代码

我们在 Vue.prototyep 原型对象上定了一个名为 $router 的 get() 属性,任何组件实例对象上都可以通过 this.$router 访问到根组件初始化时传入的 router 对象。

之所以上边这样做是为了统一调用 API 方法,任何组件可以通过 this._rootRouter 访问到根组件,自然而言我们就可以通过 this._rootRouter._router 获取到传入的 router 实例。

看到这了,你也许会稍微反应过来一些。平常我们在代码中使用的 this.$router 其实就是通过 Object.defineProperty 代理到的根组件的 router 对象。

  1. 组件注册 & 定义属性

在使用 Vue-Router 时,它会帮我们注入两个组件分别是:

  • <router-view></router-view>

  • <router-link></router-link>

我们会在之后实现这两个组件的具体逻辑,这里仅仅需要明白是在 install 方法中对这两个组件进行了注册即可。

同时让在 src/vue-router/components 中建立这两个组件对应的文件 link.js/view.js

image.png

同时我们可以看到,在 install 方法的结尾我们定义了各个组件实例上的 $router$route 两个属性的代理。

此时,install 方法的基础逻辑已经完成了。我们将 install 方法导出给 index.js 中的 VueRouter 类使用:

import install from './install';

class VueRouter {
  constructor() {
    // do something
  }
}

VueRouter.install = install;

export default VueRouter;
复制代码

此时,当我们调用 Vue.use(VueRouter) 时,会调用 install 方法。注册完成后:

  • 任何组件实例可以通过 $router 获取创建的 VueRouter 实例对象。

  • 任何组件实例拥有 $route 属性,当然这里我们还没有实现。

  • 同时定义了两个全局组件,分别是 routerView 以及 routerLink 标签。

VueRouter Class

接下来让我们回到 vue-router/index.js 文件中来,继续完善它的逻辑。

code.png

上边的代码是通常在我们使用 VueRouter 时,初始化的方式。

可以看到,在初始化 new VueRouter 时传递了三个参数:

  • mode:表示路由的模式,它支持三种模式分别是 history、hash、abstract 。这里我们先不考虑 abstract 非浏览器环境路由的情况。

  • base:应用的基路径。

  • routes:传入的路径配置选项,在开始之前我们已经配置了一份路由配置表。

接下来让先来为它填充一些实现逻辑:

import { createMatcher } from './crate-matcher';
import install from './install';

class VueRouter {
  constructor(options) {
    this.options = options;

    // 创建路由匹配器
    this.matcher = createMatcher(options.routes || []);

    // 获取路由模式 默认是hash
    const mode = options.mode || 'hash';
    switch (mode) {
      case 'hash':
        // do something
        break;
      case 'history':
        // do something
        break;
    }
  }
}

VueRouter.install = install;

export default VueRouter;
复制代码

首先,我们在 VueRouter 的构造函数中:

  • 通过 this.options = options 保存了外部传入的属性。

  • 进行模式匹配,根据传入的路由模式进行不同的逻辑处理,这里我们先放一放这部分实现逻辑。

  • 我们创建了一个 this.matcher 的匹配器对象,它是通过 createMathcer(options.routes) 来实现的。

接下来我会带你先去看看 createMatcher 方法。

createMatcher 创建匹配器对象

首先让我们在 vue-router 目录下创建一个 createMatcher.js 文件。

createMatcher 函数分析

在进入实现这个函数之前,我会简单和你聊聊这个函数的目的是要做什么。

通常我们在 new VueRouter(options) 时,传入的是一个拥有 children 的嵌套结构的路由映射表

我们正是需要 createMatcher 方法将传入的多维度路由数据表格式化成为一维列表,比如我们上方配置的:

image.png

可以看到它是一个嵌套结构,VueRouter 这样设计是为了开发者在开发时拥有更加直观的路由嵌套结构,它在源码中是将多维度的嵌套结构展开变成一维映射表方便后续处理。

比如上边的结构会转变成为:

{
    '/': {
        component: Home,
        path: '/',
        name: '/',
    },
    '/home1': {
        component: Home1,
        path: '/home1',
        name: 'Home1'
    }
    // ...
}
复制代码

同时在 createMatcher 方法中也会定义一系列 API 暴露出来,比如:

  • addRoute() 添加一条新路由规则。

  • addRoutes() 动态添加更多的路由规则。参数必须是一个符合 routes 选项要求的数组。

  • getRoutes() 获取所有活跃的路由记录列表。

  • match() 根据传入路径,获得当前路由对象匹配的所有记录对象。

上边我们说到过 createMatcher 方法会将外部传入的路由数据表进行扁平化,自然他的内部也会维护一份扁平化后的路由列表。

那么此时,如果需要寻找路径匹配的路由记录或者动态注册路由,自然都是对于映射表数据结构的增删改查操作就可以快速实现内容。

createMatcher 函数实现

接下里就让我们去实现这个 createMatcher 这个函数。

import createRouteMap from './create-router-map';
/**
 *
 *
 * @export
 * @param {*} routes 初始化时传入的路哟配置列表
 */
export function createMatcher(routes) {
  // 首先初始化需要格式化路由对象 将传入的路由列表进行扁平化
  const { pathList, pathMap, nameMap } = createRouteMap(routes);

  // 动态注册单个路由 本质上还是参数的重载
  // 当动态注册单个路由时 支持覆盖同名路由
  // 同时注册单个路由支持指定在特定的路由中添加子路由 支持parent参数
  function addRoute(parentOrRoute, route) {
    // 如果第一个参数传递了非Object对象,那么表示它不是路由对象 代表传递的是对应的parent路由的名称
    const parent =
      typeof parentOrRoute !== 'object' ? nameMap[parentOrRoute] : undefined;
    return createRouteMap(
      [route || parentOrRoute],
      pathList,
      pathMap,
      nameMap,
      parent
    );
  }

  // 动态注册多个路由
  function addRoutes(routes) {
    return createRouteMap(routes, pathList, pathMap, nameMap);
  }

  // 获取当前所有活跃的路由记录
  function getRoutes() {
    return pathList.map((path) => pathMap[path]);
  }

  // TODO:通过路径寻找当前路径匹配的所有record记录
  function match() {
    // do something
  }

  return {
    addRoute,
    addRoutes,
    getRoutes,
    match,
  };
}
复制代码

createMatcher 方法内部抽离了数据格式转化的逻辑,它在方法内部调用了 createRouteMap(routes) 方法,传入初始化时的路由配置列表。

createRouteMap(routes) 是真正扁平化路由列表的方法,稍微我会带你深入这个方法。此时,我们仅仅专注于 createMatcher 方法内部的逻辑即可。

上边我们提到过,在 createMatcher 方法中需要维护一份格式化后的路由映射表以及对应的路由方法

接下来我为你解释一下这个函数内部的详细内容:

  1. createRouteMap 方法

首先在创建 matcher 的开头,用户传入的静态路由配置表通过 createRouteMap 函数进行扁平化并且返回了三个值分别为:

  • pathList

    这是一个包含当前所有路由路径的数组,比方上边我们的路由配置表格式化的 pathList 便是['/home1', '/home2', '/', '/about/about1', '/about/about2', '/about']

  • pathMap

    这是一份包含当前所有路由的映射表对象,它类似于这样的结构:

    {
       "/home1": {
          "name": "Home1",  // 路径对应的路由名称
          "component": {}, // 路径对应的渲染组件
          "path": "/home1", // 路由路径
          "props": {}, // 路径对应的组件props
          "meta": {}, // 路径对应的组件meta
          "parent": { ... } // 该路径的父路由记录对象
       },
       ...
    }
    复制代码

    通过 createRouteMap 方法返回的 pathMap 正是维护着这样一份路由路径为 key,路径记录对象为 value 的映射表。

    所谓路径记录 Record 对象即是表示 pathMap 中对应的 value 值。

  • nameMap

    nameMap 与 pathMap 同理,它维护的是一个份名称映射表:

    {
       "Home1": {
          "name": "Home1",
          "component": {},
          "path": "/home1",
          "props": {},
          "meta": {},
          "parent": { ... }
       },
       // ...
    }
    复制代码
  1. addRoute 方法

    你可以在这里看到这个方法的具体使用Vue-Router router.addRoute,简单来说这个方法就是动态注册路由。需要额外注意的是该方法内部进行了参数的重载

    • 如果仅传递一个参数则会直接在跟路径下动态添加传入的路由记录

    • 如果传入两个参数,它支持第一个参数指定父路由的名称,此时添加的路由会在指定的父路由中添加。

  2. addRoutes 方法

    你可以在这里看到这个方法的具体使用Vue-Router router.addRoutes,该方法支持在根路由上动态注册多个路由。同时他的内部同样是基于 createRouteMap 去实现的。

  3. getRoutes 方法

    同样你可以在这里看到他的用法Vue-Router router.getRoutes,我们提到过 createMatcher 方法内部会维护一份 pathList ,我们仅需要遍历当前 pathList 中所有活跃的路由路径获得每一个的路由 Record 对象进行返回即可。

  4. match 方法

    match 方法在之后我会带大家详细实现,它的主要作用就是通过传入一个 location 的路径参数,返回当前路径匹配到的所有路由 Recored 记录对象。

可以看到在 createMatcher 函数中做的事情是非常纯粹的,通过这个函数我们创建了一个匹配器。

匹配器内部会维护处理后的路由数据结构,同时暴露方法提供给外部使用。,至于处理数据的细节 createMatcher 匹配器函数并不关心如何实现。

这一步我们在 VueRouter 的构造函数初始化时,通过 this.matcher = createMatcher(routes) 方法为 VueRouter 后续实例对象定义了一个 matcher 匹配器属性。

它的内部维护了格式化后的路由匹配列表以及暴露出对应可以修改路由列表的 API 。

VueRouter 中的 createMatcher 源码你可以在这里看到,它的内部还会处理一些关于 alias、redirect 之类的逻辑。

createRouteMap 格式化路由 Record

上边通过 createMatcher 方法在 VueRouter 实例中创建了一个匹配器对象,在 createMatcher 函数中正是通过 createRouteMap 方法来格式化路由对象的,接下里让我们一步一步来实现这个方法。

createRouteMap 原理分析

首先 createRouteMap 方法上边提到过它需要暴露三个数据对象,分别是:

  • pathList

  • pathMap

  • nameMap

在 createMatcher 匹配器章节我已经和大家探讨过这三个对象分别代表的含义,这里我就不在累赘了。

同时在 createMatcher 方法中,有以下几个地方用到了 createRouteMap 方法:

  • 首次 new VueRouter(options) 时会调用 createMatcher 从而调用 createRouteMap(routes) 传入初始路由配置列表,此时 createRouteMap 支持传入初始化的路由列表进行扁平化。

  • 在调用 addRoute 方法时会通过 createRouteMap 传入旧的 pathList 、 pathMap 、 nameMap、parent 几个对象,同时这个方法支持在原有的路由数据列表中增加路由对象。

createRouteMap 代码实现

在搞清楚了 createRouteMap 内部需要做的事情之后让我们来一起来实现这个方法,首先在 vue-router 目录下创建 create-router-map.js

/**
 * @export
 * @param {*} routes 需要注册的路由表(未格式化)
 * @param {*} oldPathList 已经格式化好的路径列表
 * @param {*} oldPathMap 已经格式化好的路径关系表
 * @param {*} oldNameMap 已经格式化好的名称关系表
 */
export default function createRouteMap(
  routes,
  oldPathList,
  oldPathMap,
  oldNameMap,
  parentRoute
) {
  // 获取之前的路径对应表
  const pathList = oldPathList || [];

  // 创建本次格式化的 pathMap 和 nameMap 对象
  const pathMap = oldPathMap || Object.create(null);
  const nameMap = oldNameMap || Object.create(null);

  // 递归格式化路径记录
  routes.forEach((route) => {
    addRouteRecord(pathList, pathMap, nameMap, route, parentRoute);
  });

  return {
    pathList,
    pathMap,
    nameMap,
  };
}
复制代码

首先你可以看到 createRouteMap 支持传入:

  • 一个必选参数,routes 表示本次需要添加(格式化)的路由列表,它是一个数组。

  • 可选参数 oldPathList ,正如名字那样,它表示旧的 pathList ,同样是一个数组。

  • 可选参数 oldPathMap ,旧的路由路径映射表。

  • 可选参数 oldNameMap ,旧的路由名称映射表。

  • 可选参数 parent ,表示需要添加的。

    关于 parent 参数我想刻意强调下,在 vue-router 中路由是支持嵌套的。

    在进行格式化时,如果指定了 parentRoute 参数时会将格式化后的 routes 对象添加到指定的 parent 路由对象中,而非根路由节点上。

createRouteMap 函数内部逻辑也非常简单,它对于在内部初始化了一系列 pathList、pathMap、nameMap 对象。

同时对于本次需要注册的对象数组 routes 中每一个路由对象调用 addRouteRecord 方法进行格式化路由处理,这是一个递归的过程。

addRouteRecord 方法

在 createRouteMap 函数内部将递归处理数据的过程单独抽离成为了一个 addRouteRecord 方法,它的作用就是根据传入的 route 对象,更新 pathList,pathMap,nameMap 的值。

我们先来看看它的实现:

function addRouteRecord(pathList, pathMap, nameMap, route, parent) {
  const { path, name } = route;

  const normalizedPath = normalizePath(path, parent);
  // 根据route构造record对象
  const record = {
    name: route.name,
    component: route.component,
    path: normalizedPath,
    props: route.props || {},
    meta: route.meta || {},
    parent,
  };

  // 递归添加children属性
  if (route.children) {
    route.children.forEach((child) => {
      addRouteRecord(pathList, pathMap, nameMap, child, record);
    });
  }

  // 不存在则添加进入pathMap
  if (!pathMap[record.path]) {
    pathList.push(record.path);
    pathMap[record.path] = record;
  }

  // 不存在则添加进入nameMap
  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record;
    }
  }
}
复制代码

我们一起来看看 addRouteRecord 方法的具体实现过程,这个方法内部接受的参数在上边我们已经啰嗦过很多次了。

首先这个方法内部获取到传入的 route 对象的 path 属性和 name 属性,关于 normalizePath 方法之后我们回去实现它,它的作用即是之前提到过关于嵌套路由的路径拼接。

这里你仅仅需要了解通过 normalizePath 方法我们获取到了当前 route 对象格式化后的路径 normalizedPath 。

之后我们根据本次传入的 route 对象创建了一个路由记录对象,我们称它为 Record 。

如果传入的 route 存在 children 属性的话递归调用该方法将 route.children 中的路由对象创建 Record 添加进入 pathList,pathMap 以及 nameMap 中去。

在之后的逻辑就很简单了,判断对应 pathMap 与 nameMap 中是否已经存在当前路由对象了,如果不存在时则进行添加。

此时我们再回过头来看看 normalizePath 方法:

/**
 *
 * 格式化路径 主要用于拼接嵌套路由的路径
 * @param {*} path
 * @param {*} parent
 * @returns
 */
function normalizePath(path, parent) {
  // 如果不存parent记录
  if (!parent) {
    return path;
  }

  // 如果path以/开头 表示不需要拼接路径
  if (path.startsWith('/')) {
    return path;
  }

  // 判断parent.path 是否以/结尾
  if (parent.path.endsWith('/')) {
    return `${parent.path}${path}`;
  } else {
    return `${parent.path}/${path}`;
  }
}
复制代码

这个方法做的其实很简单,通俗来说就是格式化路径,对于嵌套路由进行路径拼接。

到这一步我们的 createRouteMap 会根据传入的参数最终将路由进行格式化,比如文章开头我们 Deom 中传入的路由配置列表在格式化后会返回以下数据:

  • pathList 数组

image.png

  • pathMap 路径映射表

image.png

  • nameMap 路径映射表

image.png

通过 createRouteMap 方法会返回这三个主要对象提供给 createMatcher 函数。

同时 createMatcher 内部会维护这份格式化后的路由映射表,并且暴露出一系列 API 提供提供给开发者来操作 router 中维护的这份路由映射表。

提供路由修改 API

此时让我们来回到最初的 vue-router/index.js 中来完善这几个方法:

import { createMatcher } from './crate-matcher';
import install from './install';

class VueRouter {
  constructor(options) {
    this.options = options;

    // 创建路由匹配器
    this.matcher = createMatcher(options.routes || []);

    // 获取路由模式 默认是hash
    const mode = options.mode || 'hash';
    switch (mode) {
      case 'hash':
        // do something
        break;
      case 'history':
        // do something
        break;
    }
  }

  // 注册多个路由
  addRoutes(routes) {
    this.matcher.addRoutes(routes);
  }

  // 注册路由
  addRoute(parentOrRoute, route) {
    this.matcher.addRoute(parentOrRoute, route);
  }

  // 根据获取当前所有活跃的路由Record对象
  getRoutes() {
    return this.matcher.getRoutes();
  }
}

VueRouter.install = install;

export default VueRouter;

复制代码

我们在 VueRouter 类上分别定义了三个原型方法,它们内部都是通过调用 this.matcher 进行实现的。

期中总结

首先恭喜大家可以坚持到这里,在之前我们完成了 VueRouter 中初始的逻辑:

在创建 VueRouter 实例对象时格式化传入的 routes 路由表,同时在 VueRouter 原型上定义 addRoute、addRoutes 等方法修改内部路由表从而实现动态增加路由的效果。

如果你对之前的逻辑存在疑惑,那么此时我建议你稍稍回头在读一读文章中不太明白的点。

之前我们所的事情主要就是一点:格式化外部传入的路由表,创建对应的路由映射记录关系。

history 路由对象

在之前,我们获得了格式化后的路由映射表,接下来就让我们去实现核心的跳转逻辑吧。

前端路由原理

当前前端路由主要分为两种类型

传统的前端路由代表主要就是 hash 模式,在 URL 通过 onhashchange 事件从而根据路由匹配到的 record 对象动态渲染页面,这种方式最直接的体现就是路由路径上会出现 # 显得非常丑陋。

另一种比较火热的模式即是基于 HTML History API 进行的 H5 路由,基于 window.onpopstate 事件,每当当前激活状态的历史记录发生变化时触发 window.popstate 事件从而执行相应的逻辑从而渲染页面。

关于 H5 History 模式的路由方式,是额外需要服务端支持的。因为基于这种前端路由的方法,当路由发生变化有时会是会去发送完整路径去请求服务端,而 hash 则不会。

这两种模式更多的区别你可以参考 MDN History API 查阅。

创建 histroy 属性

首先,让我们回到 vue-router/index.js 中,来补充之前遗留关于 mode 的处理:

import { createMatcher } from './crate-matcher';
import { HashHistory } from './history/hash';
import { HTML5History } from './history/html5';
import install from './install';

class VueRouter {
  constructor(options) {
    this.options = options;

    // 创建路由匹配器
    this.matcher = createMatcher(options.routes || []);

    // 获取路由模式 默认是hash
    const mode = options.mode || 'hash';
    this.mode = mode;

    switch (mode) {
      case 'hash':
        this.history = new HashHistory(this);
        break;
      case 'history':
        this.history = new HTML5History(this);
        break;
    }
  }
  
  ...
}

VueRouter.install = install;

export default VueRouter;
复制代码

这里我们会根据不同的 mode 从而创建不同的路由方式创建不同的类实例方法赋值给 this.history 属性。

之所以将 hash 和 history 模式的实例对象都定义给 this.history 属性,是因为针对于两种不同的路由方式我们希望提供给外部的 API 是一致的。

比如我们可以统一调用 this.histroy.push 方法进行跳转,而在各自的类实现中分别实现自身的 push 方法中的不同逻辑即可。

base & hash & html5

接下来让我们去实现对应的 HashHistroy 和 HTML5History 这个两个类。

首先让我们在 vue-router 中创建一个 history 文件夹来存放对应的路由类:

创建基础文件

  • 创建 vue-router/history/base.js
export class BaseHistory {
  // ...
}
复制代码
  • 创建 vue-router/history/hash.js
import { BaseHistory } from './base';

export class HashHistory extends BaseHistory {
  // ...
}
复制代码
  • 创建 vue-router/history/html5.js
import { BaseHistory } from './base';

export class HTML5History extends BaseHistory {
  // ...
}
复制代码

我们分别创建了三个文件 base.jshash.js 以及 html5.js

可以看到 HashHistory 和 HTML5History 都继承了 BaseHistory 这个父类,这样做的好处是我们在上层子类中定义各自模式下不同的细节实现而在 BaseHistory 抽离相同的逻辑分别继承给两个子类进行调用。

完善初始化页面逻辑

在开始完善详细的路由逻辑之前我们来考虑这样一件事情,通常在首次加载页面时需要初始化路由,也就是所谓的监听 URL 变化执行跳转逻辑等等一系列操纵。

此时让我们重新回到 vue-loader/install.js 中先来初始化路由吧。

let _Vue;

export default function install(Vue) {
  // ...
  
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        // 如果当前options存在router对象 表示该实例是根对象
        this._rootRouter = this;
        this._rootRouter._router = this.$options.router;
        // 调用 _router 实例上的init方法初始化路由
        this._router.init(this);
      } else {
        // 非根组件实例
        this._rootRouter = this.$parent && this.$parent._rootRouter;
      }
    },
  });
  
  // ...
}
复制代码

此时我们在 install 方法中的 mixin 中添添加了一段 this._router.init(this); 调用 _router 实例对象上的 init 方法并且同时传入根组件实例 this 。

这里我们将路由初始化的逻辑放在根组件的 beforeCreate 中,也就是当该 Vue 根组件实例传入 router 属性时,我们会在当前根组件 beforeCreate 时调用 init 方法来初始化路由。

接下来让我们一起来看看 VueRouter 的 init 方法吧。

// ...
class VueRouter {
   // ...
  
   // 定义初始化路由方法
  init(app) {
    this.app = app;
    const history = this.history;

    // 路由变化监听函数
    const setupListeners = (route) => {
      history.setupListeners();
    };

    // 初始化时 首先根据当前页面路径渲染一次页面
    history.transitionTo(history.getCurrentLocation(), setupListeners);
  }

}
复制代码

我们在 VueRouter 上定义了一个实例方法 init ,它接受传入的根组件实例对象。

我们来稍微看一下它的逻辑,首先 init 方法保存了外部传入的组件实例 this.app ,同时获取到的我们在初始化时得到的 this.history 对象,此时虽然 this.history 我们仅仅填充了骨架,不过没关系之后我们会一步一步来补充它。

可以看到首先我们定义了一个 setupListeners 方法,这个方法内部调用了 history.setupListeners() 。

所谓 history.setupListeners() 正是监听页面路径变化的事件监听函数,针对不同的路由模式存在不同的监听事件 API 。

我们在之前定义了公用的 Base class 以及两个子类 HashHistory、HTML5History,不难想到针对 history.setupListeners() 不同的模式需要有各自不同的监听函数,所以这个方法应该放在各自子类上去实现是最好不过的。

此时我们再来看看 history.transitionTo 方法,这个方法是 VueRouter 路由跳转的核心方法。

它接受两个参数分别是:

  • 第一个为必选参数,表示需要跳转的路径。history.getCurrentLocation() 这个方法正是获取当前页面路径。

  • 第二个可选参数,表示跳转完成需要执行的事件函数,在执行完路由跳转后会调用这个函数。

在首次初始化页面路由时,在 init 方法上首先定义了一个路由变化监听函数。

其次在首次打开页面时我们需要跳转到当前页面匹配的路由来渲染对应的组件。

接下来我们去挨个填补 init 函数中缺失的逻辑:

hash

首先我们先来看看 hash.js 文件:

import { BaseHistory } from './base';

export class HashHistory extends BaseHistory {
  constructor(router) {
    super(router);
    // 初始化hash路由时 确保路由存在#并且 #后一定是拼接/
    ensureSlash();
  }

  // 推入记录跳转方法
  push(location) {
    this.transitionTo(location, (route) => {
      // location
      window.location.hash = route.path;
    });
  }

  // 替换当前路由记录跳转
  replace(location) {
    this.transitionTo(location, (route) => {
      window.location.replace(route.path);
    });
  }

  // 设置监听函数
  setupListeners() {
    // 源码中hash路由做了判断优先使用 popstate 不支持情况下才会考虑 hashchange
    // const eventType = supportsPushState ? 'popstate' : 'hashchange'
    // 这里Demo为了简化逻辑直接使用hashchange
    window.addEventListener('hashchange', () => {
      // 当路由变化时获取当前最新hash进行跳转
      this.transitionTo(getHash());
    });
  }

  // 获取当前#之后的路径
  getCurrentLocation() {
    return getHash();
  }
}

/**
 * 确保路由
 *
 * @returns
 */
function ensureSlash() {
  const path = getHash();
  // 如果 # 之后是以 / 开头,return true 什么操作都不进行
  if (path.charAt(0) === '/') {
    return true;
  }
  // 如果getHash() 返回以非 / 开头
  replaceHash('/' + path);
  return false;
}

/**
 * 比如传入
 * 首先获取当前#前的基础路径 比如http://hycoding.com/#/a/b
 * 此时 base为 http://hycoding.com/
 * 之后使用传入的路径拼接 `${base}#${path}` 返回
 * @param {*} path
 * @returns
 */
function getUrl(path) {
  const href = window.location.href;
  const i = href.indexOf('#');
  const base = i >= 0 ? href.slice(0, i) : href;
  return `${base}#${path}`;
}

// 替换当前页面路径
function replaceHash(path) {
  window.location.replace(getUrl(path));
}

/**
 * 获取当前页面中#之后的路径
 * 比如 http://hycoding.com/#/a/b 则会返回 /a/b
 * 如果页面当前url不存在 # 那么直接返回 ''
 * @export
 * @returns
 */
export function getHash() {
  let href = window.location.href;
  const index = href.indexOf('#');
  if (index < 0) return '';

  href = href.slice(index + 1);

  return href;
}
复制代码
  • 首先在 new HashHistory 实例对象时,在 hash 模式下额外会多一步 ensureSlash 处理,这个函数的作用是保证页面 URL 上一定存在 #/ 的路径。

  • 关于 ensureSlash、getUrl、getHash 这三个方法在这里我就不累赘了,代码中有详细的注释。这几个函数的作用无非是针对页面 URL 的街区获取/拼接当前页面路径。

  • 同时在 HashHistory 中定义了 push、replace 这两个实例方法,分别用于调用 BaseHistory 的 transitionTo 方法进行页面的重新渲染,在完成重新跳转后 transitionTo 方法会执行第二个参数的回调函数从而更新页面 URL 路径。

  • setupListeners 和 getCurrentLocation 做的事情非常简单,通过 getCurrentLocation 我们可以获取当前页面的 hash # 之后的路径,同时 setupListeners 内部设置了监听函数:每当页面路径发生变化时会调用 this.transitionTo 更新页面。

到此对于 hash.js 中的逻辑已经基本实现,它支持了 VueRouter 中 init 方法的:

  • history.setupListeners() 初始化页面路由变化监听函数。

  • history.getCurrentLocation() 获取当前页面 hash 之后的路径。

同时,HashHistory 也提供了两个跳转方法分别为 push、replace 方法提供通过 JS API 跳转的方式。

base

base 基础逻辑

在实现了 hash.js 之后,让我们继续来填补 base.js 中的逻辑。

BaseHistory 它主要就是基于 transitionTo 方法实现路由的核心核心跳转逻辑。

export class BaseHistory {
  constructor(router) {
    this.router = router;
    // 表示当前路由对象 初始化时会赋予 / 未匹配任何路由
    this.current = createRoute(null, {
      path: '/',
    });
  }

  // 核心跳转方法
  transitionTo(location, onComplete) {
    // 寻找即将跳转路径匹配到的路由对象
    const route = this.router.matcher.match(location);
    // 禁止重复跳转
    if (
      this.current.path === route.path &&
      route.matched.length === this.current.matched.length
    )  {
      // 这里不仅仅判断了前后的path是否一致
      // 同时判断了匹配路由对象的个数
      // 这是因为在首次初始化时 this.current 的值为 { path:'/',matched:[] }
      // 假如我们打开页面同样为 / 路径时,此时如果单纯判断path那么就会造成无法渲染
      return;
    }

     this.updateRoute(route);
     onComplete && onComplete(route);
    
  }

  // 更新current的值
  updateRoute(route) {
    this.current = route;
  }
}
复制代码

new BaseHisory 时,会通过 createRoute 方法初始化一个路由对象,createRoute 方法返回的是一个全量匹配的路由记录

比方说,文章开头的配置表中如果访问 /about/about1 记录,那么根据路由的嵌套规则会匹配到两条路由记录。分别是父路由 /about 对应的 Record 对象以及自身路由 /about/about1 对应的 Record 对象。

总之,在 new BaseHisotry 时,我们通过 createRoute 方法创建了一个初始的默认路由匹配列表将它赋给 this.current。

createRoute 方法

在继续往下之前我们先来看看 createRoute 方法,上边提到过这个方法需要实现的作用即是接受传入路由 record 对象以及 location 对象(这里 location 我们暂时仅考虑 path 和 name 属性)返回一个路由匹配映射的数组。

细心的小伙伴也许会想起来,在之前 VueRouter 上的 matcher 匹配器属性中也维护了一份路由映射表。

这里它们的区别主要是:

  • VueRouter 上的 matcher 属性属性维护的映射表是一对一的关系,比如 '/about/about1' 这个路径,它对应的仅仅是自身的路由记录对象,并不包含嵌套的父路由 Record 记录。

  • createRoute(record, location) 这个方法正是基于当前 record 路由记录,以及用户传入的 location 对象来返回一个详细的当前路径路由匹配对象。

也许此时你仍然不太明白 createRoute 究竟是怎么一回事,没关系。我先带你来实现这个方法:

/**
 *
 * 寻找完全匹配的路由对象 比如 /about/about1
 * 它会匹配出两个Record路由对象 [{ path:'/about', ... },{ path:'/about/about1',... }]
 * @export
 * @param {*} record 路由记录对象 (通过 matcher 匹配器属性中维护的列表获取)
 * @param {*} location 用户传入的参数
 * @returns
 */
export function createRoute(record, location) {
  const matched = [];

  if (record) {
    while (record) {
      // 首部添加
      matched.unshift(record);
      // 依次递归寻找父路由记录
      record = record.parent;
    }
  }

  return {
    matched,
    ...location,
  };
}
复制代码

这个方法内部会递归当前路由匹配的 record 对象,寻找当前路径匹配的所有路由对象。

transitionTo 核心跳转逻辑

VueRouter 中最核心的跳转方法就是 transitionTo 方法,这里我将它进行了简化:

  // 核心跳转方法
  transitionTo(location, onComplete) {
    // 寻找即将跳转路径匹配到的路由对象
    const route = this.router.matcher.match(location);
    // 禁止重复跳转
    if (
      this.current.path === route.path &&
      route.matched.length === this.current.matched.length
    ) {
      // 这里不仅仅判断了前后的path是否一致
      // 同时判断了匹配路由对象的个数
      // 这是因为在首次初始化时 this.current 的值为 { path:'/',matched:[] }
      // 假如我们打开页面同样为 / 路径时,此时如果单纯判断path那么就会造成无法渲染
      return;
    }

    if (route) {
      this.updateRoute(route);
      onComplete && onComplete(route);
    }
  }
复制代码

无论是调用 push、replace JavaSCript API 还是直接修改 URL 地址,核心页面路由变化就是这个 transitionTo 方法。

在它的内部首先通过 this.router.matcher.match(location) 寻找当前需要跳转的 location 匹配的路由记录。

vue-router/index.js 的 class VueRouter 上的匹配器属性 matcher 上还遗留了一个没有实现的 match 方法。

此时,让我们回到 vue-router/crate-matcher.js 中来完善这个方法:

export function createMatcher(routes) {
   // ...
   // 通过路径寻找当前路径匹配的所有record记录
  function match(location) {
     // 判断传入的location是否为字符串 如果为字符串则表示是通过路径跳转
    //  如果为字符串则格式化location返回一个{ path:location } 对象 否则 返回location本身
    const next = typeof location === 'string' ? { path: location } : location;
    const { name, path } = next;
    // 如果存在name属性,那么优先会去nameMap中查找当前name对应的Record路由记录
    if (name) {
      const record = nameMap[name];
      // 如果没有匹配的路由记录
      if (!record) return createRoute(null, location);
      // 返回时调用createRoute方法 返回完全匹配的路由映射数据(包含嵌套节点)
      return createRoute(record, next);
    } else if (path) {
      // 不存在name时则会寻找path对应的Record对象
      const record = pathMap[path];
      if (!record) return createRoute(null, location);
      // 返回时调用createRoute方法 返回完全匹配的路由映射数据(包含嵌套节点)
      return createRoute(record, next);
    }
  }

  return {
    addRoute,
    addRoutes,
    getRoutes,
    match,
  };
}
复制代码

这里我们填充了 match 方法的逻辑。

首先这个方法内部会格式化参数 location,同时根据格式化后的 next 对象寻找对应的 Record 对象。

同时通过调用 history/base.js 中的 createRoute 方法寻找全量匹配的路由对象列表。

此时在 transitionTo 函数内部即通过 this.router.matcher.match(location) 获取到了即将跳转路由的匹配对象。

举个例子,假如我们调用 this.$router.push('/about/about1'),相当于在 transitionTo 方法内部会进行 transitionTo('/about/about1',() => window.locaiton.hash = '/about/about1')

此时 this.router.matcher.match(location) 会返回这样一个对象:

image.png

刚才我们说到过 this.router.matcher.match 内部仍然会调用 createRoute 方法来包裹 Record 路由记录。

this.router.matcher.match(location) 返回的匹配对象中目前会存在两个属性:

  • matched 这是一个数组,它保存了传入路径匹配的所有 Record 记录对象。注意它是存在顺序的,顺序为从父到子。

  • path 跳转的路径。

当然这里的 location 是用户传入的参数,比如我们调用 this.$router.push({name:'19Qingfeng',params:{ age:23 }}) 此时{name:'19Qingfeng',params:{ age:23 } 就是 location 参数。

此时在 transitionTo 方法中我们可以拿到即将跳转到的路由对应的 Record 路由记录列表。

之后我们会判断是否是重复跳转,如果是重复跳转那么函数会终止执行。

需要额外注意的是这里在判断重复跳转时,并没有单纯的使用路径进行判断。 比如在首次打开页面时,我们在 BaseHistory 规定的初始化路径 this.current 中的 path 是 / ,同时匹配到的路由对象为 [],此时它是一个这样的对象:

image.png

如果单纯使用路径判断的话,首次页面加载 router.init 方法调用时,如果同样访问 / 路径,使用路径判断的话会一直被认为重复路径从而无法渲染正确页面。

之后的逻辑就非常简单了,当调用 transitionTo 方法时我们得到了匹配到的所有 Record 记录赋值给 route 变量,判断如果没有重复跳转那么即会更新 this.current 的值。

transitionTo 方法中最核心的逻辑就是每当该方法被调用,更新 this.current 的值。

这里我在 updateRoute 中增加一句打印:

  // ...
   
  // 更新current的值
  updateRoute(route) {
    // 每次更新 this.current 时都会打印最新的this.current
    this.current = route;
    console.log('更新后的 current', this.current);
  }
  
  // ...
复制代码

来看看此时页面实现的效果:

QQ20220105-222816-HD_1.gif

每次页面的 URL 变化时,页面都会打印出匹配到所有路由对象以及当前路由属性。

history

在有了 hash.js 的实现基础下,html5.js 的实现稍微能简单一些。

在 HTML5History 中我们仅需要提供了和 HashHistory 相同的 API ,只是具体的实现逻辑将 hashchange 替换成为 popState 等 HTML5 History API。

import { BaseHistory } from './base';

export class HTML5History extends BaseHistory {
  constructor(router) {
    super(router);
  }

  // 推入记录跳转方法
  push(location) {
    this.transitionTo(location, (route) => {
      window.history.pushState({}, null, route.path);
    });
  }

  // 替换当前路由记录跳转
  replace(location) {
    this.transitionTo(location, (route) => {
      window.history.replaceState({}, null, route.path);
    });
  }

  // 设置监听函数
  setupListeners() {
    // 源码中hash路由做了判断优先使用 popstate 不支持情况下才会考虑 hashchange
    // const eventType = supportsPushState ? 'popstate' : 'hashchange'
    // 这里Demo为了简化逻辑直接使用hashchange
    window.addEventListener('popstate', () => {
      // 当路由变化时获取当前最新hash进行跳转
      this.transitionTo(window.location.pathname);
    });
  }

  // 获取当前#之后的路径
  getCurrentLocation() {
    return window.location.pathname;
  }
}
复制代码

这里的逻辑和 hash 是类似的,我就不过多累赘了。还是定义了一系列的实例方法,唯一不同的是就是将 hash 对应的 API 替换成为了 HTML 5 History API 。

响应式数据

在上边我们已经在每次页面 URL 发生变化时,BaseHistory 中的 current 属性都会发生变化。

current 中保存着当前路径内所有的路由信息以及当前路径匹配到的所有 Vue 组件。

image.png

接下来我们需要做的即使将 current 的值变为响应式数据,每当 current 发生变化时页面需要重新渲染。

无疑接下来我们需要将 current 处理成为响应式数据,让我们回到 vue-router/install.js 中:

// vue-router/install.js
...

export default function install(Vue) {
 // ...
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        // 如果当前options存在router对象 表示该实例是根对象
        this._rootRouter = this;
        this._rootRouter._router = this.$options.router;
        // 调用 _router 实例上的init方法初始化路由
        this._router.init(this);
        // 当根组件挂载 _router 时候 我们在根组件上定义了一个_route响应式属性 初始值为 this._router.history.current
        Vue.util.defineReactive(this, '_route', this._router.history.current);
      } else {
        // 非根组件实例
        this._rootRouter = this.$parent && this.$parent._rootRouter;
      }
    },
  });
 // ...

}
复制代码

在根组件 new Vue 时如果传递了 router 属性的话,我们会在初始化路由之后通过 Vue.util.defineReactive(this, '_route', this._router.history.current) 为根组件实例对象上定义了一个 _route 属性,值为 BaseHistory 中的 current 属性,即为 this._router.history.current 。

在 install 方法中,我们说到过通过 Vue.mixin 中的 beforeCreate 生命周期,我们将根组件实例暴露给了每个子孙组件可以通过 this.rootRouter 属性访问根组件的实例对象。

同理,既然所有组件都可以通过 this.rootRouter 访问到根组件实例,那么同样可以通过 this.rootRouter._route 访问到当前路由对象 current 。

所谓的 current 即是我们日常使用的 $route 对象,让我们继续来补充 install.js 中的内容:

export default function install(Vue) {
   //...
   
  // 定义原型$router对象
  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._rootRouter._router;
    },
  });

  // 定义原型$route对象
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return this._rootRouter._route;
    },
  });
  
  // ...
}
复制代码

同样我们通过 Object.definedProperty 在 Vue.prototype 上定义了 $route 属性,代理访问到了各个实例上的 this._rootRouter._route 的值。

此时我在 App.vue 的 created 生命周期中打印 this.$route 来看一看结果:

image.png

我们可以看到此时打印的 $route 属性存在了 get/set 变成了一个响应式数据。

动态修改响应式数据

此时我们的确通过 Vue.util.defineReactive 将 $route 定义成为了响应式对象,不过当路径改变或者通过 JavaScript API 调用 push 等方法时:

这个时候改变的是 BaseHistory 中的 current 属性的值,根组件实例上的 this._route 值并不会被改变。

他俩是完全不同的对象,在 install 方法初始化的逻辑中,你可以理解 Vue.util.defineReactive 将 this._router.history.current 的值做了一层深拷贝,变成了响应式数据。

这之后,这两个属性不存在任何关联了。此时我们需要在每次 BaseHistory 的 current 属性改变后同步改变根组件实例上的 _route 响应式属性。

让我们回到 vue-loader/index.js 中:

// ...
class VueRouter {
   // ...
   
  // 定义初始化路由方法
  // init方法会接受 根组件实例
  init(app) {
    this.app = app;
    const history = this.history;

    // 路由变化监听函数
    const setupListeners = () => {
      history.setupListeners();
    };

    // 初始化时 首先根据当前页面路径渲染一次页面
    history.transitionTo(history.getCurrentLocation(), setupListeners);

    // 额外定义history.listen方法 传入一个callback
    // 在每次BaseHistory中的current属性改变时 传入最新的值 从而更新 app._route
    history.listen((route) => {
      app._route = route;
    });
  }
   
   // ...
}
// ...
复制代码

我们在 VueRouter 的 init 方法之中定义了一个 history.listen 的方法,在初始化时这个方法会被调用它会在每次更新 BaseHistory 的 current 属性值时调用传入的 callback 为根组件的 _route 赋值为最新的 current 值。

不难想到这是一个通用方法,无论是 HTML5 还是 Hash 都需要这段逻辑,所以我们将它定义在 vue-router/history/base.js 中:

// history/base.js
export class BaseHistory {
   // ...
   
  // current改变同步修改$route
  listen(cb) {
    this.cb = cb;
  }

  // 更新current的值
  updateRoute(route) {
    this.current = route;
    this.cb && this.cb(route);
  }
}
复制代码

这里我们增加了一个 listen 方法,它会接受一个 callback函数,同时在自身实例上定义一个 this.cb = cb。

在每次调用 updateRoute 方法时,如果存在 this.cb 就会调用它同时传入最新的 this.current 的值,从而达到更新根组件实例上的 $route 属性。

让我们一起来看一看此时页面的展现效果:

QQ20220105-234947-HD.gif

这里我在 App.vue 的 template 中 加入了 <div>$route<div>

此时我们可以看到,当页面 URL 变化时 App.vue 模板中依赖的 $route 属性的值也会变化从而造成页面重新渲染。

当然不要忘记我们需要在 VueRouter 补充这些已经实现的 API 提供给 $router 对象调用:

class VueRouter {
  // ...
  
  // 跳转
  push(location) {
    this.history.push(location);
  }

  // 替换
  replace(location) {
    this.history.replace(location);
  }
  
  // ...

}
复制代码

Link & View 组件实现

在上边我们已经实现了 r o u t e r router 和 route 两个对象,每当页面 URL 发生变化时。

我们在组件内部的 $route 寻找最新路径匹配的路由,同时这个属性我们将它转变成为了一个响应式属性。

在使用 VueRouter 时,会注入两个两个全局全局组件分别是:

RouterLink 组件

关于 RouterLink 更加详细的用法你可以查阅 VueRouter 官方文档

本质上,它就是一个承载跳转的节点。通过点击该节点触发跳转,我们来一起看一看它的简单实现:

export default {
  name: 'RouterLink',
  props: {
    to: {
      type: String,
      required: true,
    },
    tag: {
      type: String,
      default: 'a',
    },
  },
  methods: {
    handleJump() {
      this.$router.push(this.to);
    },
  },
  render(h) {
    return h(
      this.tag,
      {
        on: {
          click: () => {
            this.handleJump();
          },
        },
      },
      this.$slots.default
    );
  },
};
复制代码

我简化了它的实现,仅仅保留了基础的内容,它会接受外部传入的插槽内容同时渲染对应标签。在点击时,触发 this.$router.push 方法。

RouterLink 的实现非常简单,这里我就不在累赘了。

RouterView 组件

关于 router-view 这个组件就稍稍有点复杂,我们先来看看它的实现:

// components/router-view.js
export default {
  name: 'RouterView',
  functional: true,
  render(h, ctx) {
    // 标记当前 dataView 为true
    ctx.data.dataView = true;

    let { parent, data } = ctx;

    const route = parent.$route;

    // 表示当前RouterView需要渲染的层级
    let depth = 0;
    
    // 当寻找到根节点时停止
    while (parent && parent._routerRoot !== parent) {
      // 获取父节点标签上的 data
      const vnodeData = parent.$vnode ? parent.$vnode.data : {};
      if (vnodeData.dataView) {
        // 如果 parent.$vnode.dataView 为 true,则表示当前 routerView 已经渲染过了
        depth++;
      }
      // 递归向上查找
      parent = parent.$parent;
    }

    // 根据depth判断当前router-view 承载的是第几个匹配组件
    const matchRoute = route.matched[depth];
    if (!matchRoute) {
      return h();
    }

    return h(matchRoute.component, data);
  },
};

复制代码

首先这里使用了 functional 函数式组件,组件内部并没有 this 实例。

在渲染每一个 RouterView 时,我们将当前组件上下的 data 属性中标记 dataView 为 true ,你可以这样理解它 <routerView :data="true"> ,相当于为该标签增加一个 attribute 。

在每次渲染 RouterView 时,我们正是通过当前 routerView 向上查找,每当页面渲染一层 RouterView 我们都会将该 RouterView 的 dataView 的属性设置为 true 。

这样在进行嵌套渲染时,我们只需要向上递归查找 dataView 有几个为 true ,则表示该 RouterView 是嵌套匹配到的第几层路由。

关于 $vnode ,也许大部分同学经常使用的是 _vode 。这里你可以通过这样图来看看他的区别:

vnode百分之70.png

简单来说,$vnode 代表当前组件渲染的占位标签节点,而 _vnode 代表当前组件渲染的 VNode 节点内容。

大功告成

写到这里,其实关于 VueRouter 的基础功能目前我们已经实现了,一起来看看此时的效果吧:

QQ20220106-123918-HD.gif

当我们点击页面上对应的 router-link 标签时,发生跳转。

此时 URL 变化,从而造成 $route 改变,RouterView 收集到的响应式数据变化,从而造成页面渲染。

写在结尾

我不太清楚会有多少小伙伴看到这里,但是真的非常感谢每一位可以坚持到结尾的朋友。

文章中是对于源码的一个最小化实现,大家在理解了文章中思路之后可以尝试自己阅读 VueRouter 源码

说句心里话,很多时候我们觉得困难并不是因为它真的困难,在我看来大概率只是源于你对他的不了解。。

就好比文章中讲到的 VueRouter 那样,在你真的理解了文章的内容后你会发现,它无非就是在路径变化时修改响应式数据的值导致页面重新渲染,只是这之中一些具体的实现细节可能会比较繁琐而已。

如果之后对 Vue 感兴趣的小伙伴,我之后会在专栏 从原理玩转Vue 为大家带来更多有趣的内容。

猜你喜欢

转载自juejin.im/post/7049953227818663966