vue-router 源码探究——路由重置实现

在之前的一篇博文中主要阐述了前端权限控制的一种实现 —— 前端权限控制的基本实现。其中介绍了通过权限过滤实现动态地私有路由添加,那么在当前用户登出时,应该是要重置当前应用的用户数据的。那么全局的 vuex 状态可通过官方替换 store 的方法 replaceState 来实现。那么在没有官方实现的 feature 的情况下该如何删除(重置)通过 addRoutes 方法添加的动态路由?

TL;DR

起初,只有通过调用全局的原生 location.refresh 方法来实现整个页面的刷新,进而实现当前路由的重置。那么会有一种在不刷新当前页面的方法实现当前路由的重置功能么?在经过一系列的尝试,在官方源码存在这样一个 issue —— feature request: replace routes dynamically,其中提到了一种 hack 的方法,通过替换路由实例的 matcher 对象来实现路由的 重置,即实现删除通过 addRoutes 添加的路由。

那么截至现在就有两种解决方案可实现路由的删除:

  • 通过调用全局的 location.refresh 方法来实现应用刷新来实现前端路由的重置。
  • 通过替换当前 vue-routermatcher 属性对象来实现在不刷新页面的情况下重置当前路由实例。

下文将着重从与 matcher 相关的 vue-router 源码解读为什么替换 vue-routermatcher 对象可实现删除 addRoutes 添加的路由。另外截至本文写作日期,vue-router 的最新版本为 v3.0.6。后文所述的所有源码解读都是基于 此 v3.0.6 版本

首先在 src/index.js 中可见 vue-router 类,其中包含了一系列我们熟知的 router API。这里尤其要注意与本文相关联的 VueRouter构造函数 constructor原型方法 addRoutes

后文的内容都是基于这个两个点展开直至解决我们的核心问题 —— 为什么替换实例的 matcher 可实现 删除addRoutes 添加的路由。

先问是什么,再问为什么。

matcher 是什么

在源码的 src/index.js 入口文件中的 VueRouter 类的构造函数中可见,路由实例的 matcher 对象由 create-matcher 中的内部方法 createMatcher 创建 而来。

// ... other code

import { createMatcher } from './create-matcher'

// ... other code

export default class VueRouter {
  // ...

  constructor(options: RouterOptions = {}) {
    // ...
    this.matcher = createMatcher(options.routes || [], this)

    let mode = options.mode || 'hash'

    // ... other code
  }

  // ... other code

  addRoutes(routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
}

通过对 VueRouter 类的代码抽象显而易见:

  • 在实例化路由对象时,会创建一个与当前路由实例对应的 matcher。并在实例化时,传入在实例化时的 routes 参数。这里 留心 这里传入的 routes 参数,后续的源码分析也会用到该 routes 参数。
  • 在我们之前在 前端权限控制的基本实现 中用到的 addRoutes 方法中,本质上是调用了 matcheraddRoutes 方法。

不论是 VueRouter 实例化还是通过 addRoutes 方法,都绕不开 matcher。那么再次深入 create-matcher.js 源码文件中,可见以下代码:

// {projectRoot}/src/create-matcher.js

export function createMatcher(
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // ...
  return {
    match,
    addRoutes
  }
}

这里暂时性省略了其他无关代码,关注我们之前的问题 —— matcher 是什么。在这里可以很明显地看出,实例的 matcher 对象是由 match 属性和 addRoutes 属性组成。接下来我们进一步探究二者的本质。

这里先提一点,探究源码的时候最好是带着一个具体的问题来看源码,来理解其中的代码逻辑,这样才不至于在源码中迷失,以保持自己的初心。

全局路由的存储

在上一章节,已经提到不论是 路由实例化 还是调用动态添加路由的 API —— addRoutes。都会调用到一个 createMatcher 函数。这里我们将分步讨论以下 createMatcher 函数 到底具有什么样的职责。

export default class VueRouter {
  constructor(options: RouterOptions = {}) {
    // ...
    this.matcher = createMatcher(options.routes || [], this)
    // ...
  }

  // ...

  match(raw: RawLocation, current?: Route, redirectedFrom?: Location): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

  // ...

  addRoutes(routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }

  // ...
}

在查看 create-matcher.js 时,我们可见第一句代码:

const { pathList, pathMap, nameMap } = createRouteMap(routes)

这句代码我们大致从调用的函数名称上 推测 一下,在执行 createMatcher 函数 时,首要任务时对当前开发者传入的 options.routes 进行路由 映射化处理,并得到了三个路由容器 pathListpathMapnameMap

create-matcher.js 的同级目录下我们可以找到 createRouteMap 所在的文件 create-route-map.js,我们同样可将 createRouteMap 的逻辑展示如下:

export function createRouteMap(
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
  // the path list is used to control path matching priority
  const pathList: Array<string> = oldPathList || []
  /**
   * @description Dictionary 泛型
   * https://github.com/vuejs/vue-router/blob/v3.0.6/flow/declarations.js#L20
   */
  // $flow-disable-line
  const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
  // $flow-disable-line
  const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)

  /**
   * @description 将所有的 VueRouter 构造函数提供的 routes 分别存入 pathList,
   * pathMap, nameMap 三个路由容器中。
   */
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })

  /**
   * @description 因为上文代码注释已经说明 pathList 是用于保证路由匹配的优先级
   * 那么,以下代码用于保证路由通配符始终被最后匹配
   */
  // ensure wildcard routes are always at the end
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }

  return {
    pathList,
    pathMap,
    nameMap
  }
}

同样可知,createRouteMap 函数返回三个属性 —— pathListpathMapnameMap

  • pathList,作者已经给出了注释说明,pathList 是用来保证进行非命名路由时的 path 匹配优先级的(具体可查看文档 —— 匹配优先级)。

在进一步探究 addRouteRecord 时,我们可以发现 addRouteRecord 就主要做了三件事,将所有之前实例化时传入的 options.routes 格式化

  1. 对其中每一个 route 做映射,该映射集合被称为 pathMap。同时在映射时,不断向 pathList 列表加入当前 path 记录以保证匹配路由时的优先级。
  2. 对提供了 name 字段的路由记录,加入到 nameMap 映射中。因为 name 具有唯一性,所以此时在 nameMap 中就不用考虑匹配优先级了。
  3. 递归所有路由的子路由,并进行映射化处理。

具体代码解析如下:

function addRouteRecord(
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>,
  route: RouteConfig,
  parent?: RouteRecord,
  matchAs?: string
) {
  const { path, name } = route
  // 断言传入的 route 中是否包含 path 属性
  if (process.env.NODE_ENV !== 'production') {
    assert(path != null, `"path" is required in a route configuration.`)
    assert(
      typeof route.component !== 'string',
      `route config "component" for path: ${String(
        path || name
      )} cannot be a ` + `string id. Use an actual component instead.`
    )
  }

  // 有没有提供客制化解析参数
  // https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#高级匹配模式
  const pathToRegexpOptions: PathToRegexpOptions =
    route.pathToRegexpOptions || {}

  // 格式化路由,如拼接子路由等
  const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)

  // 匹配时,是否对大小写敏感
  if (typeof route.caseSensitive === 'boolean') {
    pathToRegexpOptions.sensitive = route.caseSensitive
  }

  // 路由记录对象
  const record: RouteRecord = {
    path: normalizedPath,
    regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
    components: route.components || { default: route.component },
    instances: {},
    name,
    parent,
    matchAs,
    redirect: route.redirect,
    beforeEnter: route.beforeEnter,
    meta: route.meta || {},
    props:
      route.props == null
        ? {}
        : route.components
        ? route.props
        : { default: route.props }
  }

  // 当前 route 存在子路由时
  if (route.children) {
    /**
     * @description 用来解决子路由为空字符串或为 '/' 时,将导致子路由无法渲染的 BUG
     * 具体可见:https://github.com/vuejs/vue-router/issues/629
     */
    // Warn if route is named, does not redirect and has a default child route.
    // If users navigate to this route by name, the default child will
    // not be rendered (GH Issue #629)
    if (process.env.NODE_ENV !== 'production') {
      if (
        route.name &&
        !route.redirect &&
        route.children.some(child => /^\/?$/.test(child.path))
      ) {
        warn(
          false,
          `Named Route '${route.name}' has a default child route. ` +
            `When navigating to this named route (:to="{name: '${
              route.name
            }'"), ` +
            `the default child route will not be rendered. Remove the name from ` +
            `this route and use the name of the default child route for named ` +
            `links instead.`
        )
      }
    }
    /**
     * @description 递归映射化当前路由的所有子路由
     */
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }

  /**
   * @description route.alias 表示路由别名。当存在 /a 别名为 /b 时,访问 /b 就像
   * 访问 /a 一样,但路由还是保持为 /b。
   * https://router.vuejs.org/guide/essentials/redirect-and-alias.html#alias
   * https://router.vuejs.org/zh/guide/essentials/redirect-and-alias.html#别名
   */
  if (route.alias !== undefined) {
    const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]

    aliases.forEach(alias => {
      const aliasRoute = {
        path: alias,
        children: route.children
      }
      addRouteRecord(
        pathList,
        pathMap,
        nameMap,
        aliasRoute,
        parent,
        record.path || '/' // matchAs
      )
    })
  }

  /**
   * @description 若当前 pathMap 映射容器中不包含 path 路由记录,那么将该路由记录对
   * 象添加到 pathMap 容器中。另外 pathList 用来保证匹配的优先级
   */
  if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }

  /**
   * @description 若当前路由提供了 name 字段,即当前路由是命名路由时,将在 nameMap
   * 映射容器中添加该命名路由的路由记录。
   */
  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record
    } else if (process.env.NODE_ENV !== 'production' && !matchAs) {
      warn(
        false,
        `Duplicate named routes definition: ` +
          `{ name: "${name}", path: "${record.path}" }`
      )
    }
  }
}

现在我们可以大致 总结 一下,所有之前我们通过 VueRouter 构造函数所传递的参数 最终 都在 addRouteRecord 函数中得到解析处理。

  • 所有的路由信息 path 字段都存储在 pathMap 中,通过 pathList 列表实现 匹配优先级
  • 若存在路由有 name 字段时,该命名路由将被存储在 nameMap 映射容器中,因为文档中约定了 name 字段具有唯一性,那么命名路由没有专门的 nameList 来实现匹配优先级。
  • 所有路由的每一项子路由都会递归进行解析并存储。

现在我们理解了所有路由信息的最终归宿之后,回溯之前的解析可以发现:

// {ProjectRoot}/src/index.js
import { createMatcher } from './create-matcher'
// ...
export default VueRouter {
  constructor (options: RouterOptions = {}) {
    this.matcher = createMatcher(options.routes || [], this)
  }
}
// {ProjectRoot}/src/create-matcher.js
import { createRouteMap } from './create-route-map'
// ...

export function createMatcher(
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // ...
}
// {ProjectRoot}/src/create-route-map.js
export function createRouteMap(
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  pathList: Array<string>
  pathMap: Dictionary<RouteRecord>
  nameMap: Dictionary<RouteRecord>
} {
  // ...
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })
  // ...
}

以上代码展示了传入的 options.routes 的格式化存储过程,最终所有的路由信息都在 addRouteRecord 被解析,并存储在 pathListpathMap 中,若是命名路由,另外还会被存储在 nameMap 中。在构造函数中可见,这一切的路由信息容器都是被挂载在路由实例的 matcher 对象上的。这一点,对于后续的动态路由删除功能提供了契机。

matcher.match 函数

之前我们知道了 matcher 对象由 match 方法和 addRoutes 组成的。我们大致看一下 match 属性是指什么,在 create-matcher.js 中的 26 - 72 行就是我们找的 match 属性——它是一个函数。

function match(
  raw: RawLocation,
  currentRoute?: Route,
  redirectedFrom?: Location
): Route {
  // 通过调用的函数名称可大致推断是格式化当前的路由对象
  const location = normalizeLocation(raw, currentRoute, false, router)

  // 抽离当前路由对象中的 name 字段,即命名路由中的名称
  const { name } = location

  // 若当前路由是命名路由时
  if (name) {
    // ... code
    // 当前路由不是命名路由,那么直接进行路由的 path 字段路由匹配
  } else if (location.path) {
    // ... code
  }
  // no match
  return _createRoute(null, location)
}

这里直接将 match 函数的大致脉络抽象为以上结构,经过抽象后的代码可轻易看出 match 函数的主要定位是 vue-router路由匹配模块。一切路由的匹配都是依赖于实例的 matcher.match 函数。这里我们主要是要探究替换 matcher 为什么可以实现路由重置,将不对 路由匹配模块 做深入探究,如果读者感兴趣的话,可以从这个函数开始开起,自己可以尝试着探究 vue-router 的路由匹配模块。稍微提示一下,整个 vue-router 实例都是基于命名路由的 name 字段的 nameMap 映射 用于命名路由的路由匹配,使用非命名路由的 pathList 列表 保证路由匹配的优先级和使用 pathMap 映射 来实现路由的匹配的。这里的匹配搜索原理和作者之前的 前端权限控制的基本实现 数据搜索原理都是基于 映射 这种数据结构。

本文所述的映射是指的一种抽象化的 key-value 数据结构。每一个唯一个 key 都有一个唯一的 value 值与之对应。在 JS 中,一个朴素对象或一个 Map 实例对象都可称为映射。

动态添加路由的实现

在前文中已经提到我们外部调用路由实例的 addRoutes 方法 本质 上就是调用了 match.addRoutes 方法实现 路由的动态添加

回到之前的 create-matcher.js 中的 create-matcher 函数,在 22 - 24 行可看到 addRoutes 同样是一个函数,并且我们还知道了 addRoutes 的本质就是调用了前文所述的 createRouteMap 函数,我们对之前全局静态路由的解析存储流程有了理解之后就不难理解,调用 createRouteMap 函数本质上就是 向当前路由实例的路由容器动态地添加路由

// {ProjectRoot}/src/index.js
import { createMatcher } from './create-matcher'
// ...
export default VueRouter {
  // ...
  addRoutes (routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
}
// {ProjectRoot}/src/create-matcher.js
import { createRouteMap } from './create-route-map'

export function createMatcher(
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)

  function addRoutes(routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // ...
  return {
    match,
    addRoutes
  }
}

由上源码可知,调用路由实例的 addRoutes 方法本质上是调用了路由实例的 addRoutes 方法,该方法在其内部完成了传入的 addRoutes 的路由解析并进行映射化处理。

结论

在我们明白了 addRoutes 是如何向当前路由实例 动态地添加路由 后,我们再结合之前的路由实例化中的路由映射化处理流程可知:

替换当前路由实例的 matcher 之所以能实现删除动态添加的路由,是因为替换当前路由的 matcher 本质 上是 替换了现有的路由实例的路由映射容器。新的 matcher 始终 仅仅 包含路由实例化时的路由,而 不会包含 后期被 addRoutes 方法添加的路由,那么替换当前路由的 matcher 就可实现删除通过 addRoutes 添加的路由。