Skip to content

Commit d9607ab

Browse files
committed
perf: faster handling of static paths
1 parent f934fcf commit d9607ab

File tree

5 files changed

+322
-39
lines changed

5 files changed

+322
-39
lines changed

packages/router/src/matcher/index.ts

+12-35
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
_RouteRecordProps,
88
} from '../types'
99
import { createRouterError, ErrorTypes, MatcherError } from '../errors'
10+
import { createMatcherTree } from './matcherTree'
1011
import { createRouteRecordMatcher, RouteRecordMatcher } from './pathMatcher'
1112
import { RouteRecordNormalized } from './types'
1213

@@ -16,8 +17,6 @@ import type {
1617
_PathParserOptions,
1718
} from './pathParserRanker'
1819

19-
import { comparePathParserScore } from './pathParserRanker'
20-
2120
import { warn } from '../warning'
2221
import { assign, noop } from '../utils'
2322

@@ -58,8 +57,8 @@ export function createRouterMatcher(
5857
routes: Readonly<RouteRecordRaw[]>,
5958
globalOptions: PathParserOptions
6059
): RouterMatcher {
61-
// normalized ordered array of matchers
62-
const matchers: RouteRecordMatcher[] = []
60+
// normalized ordered tree of matchers
61+
const matcherTree = createMatcherTree()
6362
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
6463
globalOptions = mergeOptions(
6564
{ strict: false, end: true, sensitive: false } as PathParserOptions,
@@ -203,37 +202,24 @@ export function createRouterMatcher(
203202
const matcher = matcherMap.get(matcherRef)
204203
if (matcher) {
205204
matcherMap.delete(matcherRef)
206-
matchers.splice(matchers.indexOf(matcher), 1)
205+
matcherTree.remove(matcher)
207206
matcher.children.forEach(removeRoute)
208207
matcher.alias.forEach(removeRoute)
209208
}
210209
} else {
211-
const index = matchers.indexOf(matcherRef)
212-
if (index > -1) {
213-
matchers.splice(index, 1)
214-
if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
215-
matcherRef.children.forEach(removeRoute)
216-
matcherRef.alias.forEach(removeRoute)
217-
}
210+
matcherTree.remove(matcherRef)
211+
if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
212+
matcherRef.children.forEach(removeRoute)
213+
matcherRef.alias.forEach(removeRoute)
218214
}
219215
}
220216

221217
function getRoutes() {
222-
return matchers
218+
return matcherTree.toArray()
223219
}
224220

225221
function insertMatcher(matcher: RouteRecordMatcher) {
226-
let i = 0
227-
while (
228-
i < matchers.length &&
229-
comparePathParserScore(matcher, matchers[i]) >= 0 &&
230-
// Adding children with empty path should still appear before the parent
231-
// https://github.com/vuejs/router/issues/1124
232-
(matcher.record.path !== matchers[i].record.path ||
233-
!isRecordChildOf(matcher, matchers[i]))
234-
)
235-
i++
236-
matchers.splice(i, 0, matcher)
222+
matcherTree.add(matcher)
237223
// only add the original record to the name map
238224
if (matcher.record.name && !isAliasRecord(matcher))
239225
matcherMap.set(matcher.record.name, matcher)
@@ -306,7 +292,7 @@ export function createRouterMatcher(
306292
)
307293
}
308294

309-
matcher = matchers.find(m => m.re.test(path))
295+
matcher = matcherTree.find(path)
310296
// matcher should have a value after the loop
311297

312298
if (matcher) {
@@ -319,7 +305,7 @@ export function createRouterMatcher(
319305
// match by name or path of current route
320306
matcher = currentLocation.name
321307
? matcherMap.get(currentLocation.name)
322-
: matchers.find(m => m.re.test(currentLocation.path))
308+
: matcherTree.find(currentLocation.path)
323309
if (!matcher)
324310
throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
325311
location,
@@ -525,13 +511,4 @@ function checkMissingParamsInAbsolutePath(
525511
}
526512
}
527513

528-
function isRecordChildOf(
529-
record: RouteRecordMatcher,
530-
parent: RouteRecordMatcher
531-
): boolean {
532-
return parent.children.some(
533-
child => child === record || isRecordChildOf(record, child)
534-
)
535-
}
536-
537514
export type { PathParserOptions, _PathParserOptions }
+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { RouteRecordMatcher } from './pathMatcher'
2+
import { comparePathParserScore } from './pathParserRanker'
3+
4+
type MatcherTree = {
5+
add: (matcher: RouteRecordMatcher) => void
6+
remove: (matcher: RouteRecordMatcher) => void
7+
find: (path: string) => RouteRecordMatcher | undefined
8+
toArray: () => RouteRecordMatcher[]
9+
}
10+
11+
function normalizePath(path: string) {
12+
// We match case-insensitively initially, then let the matcher check more rigorously
13+
path = path.toUpperCase()
14+
15+
// TODO: Check more thoroughly whether this is really necessary
16+
while (path.endsWith('/')) {
17+
path = path.slice(0, -1)
18+
}
19+
20+
return path
21+
}
22+
23+
export function createMatcherTree(): MatcherTree {
24+
const root = createMatcherNode()
25+
const exactMatchers: Record<string, RouteRecordMatcher[]> =
26+
Object.create(null)
27+
28+
return {
29+
add(matcher) {
30+
if (matcher.staticPath) {
31+
const path = normalizePath(matcher.record.path)
32+
33+
exactMatchers[path] = exactMatchers[path] || []
34+
insertMatcher(matcher, exactMatchers[path])
35+
} else {
36+
root.add(matcher)
37+
}
38+
},
39+
40+
remove(matcher) {
41+
if (matcher.staticPath) {
42+
const path = normalizePath(matcher.record.path)
43+
44+
if (exactMatchers[path]) {
45+
// TODO: Remove array if length is zero
46+
remove(matcher, exactMatchers[path])
47+
}
48+
} else {
49+
root.remove(matcher)
50+
}
51+
},
52+
53+
find(path) {
54+
const matchers = exactMatchers[normalizePath(path)]
55+
56+
if (matchers) {
57+
for (const matcher of matchers) {
58+
if (matcher.re.test(path)) {
59+
return matcher
60+
}
61+
}
62+
}
63+
64+
return root.find(path)
65+
},
66+
67+
toArray() {
68+
const arr = root.toArray()
69+
70+
for (const key in exactMatchers) {
71+
arr.unshift(...exactMatchers[key])
72+
}
73+
74+
return arr
75+
},
76+
}
77+
}
78+
79+
function createMatcherNode(depth = 1): MatcherTree {
80+
let segments: Record<string, MatcherTree> | null = null
81+
let wildcards: RouteRecordMatcher[] | null = null
82+
83+
return {
84+
add(matcher) {
85+
const { staticTokens } = matcher
86+
const myToken = staticTokens[depth - 1]?.toUpperCase()
87+
88+
if (myToken != null) {
89+
if (!segments) {
90+
segments = Object.create(null)
91+
}
92+
93+
if (!segments![myToken]) {
94+
segments![myToken] = createMatcherNode(depth + 1)
95+
}
96+
97+
segments![myToken].add(matcher)
98+
99+
return
100+
}
101+
102+
if (!wildcards) {
103+
wildcards = []
104+
}
105+
106+
insertMatcher(matcher, wildcards)
107+
},
108+
109+
remove(matcher) {
110+
// TODO: Remove any empty data structures
111+
if (segments) {
112+
const myToken = matcher.staticTokens[depth - 1]?.toUpperCase()
113+
114+
if (myToken != null) {
115+
if (segments[myToken]) {
116+
segments[myToken].remove(matcher)
117+
return
118+
}
119+
}
120+
}
121+
122+
if (wildcards) {
123+
remove(matcher, wildcards)
124+
}
125+
},
126+
127+
find(path) {
128+
const tokens = path.split('/')
129+
const myToken = tokens[depth]
130+
131+
if (segments && myToken != null) {
132+
const segmentMatcher = segments[myToken.toUpperCase()]
133+
134+
if (segmentMatcher) {
135+
const match = segmentMatcher.find(path)
136+
137+
if (match) {
138+
return match
139+
}
140+
}
141+
}
142+
143+
if (wildcards) {
144+
return wildcards.find(matcher => matcher.re.test(path))
145+
}
146+
147+
return
148+
},
149+
150+
toArray() {
151+
const matchers: RouteRecordMatcher[] = []
152+
153+
for (const key in segments) {
154+
// TODO: push may not scale well enough
155+
matchers.push(...segments[key].toArray())
156+
}
157+
158+
if (wildcards) {
159+
matchers.push(...wildcards)
160+
}
161+
162+
return matchers
163+
},
164+
}
165+
}
166+
167+
function remove<T>(item: T, items: T[]) {
168+
const index = items.indexOf(item)
169+
170+
if (index > -1) {
171+
items.splice(index, 1)
172+
}
173+
}
174+
175+
function insertMatcher(
176+
matcher: RouteRecordMatcher,
177+
matchers: RouteRecordMatcher[]
178+
) {
179+
const index = findInsertionIndex(matcher, matchers)
180+
matchers.splice(index, 0, matcher)
181+
}
182+
183+
function findInsertionIndex(
184+
matcher: RouteRecordMatcher,
185+
matchers: RouteRecordMatcher[]
186+
) {
187+
let i = 0
188+
while (
189+
i < matchers.length &&
190+
comparePathParserScore(matcher, matchers[i]) >= 0 &&
191+
// Adding children with empty path should still appear before the parent
192+
// https://github.com/vuejs/router/issues/1124
193+
(matcher.record.path !== matchers[i].record.path ||
194+
!isRecordChildOf(matcher, matchers[i]))
195+
)
196+
i++
197+
198+
return i
199+
}
200+
201+
function isRecordChildOf(
202+
record: RouteRecordMatcher,
203+
parent: RouteRecordMatcher
204+
): boolean {
205+
return parent.children.some(
206+
child => child === record || isRecordChildOf(record, child)
207+
)
208+
}

packages/router/src/matcher/pathMatcher.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import {
44
PathParser,
55
PathParserOptions,
66
} from './pathParserRanker'
7+
import { staticPathToParser } from './staticPathParser'
78
import { tokenizePath } from './pathTokenizer'
89
import { warn } from '../warning'
910
import { assign } from '../utils'
1011

1112
export interface RouteRecordMatcher extends PathParser {
13+
staticPath: boolean
14+
staticTokens: string[]
1215
record: RouteRecord
1316
parent: RouteRecordMatcher | undefined
1417
children: RouteRecordMatcher[]
@@ -21,7 +24,29 @@ export function createRouteRecordMatcher(
2124
parent: RouteRecordMatcher | undefined,
2225
options?: PathParserOptions
2326
): RouteRecordMatcher {
24-
const parser = tokensToParser(tokenizePath(record.path), options)
27+
const tokens = tokenizePath(record.path)
28+
29+
// TODO: Merge options properly
30+
const staticPath =
31+
options?.end !== false &&
32+
tokens.every(
33+
segment =>
34+
segment.length === 0 || (segment.length === 1 && segment[0].type === 0)
35+
)
36+
37+
const staticTokens: string[] = []
38+
39+
for (const token of tokens) {
40+
if (token.length === 1 && token[0].type === 0) {
41+
staticTokens.push(token[0].value)
42+
} else {
43+
break
44+
}
45+
}
46+
47+
const parser = staticPath
48+
? staticPathToParser(record.path, tokens, options)
49+
: tokensToParser(tokens, options)
2550

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

3863
const matcher: RouteRecordMatcher = assign(parser, {
64+
staticPath,
65+
staticTokens,
3966
record,
4067
parent,
4168
// these needs to be populated by the parent

packages/router/src/matcher/pathParserRanker.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface PathParser {
1616
/**
1717
* The regexp used to match a url
1818
*/
19-
re: RegExp
19+
re: { test: (str: string) => boolean }
2020

2121
/**
2222
* The score of the parser
@@ -89,15 +89,15 @@ export type PathParserOptions = Pick<
8989
// default pattern for a param: non-greedy everything but /
9090
const BASE_PARAM_PATTERN = '[^/]+?'
9191

92-
const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = {
92+
export const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = {
9393
sensitive: false,
9494
strict: false,
9595
start: true,
9696
end: true,
9797
}
9898

9999
// Scoring values used in tokensToParser
100-
const enum PathScore {
100+
export const enum PathScore {
101101
_multiplier = 10,
102102
Root = 9 * _multiplier, // just /
103103
Segment = 4 * _multiplier, // /a-segment

0 commit comments

Comments
 (0)