Skip to content
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

perf: faster handling of static paths #2148

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 }
219 changes: 219 additions & 0 deletions 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<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)]

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<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]
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)
}

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)
)
}
33 changes: 32 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,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__) {
Expand All @@ -36,6 +65,8 @@ export function createRouteRecordMatcher(
}

const matcher: RouteRecordMatcher = assign(parser, {
staticPath,
staticTokens,
record,
parent,
// these needs to be populated by the parent
Expand Down