Appearance
第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 路由设计原则
- 明确的路径结构:使用清晰、语义化的路径
- 合理的嵌套层级:避免过深的嵌套结构
- 参数命名规范:使用有意义的参数名称
- 错误处理:为无效路由提供友好的错误页面
9.2 性能优化建议
- 路由懒加载:使用动态导入来实现路由组件的懒加载
- 路由预取:合理使用路由预取来提高用户体验
- 避免复杂正则:在路由参数中避免使用过于复杂的正则表达式
9.3 调试技巧
- 使用开发工具:利用 Vue Devtools 来调试路由状态
- 启用警告:在开发环境中注意 Vue Router 的警告信息
- 路由测试:编写单元测试来验证路由配置的正确性
总结
Vue Router 的路由匹配器是一个精心设计的系统,它通过以下核心机制实现了强大的路由功能:
- 双模式支持:History API 和 Hash 模式满足不同的部署需求
- 智能匹配:基于评分系统的路由匹配算法确保最佳匹配
- 灵活解析:自定义的路径标记化和解析系统支持复杂的路由模式
- 动态管理:支持运行时动态添加和删除路由
- 嵌套支持:完整的嵌套路由支持,包括路径构建和参数继承
- 性能优化:通过评分系统、插入优化等机制确保良好的性能
这些设计使得 Vue Router 能够处理从简单的静态路由到复杂的动态嵌套路由的各种场景,为 Vue.js 应用提供了强大而灵活的路由解决方案。在下一节中,我们将继续探讨 Vue Router 的导航守卫和路由组件的实现机制。
