vue router 4 源码篇:路由matcher的前世今生

2,662 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

源码专栏

感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:

开篇

哈喽大咖好,我是跑手,本次给大家继续讲解下vue-router@4.xrouter matcher的实现。

在上节讲到,createRouter方法的第一步就是根据传进来的路由配置列表,为每项创建matcher。这里的matcher可以理解成一个路由页面的匹配器,包含了路由常规方法。而创建matcher,调用了createRouterMatcher方法。

最终输出

createRouterMatcher执行完后,会返回的5个函数{ addRoute, resolve, removeRoute, getRoutes, getRecordMatcher },为后续的路由创建提供帮助。这些函数的作用,无非就是围绕着上面说到的matcher增删改查操作,例如,getRoutes用于返回所有matcher,removeRoute则是删除某个指定的matcher。。。

为了方便大家阅读,我们先看下创建的matcher最终长啥样?我们可以使用getRoutes()方法获取到的对象集,得到最终生成的matcher列表:

import {
  createRouterMatcher,
  createWebHistory,
} from 'vue-router'

export const routerHistory = createWebHistory()
const options = { 
    // your options... 
}
console.log('matchers:', createRouterMatcher(options.routes, options).getRoutes())

输出:

image.png

其中,record字段就是我们经常使用到的vue-router路由对象(即router.getRoute()得到的对象),这样理解方便多了吧 [\手动狗头]。。。

接下来,我们分别对addRoute, resolve, removeRoute, getRoutes, getRecordMatcher这5个方法解读,全面了解vue router是如何创建matcher的。

处理流程

讲了一大堆,还是回归到源码。createRouterMatcher函数一共286行,初始化matcher入口在代码340行,调用的方法是addRoute

image.png

addRoute

  • 定义:初始化matcher
  • 接收参数(3个):record(需要处理的路由)、parent(父matcher)、originalRecord(原始matcher),其中后两个是可选项,意思就是只传record则会认为是一个简单路由「无父无别名」并对其处理,假如带上第2、3参数,则还要结合父路由或者别名路由处理
  • 返回:单个matcher对象

扩展阅读:别名路由

addRoute关键步骤源码

image.png

addRoute的处理过程

image.png

流程拆分

标准化处理record和options合并

// used later on to remove by name
const isRootAdd = !originalRecord
const mainNormalizedRecord = normalizeRouteRecord(record)
if (__DEV__) {
  checkChildMissingNameWithEmptyPath(mainNormalizedRecord, parent)
}
// we might be the child of an alias
mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record
const options: PathParserOptions = mergeOptions(globalOptions, record)
// generate an array of records to correctly handle aliases
const normalizedRecords: typeof mainNormalizedRecord[] = [
  mainNormalizedRecord,
]

在执行过程中,先对record调用normalizeRouteRecord进行标准化处理,再调用mergeOptions方法把自身options与全局options合并得到最终options,然后把结果放进normalizedRecords数组存储。

再讲解下normalizedRecords,它是一个存储标准化matcher的数组,数组每一项都包含是matcher所有信息:options、parent、compoment、alias等等。。。在接下来要对matcher进行完成初始化的流程中,只要遍历这个数组就行了。

处理alias

if ('alias' in record) {
  const aliases =
    typeof record.alias === 'string' ? [record.alias] : record.alias!
  for (const alias of aliases) {
    normalizedRecords.push(
      assign({}, mainNormalizedRecord, {
        // this allows us to hold a copy of the `components` option
        // so that async components cache is hold on the original record
        components: originalRecord
          ? originalRecord.record.components
          : mainNormalizedRecord.components,
        path: alias,
        // we might be the child of an alias
        aliasOf: originalRecord
          ? originalRecord.record
          : mainNormalizedRecord,
        // the aliases are always of the same kind as the original since they
        // are defined on the same record
      }) as typeof mainNormalizedRecord
    )
  }
}

然后就是处理别名路由,如果record设置了别名,则把原record(也就是传进来的第三个参数),当然这些信息也要塞进normalizedRecords数组保存,以便后续对原record处理。

扩展阅读:vue router alias

生成路由匹配器

万事俱备,接下来就要遍历normalizedRecords数组了。

const { path } = normalizedRecord
// Build up the path for nested routes if the child isn't an absolute
// route. Only add the / delimiter if the child path isn't empty and if the
// parent path doesn't have a trailing slash
if (parent && path[0] !== '/') {
  const parentPath = parent.record.path
  const connectingSlash =
    parentPath[parentPath.length - 1] === '/' ? '' : '/'
  normalizedRecord.path =
    parent.record.path + (path && connectingSlash + path)
}

if (__DEV__ && normalizedRecord.path === '*') {
  throw new Error(
    'Catch all routes ("*") must now be defined using a param with a custom regexp.\n' +
      'See more at https://next.router.vuejs.org/guide/migration/#removed-star-or-catch-all-routes.'
  )
}

// create the object beforehand, so it can be passed to children
matcher = createRouteRecordMatcher(normalizedRecord, parent, options)

首先,生成普通路由和嵌套路由的path,然后调用createRouteRecordMatcher 方法生成一个路由匹配器,至于createRouteRecordMatcher内部逻辑这里就不细述了(以后有时间再补充),大概思路就是通过编码 | 解码将路由path变化到一个token数组的过程,让程序能准确辨认并处理子路由、动态路由、路由参数等情景。

处理originalRecord

// if we are an alias we must tell the original record that we exist,
// so we can be removed
if (originalRecord) {
  originalRecord.alias.push(matcher)
  if (__DEV__) {
    checkSameParams(originalRecord, matcher)
  }
} else {
  // otherwise, the first record is the original and others are aliases
  originalMatcher = originalMatcher || matcher
  if (originalMatcher !== matcher) originalMatcher.alias.push(matcher)

  // remove the route if named and only for the top record (avoid in nested calls)
  // this works because the original record is the first one
  if (isRootAdd && record.name && !isAliasRecord(matcher))
    removeRoute(record.name)
}

完成上一步后,程序会对originalRecord做判断,如果有则将匹配器(matcher)放入alias中;没有则认为第一个recordoriginalMatcher,而其他则是当前路由的aliases,这里要注意点是当originalMatchermatcher不等时,说明此时matcher是由别名记录产生的,将matcher放到originalMatcher的aliases中。再往后就是为了避免嵌套调用而删掉不冗余路由。

遍历子路由

if (mainNormalizedRecord.children) {
  const children = mainNormalizedRecord.children
  for (let i = 0; i < children.length; i++) {
    addRoute(
      children[i],
      matcher,
      originalRecord && originalRecord.children[i]
    )
  }
}

再往下就是遍历当前matcher的children matcher做同样的初始化操作。

插入matcher

// if there was no original record, then the first one was not an alias and all
// other aliases (if any) need to reference this record when adding children
originalRecord = originalRecord || matcher

// TODO: add normalized records for more flexibility
// if (parent && isAliasRecord(originalRecord)) {
//   parent.children.push(originalRecord)
// }

insertMatcher(matcher)

再看看insertMatcher定义:

function insertMatcher(matcher: RouteRecordMatcher) {
  let i = 0
  while (
    i < matchers.length &&
    comparePathParserScore(matcher, matchers[i]) >= 0 &&
    // Adding children with empty path should still appear before the parent
    // https://github.com/vuejs/router/issues/1124
    (matcher.record.path !== matchers[i].record.path ||
      !isRecordChildOf(matcher, matchers[i]))
  )
    i++
  matchers.splice(i, 0, matcher)
  // only add the original record to the name map
  if (matcher.record.name && !isAliasRecord(matcher))
    matcherMap.set(matcher.record.name, matcher)
}

源码在添加matcher前还要对其判断,以便重复插入。当满足条件时,将matcher增加到matchers数组中;另外,假如matcher并非别名record时,也要将其记录到matcherMap中,matcherMap作用是通过名字快速检索到对应的record对象,在增加、删除、查询路由时都会用到。

至此addRoute逻辑基本完结了,最后返回original matcher集合,得到文中开头截图的matchers。

resolve

  • 定义:获取路由的标准化版本
  • 入参2个:location路由路径对象,可以是path 或 name与params的组合;currentLocation当前路由matcher location,这个在外层调用时已经处理好)
  • 返回:标准化的路由对象

举例

方便大家理解,这里还是先举个例子:

export const router = createRouter(options)
const matchers = createRouterMatcher(options.routes, options)
console.log('obj:', matchers)

输出:

image.png

这里大家可能会有个疑问,假如2个参数的路由不一致会以哪个为准?

其实这是个伪命题,matcher内部的resolve方法和平时我们外部调用的router resolve方法不一样,内部这个resolve的2入参数默认指向同一个路由而不管外部的业务逻辑如何,在外部router resolve已经把第二个参数处理好,所以才有上面截图的效果。

关键源码

function resolve(
  location: Readonly<MatcherLocationRaw>,
  currentLocation: Readonly<MatcherLocation>
): MatcherLocation {
  let matcher: RouteRecordMatcher | undefined
  let params: PathParams = {}
  let path: MatcherLocation['path']
  let name: MatcherLocation['name']

  if ('name' in location && location.name) {
    // match by name
  } else if ('path' in location) {
    // match by path
  } else {
    // match by name or path of current route...
  }

  const matched: MatcherLocation['matched'] = []
  let parentMatcher: RouteRecordMatcher | undefined = matcher
  while (parentMatcher) {
    // reversed order so parents are at the beginning

    matched.unshift(parentMatcher.record)
    parentMatcher = parentMatcher.parent
  }

  return {
    name,
    path,
    params,
    matched,
    meta: mergeMetaFields(matched),
  }
}

上面为省略源码,无非就是通过3种方式(通过name、path、当前路由的name或path)查找matcher,最后返回一个完整的信息对象。

removeRoute

  • 定义:删除某个路由matcher
  • 入参:matcherRef(路由标识,可以是字符串或object)
  • 返回:无

源码

function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
  if (isRouteName(matcherRef)) {
    const matcher = matcherMap.get(matcherRef)
    if (matcher) {
      matcherMap.delete(matcherRef)
      matchers.splice(matchers.indexOf(matcher), 1)
      matcher.children.forEach(removeRoute)
      matcher.alias.forEach(removeRoute)
    }
  } else {
    const index = matchers.indexOf(matcherRef)
    if (index > -1) {
      matchers.splice(index, 1)
      if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
      matcherRef.children.forEach(removeRoute)
      matcherRef.alias.forEach(removeRoute)
    }
  }
}

删除路由matcher逻辑也不复杂,先干掉本路由matcher,然后再递归干掉其子路由和别名路由。

getRoutes

  • 定义:获取所有的matchers
  • 入参:无
  • 返回:matchers

源码

function getRoutes() {
  return matchers
}

getRecordMatcher

  • 定义:获取某个matcher
  • 返回:matcher

源码

function getRecordMatcher(name: RouteRecordName) {
  return matcherMap.get(name)
}

上面说过,matcherMap是一个map结构的内存变量,能通过name快速检索到指定的matcher。

落幕

好了,相信小伙伴们都对vue router 4matcher有总体的认识和理解,这节先到这里,下节我们会聊下vue router 4中核心能力之一:源码中有关Web History API能力的部分,看看它是如何把原生能力完美结合起来的。

最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹