Skip to content

Commit 510f66f

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

File tree

5 files changed

+321
-39
lines changed

5 files changed

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

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)