diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index da9e821f3..0b4ad1221 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -5,6 +5,7 @@ import { isRouteName, } from '../types' import { createRouterError, ErrorTypes, MatcherError } from '../errors' +import { createMatcherTree, isMatchable } from './matcherTree' import { createRouteRecordMatcher, RouteRecordMatcher } from './pathMatcher' import { RouteRecordNormalized } from './types' @@ -14,8 +15,6 @@ import type { _PathParserOptions, } from './pathParserRanker' -import { comparePathParserScore } from './pathParserRanker' - import { warn } from '../warning' import { assign, noop } from '../utils' import type { RouteRecordNameGeneric, _RouteRecordProps } from '../typed-routes' @@ -58,8 +57,8 @@ export function createRouterMatcher( routes: Readonly, globalOptions: PathParserOptions ): RouterMatcher { - // normalized ordered array of matchers - const matchers: RouteRecordMatcher[] = [] + // normalized ordered tree of matchers + const matcherTree = createMatcherTree() const matcherMap = new Map< NonNullable, RouteRecordMatcher @@ -203,28 +202,24 @@ export function createRouterMatcher( const matcher = matcherMap.get(matcherRef) if (matcher) { matcherMap.delete(matcherRef) - matchers.splice(matchers.indexOf(matcher), 1) + matcherTree.remove(matcher) 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) - } + matcherTree.remove(matcherRef) + if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name) + matcherRef.children.forEach(removeRoute) + matcherRef.alias.forEach(removeRoute) } } function getRoutes() { - return matchers + return matcherTree.toArray() } function insertMatcher(matcher: RouteRecordMatcher) { - const index = findInsertionIndex(matcher, matchers) - matchers.splice(index, 0, matcher) + matcherTree.add(matcher) // only add the original record to the name map if (matcher.record.name && !isAliasRecord(matcher)) matcherMap.set(matcher.record.name, matcher) @@ -297,7 +292,7 @@ export function createRouterMatcher( ) } - matcher = matchers.find(m => m.re.test(path)) + matcher = matcherTree.find(path) // matcher should have a value after the loop if (matcher) { @@ -310,7 +305,7 @@ export function createRouterMatcher( // match by name or path of current route matcher = currentLocation.name ? matcherMap.get(currentLocation.name) - : matchers.find(m => m.re.test(currentLocation.path)) + : matcherTree.find(currentLocation.path) if (!matcher) throw createRouterError(ErrorTypes.MATCHER_NOT_FOUND, { location, @@ -345,7 +340,7 @@ export function createRouterMatcher( routes.forEach(route => addRoute(route)) function clearRoutes() { - matchers.length = 0 + matcherTree.clear() matcherMap.clear() } @@ -528,79 +523,4 @@ function checkMissingParamsInAbsolutePath( } } -/** - * Performs a binary search to find the correct insertion index for a new matcher. - * - * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships, - * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes. - * - * @param matcher - new matcher to be inserted - * @param matchers - existing matchers - */ -function findInsertionIndex( - matcher: RouteRecordMatcher, - matchers: RouteRecordMatcher[] -) { - // First phase: binary search based on score - let lower = 0 - let upper = matchers.length - - while (lower !== upper) { - const mid = (lower + upper) >> 1 - const sortOrder = comparePathParserScore(matcher, matchers[mid]) - - if (sortOrder < 0) { - upper = mid - } else { - lower = mid + 1 - } - } - - // Second phase: check for an ancestor with the same score - const insertionAncestor = getInsertionAncestor(matcher) - - if (insertionAncestor) { - upper = matchers.lastIndexOf(insertionAncestor, upper - 1) - - if (__DEV__ && upper < 0) { - // This should never happen - warn( - `Finding ancestor route "${insertionAncestor.record.path}" failed for "${matcher.record.path}"` - ) - } - } - - return upper -} - -function getInsertionAncestor(matcher: RouteRecordMatcher) { - let ancestor: RouteRecordMatcher | undefined = matcher - - while ((ancestor = ancestor.parent)) { - if ( - isMatchable(ancestor) && - comparePathParserScore(matcher, ancestor) === 0 - ) { - return ancestor - } - } - - return -} - -/** - * Checks if a matcher can be reachable. This means if it's possible to reach it as a route. For example, routes without - * a component, or name, or redirect, are just used to group other routes. - * @param matcher - * @param matcher.record record of the matcher - * @returns - */ -function isMatchable({ record }: RouteRecordMatcher): boolean { - return !!( - record.name || - (record.components && Object.keys(record.components).length) || - record.redirect - ) -} - export type { PathParserOptions, _PathParserOptions } diff --git a/packages/router/src/matcher/matcherTree.ts b/packages/router/src/matcher/matcherTree.ts new file mode 100644 index 000000000..e00185b32 --- /dev/null +++ b/packages/router/src/matcher/matcherTree.ts @@ -0,0 +1,282 @@ +import { RouteRecordMatcher } from './pathMatcher' +import { comparePathParserScore } from './pathParserRanker' +import { warn } from '../warning' + +type MatcherNode = { + add: (matcher: RouteRecordMatcher) => void + remove: (matcher: RouteRecordMatcher) => void + find: (path: string) => RouteRecordMatcher | undefined + toArray: () => RouteRecordMatcher[] +} + +type MatcherTree = MatcherNode & { + clear: () => void +} + +function normalizePath(path: string) { + // We match case-insensitively initially, then let the matcher check more rigorously + path = path.toUpperCase() + + // TODO: Check more thoroughly whether this is really necessary + while (path.endsWith('/')) { + path = path.slice(0, -1) + } + + return path +} + +function chooseBestMatcher( + firstMatcher: RouteRecordMatcher | undefined, + secondMatcher: RouteRecordMatcher | undefined +) { + if (secondMatcher) { + if ( + !firstMatcher || + comparePathParserScore(firstMatcher, secondMatcher) > 0 + ) { + firstMatcher = secondMatcher + } + } + + return firstMatcher +} + +export function createMatcherTree(): MatcherTree { + let root = createMatcherNode() + let exactMatchers: Record = Object.create(null) + + return { + add(matcher) { + if (matcher.staticPath) { + const path = normalizePath(matcher.record.path) + + exactMatchers[path] = exactMatchers[path] || [] + insertMatcher(matcher, exactMatchers[path]) + } else { + root.add(matcher) + } + }, + + remove(matcher) { + if (matcher.staticPath) { + const path = normalizePath(matcher.record.path) + + if (exactMatchers[path]) { + // TODO: Remove array if length is zero + remove(matcher, exactMatchers[path]) + } + } else { + root.remove(matcher) + } + }, + + clear() { + root = createMatcherNode() + exactMatchers = Object.create(null) + }, + + find(path) { + const matchers = exactMatchers[normalizePath(path)] + + return chooseBestMatcher( + matchers && matchers.find(matcher => matcher.re.test(path)), + root.find(path) + ) + }, + + toArray() { + const arr = root.toArray() + + for (const key in exactMatchers) { + arr.unshift(...exactMatchers[key]) + } + + return arr + }, + } +} + +function createMatcherNode(depth = 1): MatcherNode { + let segments: Record | null = null + let wildcards: RouteRecordMatcher[] | null = null + + return { + add(matcher) { + const { staticTokens } = matcher + const myToken = staticTokens[depth - 1]?.toUpperCase() + + if (myToken != null) { + if (!segments) { + segments = Object.create(null) + } + + if (!segments![myToken]) { + segments![myToken] = createMatcherNode(depth + 1) + } + + segments![myToken].add(matcher) + + return + } + + if (!wildcards) { + wildcards = [] + } + + insertMatcher(matcher, wildcards) + }, + + remove(matcher) { + // TODO: Remove any empty data structures + if (segments) { + const myToken = matcher.staticTokens[depth - 1]?.toUpperCase() + + if (myToken != null) { + if (segments[myToken]) { + segments[myToken].remove(matcher) + return + } + } + } + + if (wildcards) { + remove(matcher, wildcards) + } + }, + + find(path) { + const tokens = path.split('/') + const myToken = tokens[depth] + let matcher: RouteRecordMatcher | undefined + + if (segments && myToken != null) { + const segmentMatcher = segments[myToken.toUpperCase()] + + if (segmentMatcher) { + matcher = segmentMatcher.find(path) + } + } + + if (wildcards) { + matcher = chooseBestMatcher( + matcher, + wildcards.find(matcher => matcher.re.test(path)) + ) + } + + return matcher + }, + + toArray() { + const matchers: RouteRecordMatcher[] = [] + + for (const key in segments) { + // TODO: push may not scale well enough + matchers.push(...segments[key].toArray()) + } + + if (wildcards) { + matchers.push(...wildcards) + } + + return matchers + }, + } +} + +function remove(item: T, items: T[]) { + const index = items.indexOf(item) + + if (index > -1) { + items.splice(index, 1) + } +} + +function insertMatcher( + matcher: RouteRecordMatcher, + matchers: RouteRecordMatcher[] +) { + const index = findInsertionIndex(matcher, matchers) + matchers.splice(index, 0, matcher) +} + +/** + * Performs a binary search to find the correct insertion index for a new matcher. + * + * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships, + * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes. + * + * @param matcher - new matcher to be inserted + * @param matchers - existing matchers + */ +function findInsertionIndex( + matcher: RouteRecordMatcher, + matchers: RouteRecordMatcher[] +) { + // First phase: binary search based on score + let lower = 0 + let upper = matchers.length + + while (lower !== upper) { + const mid = (lower + upper) >> 1 + const sortOrder = comparePathParserScore(matcher, matchers[mid]) + + if (sortOrder < 0) { + upper = mid + } else { + lower = mid + 1 + } + } + + // Second phase: check for an ancestor with the same score + const insertionAncestor = getInsertionAncestor(matcher) + + if (insertionAncestor) { + upper = matchers.lastIndexOf(insertionAncestor, upper - 1) + + if (__DEV__ && upper < 0) { + // This should never happen + warn( + `Finding ancestor route "${insertionAncestor.record.path}" failed for "${matcher.record.path}"` + ) + } + } + + return upper +} + +function getInsertionAncestor(matcher: RouteRecordMatcher) { + let ancestor: RouteRecordMatcher | undefined = matcher + + while ((ancestor = ancestor.parent)) { + if ( + isMatchable(ancestor) && + matcher.staticTokens.length === ancestor.staticTokens.length && + comparePathParserScore(matcher, ancestor) === 0 && + ancestor.staticTokens.every( + (token, index) => + matcher.staticTokens[index].toUpperCase() === token.toUpperCase() + ) + ) { + return ancestor + } + } + + return +} + +/** + * Checks if a matcher can be reachable. This means if it's possible to reach it as a route. For example, routes without + * a component, or name, or redirect, are just used to group other routes. + * @param matcher + * @param matcher.record record of the matcher + * @returns + */ +// TODO: This should probably live elsewhere +export function isMatchable({ record }: RouteRecordMatcher): boolean { + return !!( + record.name || + (record.components && Object.keys(record.components).length) || + record.redirect + ) +} diff --git a/packages/router/src/matcher/pathMatcher.ts b/packages/router/src/matcher/pathMatcher.ts index aae2b7826..78fcc466b 100644 --- a/packages/router/src/matcher/pathMatcher.ts +++ b/packages/router/src/matcher/pathMatcher.ts @@ -4,11 +4,14 @@ import { PathParser, PathParserOptions, } from './pathParserRanker' +import { staticPathToParser } from './staticPathParser' import { tokenizePath } from './pathTokenizer' import { warn } from '../warning' import { assign } from '../utils' export interface RouteRecordMatcher extends PathParser { + staticPath: boolean + staticTokens: string[] record: RouteRecord parent: RouteRecordMatcher | undefined children: RouteRecordMatcher[] @@ -21,7 +24,33 @@ export function createRouteRecordMatcher( parent: RouteRecordMatcher | undefined, options?: PathParserOptions ): RouteRecordMatcher { - const parser = tokensToParser(tokenizePath(record.path), options) + const tokens = tokenizePath(record.path) + + // TODO: Merge options properly + const staticPath = + options?.end !== false && + tokens.every( + segment => + segment.length === 0 || (segment.length === 1 && segment[0].type === 0) + ) + + const staticTokens: string[] = [] + + for (const token of tokens) { + if (token.length === 1 && token[0].type === 0) { + staticTokens.push(token[0].value) + } else { + break + } + } + + if (options?.end === false && !options?.strict) { + staticTokens.pop() + } + + const parser = staticPath + ? staticPathToParser(record.path, tokens, options) + : tokensToParser(tokens, options) // warn against params with the same name if (__DEV__) { @@ -36,6 +65,8 @@ export function createRouteRecordMatcher( } const matcher: RouteRecordMatcher = assign(parser, { + staticPath, + staticTokens, record, parent, // these needs to be populated by the parent diff --git a/packages/router/src/matcher/pathParserRanker.ts b/packages/router/src/matcher/pathParserRanker.ts index 670013794..dfc4cde03 100644 --- a/packages/router/src/matcher/pathParserRanker.ts +++ b/packages/router/src/matcher/pathParserRanker.ts @@ -16,7 +16,7 @@ export interface PathParser { /** * The regexp used to match a url */ - re: RegExp + re: { test: (str: string) => boolean } /** * The score of the parser @@ -89,7 +89,7 @@ export type PathParserOptions = Pick< // default pattern for a param: non-greedy everything but / const BASE_PARAM_PATTERN = '[^/]+?' -const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = { +export const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = { sensitive: false, strict: false, start: true, @@ -97,7 +97,7 @@ const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = { } // Scoring values used in tokensToParser -const enum PathScore { +export const enum PathScore { _multiplier = 10, Root = 9 * _multiplier, // just / Segment = 4 * _multiplier, // /a-segment diff --git a/packages/router/src/matcher/staticPathParser.ts b/packages/router/src/matcher/staticPathParser.ts new file mode 100644 index 000000000..c4d4af8e2 --- /dev/null +++ b/packages/router/src/matcher/staticPathParser.ts @@ -0,0 +1,71 @@ +import { + PathParser, + PathParserOptions, + PathScore, + BASE_PATH_PARSER_OPTIONS, +} from './pathParserRanker' +import { Token } from './pathTokenizer' +import { assign } from '../utils' + +export function staticPathToParser( + path: string, + tokens: Array, + extraOptions?: PathParserOptions +): PathParser { + const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions) + + const matchPath = options.sensitive ? path : path.toUpperCase() + + let test: (p: string) => boolean + + if (options.strict) { + if (options.sensitive) { + test = p => p === matchPath + } else { + test = p => p.toUpperCase() === matchPath + } + } else { + const withSlash = matchPath.endsWith('/') ? matchPath : matchPath + '/' + const withoutSlash = withSlash.slice(0, -1) + + if (options.sensitive) { + test = p => p === withSlash || p === withoutSlash + } else { + test = p => { + p = p.toUpperCase() + return p === withSlash || p === withoutSlash + } + } + } + + const score: Array = tokens.map(segment => { + if (segment.length === 1) { + return [ + PathScore.Static + + PathScore.Segment + + (options.sensitive ? PathScore.BonusCaseSensitive : 0), + ] + } else { + return [PathScore.Root] + } + }) + + if (options.strict && options.end) { + const i = score.length - 1 + score[i][score[i].length - 1] += PathScore.BonusStrict + } + + return { + re: { + test, + }, + score, + keys: [], + parse() { + return {} + }, + stringify() { + return path || '/' + }, + } +}