Skip to content

第8.1节:Vue Router - 如何实现一个前端路由?(上:原理与 matcher)

概述

Vue Router 是 Vue.js 官方的路由管理器,它为单页应用提供了强大的路由功能。本节将深入分析 Vue Router 的核心实现原理,重点探讨路由系统架构和路由匹配器(matcher)的实现机制。

1. 路由系统架构:History API vs Hash 模式

1.1 History 模式的实现

Vue Router 支持两种主要的路由模式:History API 模式和 Hash 模式。让我们先看看 History API 模式的实现:

typescript
// packages/router/src/history/html5.ts
function createCurrentLocation(
  base: string,
  location: Location
): HistoryLocation {
  const { pathname, search, hash } = location
  // allows hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end
  const hashPos = base.indexOf('#')
  if (hashPos > -1) {
    let slicePos = hash.includes(base.slice(hashPos))
      ? base.slice(hashPos).length
      : 1
    let pathFromHash = hash.slice(slicePos)
    // prepend the starting slash to hash so the url starts with /#
    if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
    return stripBase(pathFromHash, '')
  }
  const path = stripBase(pathname, base)
  return path + search + hash
}

这个函数负责从浏览器的 window.location 创建标准化的历史位置。它处理了各种边界情况,包括带有 hash 的 base 路径。

1.2 Hash 模式的实现

Hash 模式的实现相对简单,它通过 createWebHashHistory 函数创建:

typescript
// packages/router/src/history/hash.ts
export function createWebHashHistory(base?: string): RouterHistory {
  // Make sure this implementation is fine in terms of encoding, specially for IE11
  // for `file://`, directly use the pathname and ignore the base
  // location.pathname contains an initial `/` even at the root: `https://example.com/`
  base = location.host ? base || location.pathname + location.search : ''
  // allow the user to provide a `#` in the middle: `/base/#/app`
  if (!base.includes('#')) base += '#'

  if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
    warn(
      `A hash base must end with a "#":\n"${base}" should be "${base}#".`
    )
  }
  // createWebHistory handles the rest
  return createWebHistory(base)
}

Hash 模式通过在 URL 中添加 # 符号来实现路由,这种方式不需要服务器配置,但对 SEO 不友好。

1.3 RouterHistory 接口

两种模式都实现了统一的 RouterHistory 接口:

typescript
// packages/router/src/history/common.ts
export interface RouterHistory {
  readonly base: string
  readonly location: HistoryLocation
  readonly state: HistoryState

  push(to: HistoryLocation, data?: HistoryState): void
  replace(to: HistoryLocation, data?: HistoryState): void
  go(delta: number, triggerListeners?: boolean): void
  listen(callback: NavigationCallback): () => void
  createHref(location: HistoryLocation): string
  destroy(): void
}

这个接口定义了路由历史管理的核心方法,包括导航、监听和销毁等功能。

2. 路由匹配器:createRouterMatcher 实现

2.1 RouterMatcher 接口

路由匹配器是 Vue Router 的核心组件,负责路由的添加、删除、解析和匹配:

typescript
// packages/router/src/matcher/index.ts
export interface RouterMatcher {
  addRoute: (record: RouteRecordRaw, parent?: RouteRecordMatcher) => () => void
  removeRoute: {
    (matcher: RouteRecordMatcher): void
    (name: RouteRecordNameGeneric): void
  }
  getRoutes: () => RouteRecordMatcher[]
  getRecordMatcher: (name: RouteRecordNameGeneric) => RouteRecordMatcher | undefined
  resolve: (
    location: Readonly<MatcherLocationRaw>,
    currentLocation: Readonly<MatcherLocation>
  ) => MatcherLocation
}

2.2 createRouterMatcher 函数

createRouterMatcher 函数创建路由匹配器实例:

typescript
export function createRouterMatcher(
  routes: Readonly<RouteRecordRaw[]>,
  globalOptions: PathParserOptions
): RouterMatcher {
  // 存储所有路由匹配器
  const matchers: RouteRecordMatcher[] = []
  // 名称到匹配器的映射
  const matcherMap = new Map<RouteRecordNameGeneric, RouteRecordMatcher>()
  // 合并全局选项
  globalOptions = mergeOptions({ strict: false, end: true, sensitive: false }, globalOptions)

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

  function addRoute(
    record: RouteRecordRaw,
    parent?: RouteRecordMatcher,
    originalRecord?: RouteRecordMatcher
  ) {
    // 规范化记录路径
    const isRootAdd = !originalRecord
    const mainNormalizedRecord = normalizeRouteRecord(record)
    // 我们可能有多个别名记录,所以我们需要检查所有记录
    const normalizedRecords: typeof mainNormalizedRecord[] = [
      mainNormalizedRecord,
    ]
    if ('alias' in record) {
      const aliases = typeof record.alias === 'string' ? [record.alias] : record.alias!
      for (const alias of aliases) {
        normalizedRecords.push(
          assign({}, mainNormalizedRecord, {
            // 这允许我们保持一个引用到原始记录
            components: originalRecord
              ? originalRecord.record.components
              : mainNormalizedRecord.components,
            path: alias,
            // 我们可能有一个别名记录,但我们解析的是原始记录
            aliasOf: originalRecord ? originalRecord.record : mainNormalizedRecord,
          }) as typeof mainNormalizedRecord
        )
      }
    }

    let matcher: RouteRecordMatcher
    let originalMatcher: RouteRecordMatcher | undefined

    for (const normalizedRecord of normalizedRecords) {
      const { path } = normalizedRecord
      // 构建绝对路径
      if (parent && path[0] !== '/') {
        const parentPath = parent.record.path
        const connectingSlash =
          parentPath[parentPath.length - 1] === '/' ? '' : '/'
        normalizedRecord.path =
          parent.record.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.'
        )
      }

      // 创建路由记录匹配器
      matcher = createRouteRecordMatcher(normalizedRecord, parent, globalOptions)

      if (__DEV__ && parent && path[0] === '/')
        checkMissingParamsInAbsolutePath(matcher, parent)

      // 如果我们是一个别名,我们需要将其标记为别名
      if (originalRecord) {
        originalRecord.alias.push(matcher)
        if (__DEV__) {
          checkSameParams(originalRecord, matcher)
        }
      } else {
        // 否则,第一个记录是原始记录,其他的是别名
        originalMatcher = originalMatcher || matcher
        if (originalMatcher !== matcher) originalMatcher.alias.push(matcher)

        // 删除路由如果名称已经存在
        if (isRootAdd && record.name && !isAliasRecord(matcher))
          removeRoute(record.name)
      }

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

      // 如果没有原始记录,意味着我们是第一个
      originalRecord = originalRecord || matcher

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

      insertMatcher(matcher)
    }

    return originalMatcher
      ? () => {
          // 由于数组拼接,我们可以保证原始记录和所有别名都被删除
          removeRoute(originalMatcher!)
        }
      : noop
  }

  // 省略其他方法实现...

  return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}

2.3 路由记录匹配器

每个路由记录都会创建一个对应的匹配器:

typescript
// packages/router/src/matcher/pathMatcher.ts
export function createRouteRecordMatcher(
  record: Readonly<RouteRecordNormalized>,
  parent: RouteRecordMatcher | undefined,
  options?: PathParserOptions
): RouteRecordMatcher {
  const parser = tokensToParser(tokenizePath(record.path), options)

  // 警告重复的参数
  if (__DEV__) {
    const existingKeys = new Set<string>()
    for (const key of parser.keys) {
      if (existingKeys.has(key.name))
        warn(
          `Found duplicated params with name "${key.name}" for path "${record.path}". Only the last one will be available on "$route.params".`
        )
      existingKeys.add(key.name)
    }
  }

  const matcher: RouteRecordMatcher = assign(parser, {
    record,
    parent,
    // 这些属性应该在创建时构建
    children: [],
    alias: [],
  })

  if (parent) {
    // 父子关系和别名都被推送到父级
    if (!matcher.record.aliasOf === !parent.record.aliasOf)
      parent.children.push(matcher)
  }

  return matcher
}

3. 路径解析:path-to-regexp 的使用

3.1 路径标记化(Tokenization)

Vue Router 使用自定义的路径标记化系统来解析路由路径:

typescript
// packages/router/src/matcher/pathTokenizer.ts
export const enum TokenType {
  Static,
  Param,
  Group,
}

interface TokenStatic {
  type: TokenType.Static
  value: string
}

interface TokenParam {
  type: TokenType.Param
  regexp?: string
  value: string
  optional: boolean
  repeatable: boolean
}

export function tokenizePath(path: string): Array<Token[]> {
  if (!path) return [[]]
  if (path === '/') return [[ROOT_TOKEN]]
  if (!path.startsWith('/')) {
    throw new Error(
      __DEV__
        ? `Route paths should start with a "/": "${path}" should be "/${path}".`
        : `Invalid path "${path}"`
    )
  }

  function crash(message: string) {
    throw new Error(`ERR (${state})/"${buffer}": ${message}`)
  }

  let state: TokenizerState = TokenizerState.Static
  let previousState: TokenizerState = state
  const tokens: Array<Token[]> = []
  let segment!: Token[]

  function finalizeSegment() {
    if (segment) tokens.push(segment)
    segment = []
  }

  // 解析逻辑...
}

3.2 路径解析器和评分系统

Vue Router 实现了一个复杂的路径解析器,包含评分系统来确定最佳匹配:

typescript
// packages/router/src/matcher/pathParserRanker.ts
const enum PathScore {
  _multiplier = 10,
  Root = 9 * _multiplier, // just /
  Segment = 4 * _multiplier, // /a-segment
  SubSegment = 3 * _multiplier, // /multiple-:things-in-one-:segment
  Static = 4 * _multiplier, // /static
  Dynamic = 2 * _multiplier, // /:someId
  BonusCustomRegExp = 1 * _multiplier, // /:someId(\\d+)
  BonusWildcard = -4 * _multiplier - BonusCustomRegExp, // /:namedWildcard(.*)
  BonusRepeatable = -2 * _multiplier, // /:w+ or /:w*
  BonusOptional = -0.8 * _multiplier, // /:w? or /:w*
  BonusStrict = 0.07 * _multiplier,
  BonusCaseSensitive = 0.025 * _multiplier,
}

export function tokensToParser(
  segments: Array<Token[]>,
  extraOptions?: _PathParserOptions
): PathParser {
  const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions)
  const score: Array<number[]> = []
  let pattern = options.start ? '^' : ''
  const keys: PathParserParamKey[] = []

  for (const segment of segments) {
    const segmentScores: number[] = segment.length ? [] : [PathScore.Root]

    if (options.strict && !segment.length) pattern += '/'
    for (let tokenIndex = 0; tokenIndex < segment.length; tokenIndex++) {
      const token = segment[tokenIndex]
      let subSegmentScore: number =
        PathScore.Segment +
        (options.sensitive ? PathScore.BonusCaseSensitive : 0)

      if (token.type === TokenType.Static) {
        if (!tokenIndex) pattern += '/'
        pattern += token.value.replace(REGEX_CHARS_RE, '\\$&')
        subSegmentScore += PathScore.Static
      } else if (token.type === TokenType.Param) {
        const { value, repeatable, optional, regexp } = token
        keys.push({
          name: value,
          repeatable,
          optional,
        })
        const re = regexp ? regexp : BASE_PARAM_PATTERN
        if (re !== BASE_PARAM_PATTERN) {
          subSegmentScore += PathScore.BonusCustomRegExp
          try {
            new RegExp(`(${re})`)
          } catch (err) {
            throw new Error(
              `Invalid custom RegExp for param "${value}" (${re}): ` +
                (err as Error).message
            )
          }
        }

        let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})`

        if (!tokenIndex)
          subPattern =
            optional && segment.length < 2
              ? `(?:/${subPattern})`
              : '/' + subPattern
        if (optional) subPattern += '?'

        pattern += subPattern

        subSegmentScore += PathScore.Dynamic
        if (optional) subSegmentScore += PathScore.BonusOptional
        if (repeatable) subSegmentScore += PathScore.BonusRepeatable
        if (re === '.*') subSegmentScore += PathScore.BonusWildcard
      }

      segmentScores.push(subSegmentScore)
    }

    score.push(segmentScores)
  }

  const re = new RegExp(pattern, options.sensitive ? '' : 'i')

  function parse(path: string): PathParams | null {
    const match = path.match(re)
    const params: PathParams = {}

    if (!match) return null

    for (let i = 1; i < match.length; i++) {
      const value: string = match[i] || ''
      const key = keys[i - 1]
      params[key.name] = value && key.repeatable ? value.split('/') : value
    }

    return params
  }

  function stringify(params: PathParams): string {
    let path = ''
    let avoidDuplicatedSlash: boolean = false
    for (const segment of segments) {
      if (!avoidDuplicatedSlash || !path.endsWith('/')) path += '/'
      avoidDuplicatedSlash = false

      for (const token of segment) {
        if (token.type === TokenType.Static) {
          path += token.value
        } else if (token.type === TokenType.Param) {
          const { value, repeatable, optional } = token
          const param: string | readonly string[] =
            value in params ? params[value] : ''

          if (isArray(param) && !repeatable) {
            throw new Error(
              `Provided param "${value}" is an array but it is not repeatable (* or + modifiers)`
            )
          }

          const text: string = isArray(param)
            ? (param as string[]).join('/')
            : (param as string)
          if (!text) {
            if (optional) {
              if (segment.length < 2) {
                if (path.endsWith('/')) path = path.slice(0, -1)
                else avoidDuplicatedSlash = true
              }
            } else throw new Error(`Missing required param "${value}"`)
          }
          path += text
        }
      }
    }

    return path || '/'
  }

  return {
    re,
    score,
    keys,
    parse,
    stringify,
  }
}

4. 动态路由:参数提取和验证

4.1 路由参数类型定义

typescript
export type PathParams = Record<string, string | string[]>

interface PathParserParamKey {
  name: string
  repeatable: boolean
  optional: boolean
}

4.2 参数提取机制

路由参数的提取通过正则表达式匹配实现:

typescript
function parse(path: string): PathParams | null {
  const match = path.match(re)
  const params: PathParams = {}

  if (!match) return null

  for (let i = 1; i < match.length; i++) {
    const value: string = match[i] || ''
    const key = keys[i - 1]
    params[key.name] = value && key.repeatable ? value.split('/') : value
  }

  return params
}

4.3 参数验证和警告

在开发环境中,Vue Router 会对无效参数进行警告:

typescript
if (__DEV__) {
  const invalidParams: string[] = Object.keys(
    location.params || {}
  ).filter(paramName => !matcher!.keys.find(k => k.name === paramName))

  if (invalidParams.length) {
    warn(
      `Discarded invalid param(s) "${invalidParams.join(
        '", "'
      )}" when navigating. See https://github.com/vuejs/router/blob/main/packages/router/CHANGELOG.md#414-2022-08-22 for more details.`
    )
  }
}

5. 嵌套路由:子路由的处理

5.1 RouteRecord 数据结构

typescript
export interface RouteRecordNormalized {
  path: _RouteRecordBase['path']
  redirect: _RouteRecordBase['redirect'] | undefined
  name: _RouteRecordBase['name']
  components: RouteRecordMultipleViews['components'] | null | undefined
  children: RouteRecordRaw[]
  meta: Exclude<_RouteRecordBase['meta'], void>
  props: Record<string, _RouteRecordProps>
  beforeEnter: _RouteRecordBase['beforeEnter']
  leaveGuards: Set<NavigationGuard>
  updateGuards: Set<NavigationGuard>
  enterCallbacks: Record<string, NavigationGuardNextCallback[]>
  instances: Record<string, ComponentPublicInstance | undefined | null>
  aliasOf: RouteRecordNormalized | undefined
}

5.2 嵌套路由的路径构建

typescript
if (parent && path[0] !== '/') {
  const parentPath = parent.record.path
  const connectingSlash =
    parentPath[parentPath.length - 1] === '/' ? '' : '/'
  normalizedRecord.path =
    parent.record.path + connectingSlash + path
}

5.3 子路由的递归处理

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

6. 路径规范化:normalizePath 的实现

6.1 URL 编码处理

Vue Router 提供了完整的 URL 编码和解码机制:

typescript
// packages/router/src/encoding.ts
function commonEncode(text: string | number): string {
  return encodeURI('' + text)
    .replace(ENC_PIPE_RE, '|')
    .replace(ENC_BRACKET_OPEN_RE, '[')
    .replace(ENC_BRACKET_CLOSE_RE, ']')
}

export function encodeHash(text: string): string {
  return commonEncode(text)
    .replace(ENC_CURLY_OPEN_RE, '{')
    .replace(ENC_CURLY_CLOSE_RE, '}')
    .replace(ENC_CARET_RE, '^')
}

export function encodeQueryValue(text: string | number): string {
  return (
    commonEncode(text)
      .replace(PLUS_RE, '%2B')
      .replace(ENC_SPACE_RE, '+')
      .replace(HASH_RE, '%23')
      .replace(AMPERSAND_RE, '%26')
      .replace(ENC_BACKTICK_RE, '`')
      .replace(ENC_CURLY_OPEN_RE, '{')
      .replace(ENC_CURLY_CLOSE_RE, '}')
      .replace(ENC_CARET_RE, '^')
  )
}

6.2 Base 路径处理

typescript
function normalizeBase(base?: string): string {
  if (!base) {
    if (isBrowser) {
      // 尊重 <base> 标签
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      // 去掉 origin
      base = base.replace(TRAILING_SLASH_RE, '')
    } else {
      base = '/'
    }
  }
  // 确保前导斜杠
  if (base[0] !== '/' && base[0] !== '#') base = '/' + base
  // 移除尾随斜杠
  return removeTrailingSlash(base)
}

7. 性能优化策略

7.1 路由匹配器的插入优化

typescript
function insertMatcher(matcher: RouteRecordMatcher) {
  const index = findInsertionIndex(matcher, matchers)
  matchers.splice(index, 0, matcher)
  // 只将原始记录添加到名称映射
  if (matcher.record.name && !isAliasRecord(matcher))
    matcherMap.set(matcher.record.name, matcher)
}

7.2 评分系统优化

路由匹配器使用评分系统来确定最佳匹配,静态路径得分最高,动态参数得分较低,通配符得分最低。

7.3 缓存机制

虽然当前实现中注释掉了缓存,但 Vue Router 设计时考虑了缓存机制来提高性能:

typescript
// 在某些情况下可以启用缓存
// const tokenCache = new Map<string, Token[][]>()

8. 调试和错误处理

8.1 开发环境警告

Vue Router 在开发环境中提供了丰富的警告信息:

typescript
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.'
  )
}

8.2 参数重复检查

typescript
if (__DEV__) {
  const existingKeys = new Set<string>()
  for (const key of parser.keys) {
    if (existingKeys.has(key.name))
      warn(
        `Found duplicated params with name "${key.name}" for path "${record.path}". Only the last one will be available on "$route.params".`
      )
    existingKeys.add(key.name)
  }
}

9. 最佳实践

9.1 路由设计原则

  1. 明确的路径结构:使用清晰、语义化的路径
  2. 合理的嵌套层级:避免过深的嵌套结构
  3. 参数命名规范:使用有意义的参数名称
  4. 错误处理:为无效路由提供友好的错误页面

9.2 性能优化建议

  1. 路由懒加载:使用动态导入来实现路由组件的懒加载
  2. 路由预取:合理使用路由预取来提高用户体验
  3. 避免复杂正则:在路由参数中避免使用过于复杂的正则表达式

9.3 调试技巧

  1. 使用开发工具:利用 Vue Devtools 来调试路由状态
  2. 启用警告:在开发环境中注意 Vue Router 的警告信息
  3. 路由测试:编写单元测试来验证路由配置的正确性

总结

Vue Router 的路由匹配器是一个精心设计的系统,它通过以下核心机制实现了强大的路由功能:

  1. 双模式支持:History API 和 Hash 模式满足不同的部署需求
  2. 智能匹配:基于评分系统的路由匹配算法确保最佳匹配
  3. 灵活解析:自定义的路径标记化和解析系统支持复杂的路由模式
  4. 动态管理:支持运行时动态添加和删除路由
  5. 嵌套支持:完整的嵌套路由支持,包括路径构建和参数继承
  6. 性能优化:通过评分系统、插入优化等机制确保良好的性能

这些设计使得 Vue Router 能够处理从简单的静态路由到复杂的动态嵌套路由的各种场景,为 Vue.js 应用提供了强大而灵活的路由解决方案。在下一节中,我们将继续探讨 Vue Router 的导航守卫和路由组件的实现机制。


微信公众号二维码