diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index bdc292c86..a6c593d9b 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -7,6 +7,7 @@ import { _RouteRecordProps, } from '../types' import { createRouterError, ErrorTypes, MatcherError } from '../errors' +import { createMatcherTree } from './matcherTree' import { createRouteRecordMatcher, RouteRecordMatcher } from './pathMatcher' import { RouteRecordNormalized } from './types' @@ -16,8 +17,6 @@ import type { _PathParserOptions, } from './pathParserRanker' -import { comparePathParserScore } from './pathParserRanker' - import { warn } from '../warning' import { assign, noop } from '../utils' @@ -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() globalOptions = mergeOptions( { strict: false, end: true, sensitive: false } as PathParserOptions, @@ -203,37 +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) { - 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) + matcherTree.add(matcher) // only add the original record to the name map if (matcher.record.name && !isAliasRecord(matcher)) matcherMap.set(matcher.record.name, matcher) @@ -306,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) { @@ -319,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, @@ -525,13 +511,4 @@ function checkMissingParamsInAbsolutePath( } } -function isRecordChildOf( - record: RouteRecordMatcher, - parent: RouteRecordMatcher -): boolean { - return parent.children.some( - child => child === record || isRecordChildOf(record, child) - ) -} - 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..aaf889762 --- /dev/null +++ b/packages/router/src/matcher/matcherTree.ts @@ -0,0 +1,219 @@ +import { RouteRecordMatcher } from './pathMatcher' +import { comparePathParserScore } from './pathParserRanker' + +type MatcherTree = { + add: (matcher: RouteRecordMatcher) => void + remove: (matcher: RouteRecordMatcher) => void + find: (path: string) => RouteRecordMatcher | undefined + toArray: () => RouteRecordMatcher[] +} + +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 { + const root = createMatcherNode() + const 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) + } + }, + + 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): MatcherTree { + 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) +} + +function findInsertionIndex( + matcher: RouteRecordMatcher, + matchers: 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++ + + return i +} + +function isRecordChildOf( + record: RouteRecordMatcher, + parent: RouteRecordMatcher +): boolean { + return parent.children.some( + child => child === record || isRecordChildOf(record, child) + ) +} 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 || '/' + }, + } +}