Skip to content
/ router Public

perf: faster handling of static paths #2148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 13 additions & 93 deletions packages/router/src/matcher/index.ts
Original file line number Diff line number Diff line change
@@ -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<RouteRecordRaw[]>,
globalOptions: PathParserOptions
): RouterMatcher {
// normalized ordered array of matchers
const matchers: RouteRecordMatcher[] = []
// normalized ordered tree of matchers
const matcherTree = createMatcherTree()
const matcherMap = new Map<
NonNullable<RouteRecordNameGeneric>,
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<MatcherError>(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 }
282 changes: 282 additions & 0 deletions packages/router/src/matcher/matcherTree.ts
Original file line number Diff line number Diff line change
@@ -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<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)
}
},

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<string, MatcherNode> | 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<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)
}

/**
* 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
)
}
33 changes: 32 additions & 1 deletion packages/router/src/matcher/pathMatcher.ts
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions packages/router/src/matcher/pathParserRanker.ts
Original file line number Diff line number Diff line change
@@ -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,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
71 changes: 71 additions & 0 deletions packages/router/src/matcher/staticPathParser.ts
Original file line number Diff line number Diff line change
@@ -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<Token[]>,
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<number[]> = 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 || '/'
},
}
}