Skip to content

Commit

Permalink
perf: faster handling of static paths
Browse files Browse the repository at this point in the history
  • Loading branch information
skirtles-code committed Feb 20, 2024
1 parent f934fcf commit 510f66f
Show file tree
Hide file tree
Showing 5 changed files with 321 additions and 39 deletions.
47 changes: 12 additions & 35 deletions packages/router/src/matcher/index.ts
Expand Up @@ -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'

Expand All @@ -16,8 +17,6 @@ import type {
_PathParserOptions,
} from './pathParserRanker'

import { comparePathParserScore } from './pathParserRanker'

import { warn } from '../warning'
import { assign, noop } from '../utils'

Expand Down Expand Up @@ -58,8 +57,8 @@ export function createRouterMatcher(
routes: Readonly<RouteRecordRaw[]>,
globalOptions: PathParserOptions
): RouterMatcher {
// normalized ordered array of matchers
const matchers: RouteRecordMatcher[] = []
// normalized ordered tree of matchers
const matcherTree = createMatcherTree()
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
globalOptions = mergeOptions(
{ strict: false, end: true, sensitive: false } as PathParserOptions,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
location,
Expand Down Expand Up @@ -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 }
207 changes: 207 additions & 0 deletions packages/router/src/matcher/matcherTree.ts
@@ -0,0 +1,207 @@
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
}

export function createMatcherTree(): MatcherTree {
const root = createMatcherNode()
const exactMatchers: Record<string, RouteRecordMatcher[]> = 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)]

if (matchers) {
for (const matcher of matchers) {
if (matcher.re.test(path)) {
return matcher
}
}
}

return 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<string, MatcherTree> | 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]

if (segments && myToken != null) {
const segmentMatcher = segments[myToken.toUpperCase()]

if (segmentMatcher) {
const match = segmentMatcher.find(path)

if (match) {
return match
}
}
}

if (wildcards) {
return wildcards.find(matcher => matcher.re.test(path))
}

return
},

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<T>(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)
)
}
29 changes: 28 additions & 1 deletion packages/router/src/matcher/pathMatcher.ts
Expand Up @@ -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[]
Expand All @@ -21,7 +24,29 @@ 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
}
}

const parser = staticPath
? staticPathToParser(record.path, tokens, options)
: tokensToParser(tokens, options)

// warn against params with the same name
if (__DEV__) {
Expand All @@ -36,6 +61,8 @@ export function createRouteRecordMatcher(
}

const matcher: RouteRecordMatcher = assign(parser, {
staticPath,
staticTokens,
record,
parent,
// these needs to be populated by the parent
Expand Down
6 changes: 3 additions & 3 deletions packages/router/src/matcher/pathParserRanker.ts
Expand Up @@ -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
Expand Down Expand Up @@ -89,15 +89,15 @@ 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,
end: true,
}

// Scoring values used in tokensToParser
const enum PathScore {
export const enum PathScore {
_multiplier = 10,
Root = 9 * _multiplier, // just /
Segment = 4 * _multiplier, // /a-segment
Expand Down

0 comments on commit 510f66f

Please sign in to comment.