@vobs/router 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +63 -0
- package/package.json +13 -36
- package/src/debug.ts +41 -0
- package/src/index.test.ts +518 -0
- package/src/index.ts +1156 -0
- package/dist/history.d.ts +0 -25
- package/dist/history.js +0 -153
- package/dist/index.d.ts +0 -15
- package/dist/index.js +0 -14
- package/dist/keep-alive.d.ts +0 -20
- package/dist/keep-alive.js +0 -75
- package/dist/location.d.ts +0 -20
- package/dist/location.js +0 -107
- package/dist/match.d.ts +0 -23
- package/dist/match.js +0 -203
- package/dist/mount.d.ts +0 -18
- package/dist/mount.js +0 -205
- package/dist/navigation.d.ts +0 -9
- package/dist/navigation.js +0 -95
- package/dist/resolve.d.ts +0 -27
- package/dist/resolve.js +0 -261
- package/dist/router.d.ts +0 -6
- package/dist/router.js +0 -377
- package/dist/types.d.ts +0 -258
- package/dist/types.js +0 -9
package/src/index.ts
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
import { state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createComponent,
|
|
4
|
+
createFragment,
|
|
5
|
+
createInjectionKey,
|
|
6
|
+
inject,
|
|
7
|
+
insertBoundary,
|
|
8
|
+
type InjectionKey,
|
|
9
|
+
type VobsNode,
|
|
10
|
+
type VobsPlugin
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
import {
|
|
13
|
+
createRouterDebugId,
|
|
14
|
+
emitRouterDebug
|
|
15
|
+
} from './debug'
|
|
16
|
+
import {
|
|
17
|
+
getRuntimeDebugContext,
|
|
18
|
+
runWithRuntimeDebugContext,
|
|
19
|
+
type RuntimeDebugContext
|
|
20
|
+
} from '@vobs/runtime'
|
|
21
|
+
|
|
22
|
+
export { createRouterDebugId, emitRouterDebug, subscribeRouterDebug } from './debug'
|
|
23
|
+
export type { RouterDebugEvent, RouterDebugEventType } from './debug'
|
|
24
|
+
|
|
25
|
+
export type RouteParams = Readonly<Record<string, string>>
|
|
26
|
+
export type RouteQueryValue = string | readonly string[]
|
|
27
|
+
export type RouteQuery = Readonly<Record<string, RouteQueryValue>>
|
|
28
|
+
export type RouteMeta = Readonly<Record<string, unknown>>
|
|
29
|
+
|
|
30
|
+
export interface RouteLocation {
|
|
31
|
+
readonly path: string
|
|
32
|
+
readonly fullPath: string
|
|
33
|
+
readonly params: RouteParams
|
|
34
|
+
readonly query: RouteQuery
|
|
35
|
+
readonly hash: string
|
|
36
|
+
readonly name: string | undefined
|
|
37
|
+
readonly meta: RouteMeta
|
|
38
|
+
readonly record: RouteRecord | null
|
|
39
|
+
readonly matched: readonly RouteRecord[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RouteComponentProps {
|
|
43
|
+
readonly route: RouteLocation
|
|
44
|
+
readonly params: RouteParams
|
|
45
|
+
readonly query: RouteQuery
|
|
46
|
+
readonly children?: VobsNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RouteComponent = (props: RouteComponentProps) => VobsNode
|
|
50
|
+
export type RouteComponentModule = RouteComponent | { default: RouteComponent }
|
|
51
|
+
export type RouteComponentLoader = () => PromiseLike<RouteComponentModule>
|
|
52
|
+
|
|
53
|
+
export interface LazyRouteComponent {
|
|
54
|
+
readonly kind: 'vobs-lazy-route'
|
|
55
|
+
readonly load: RouteComponentLoader
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type RouteComponentDefinition = RouteComponent | LazyRouteComponent
|
|
59
|
+
|
|
60
|
+
export interface RouteLoaderContext {
|
|
61
|
+
readonly route: RouteLocation
|
|
62
|
+
readonly navigationId?: number
|
|
63
|
+
readonly dataRequestId?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type RouteLoader = (context: RouteLoaderContext) => unknown | PromiseLike<unknown>
|
|
67
|
+
|
|
68
|
+
export interface RouteRecord {
|
|
69
|
+
readonly path?: string
|
|
70
|
+
readonly component?: RouteComponentDefinition
|
|
71
|
+
readonly source?: string
|
|
72
|
+
readonly name?: string
|
|
73
|
+
readonly meta?: Record<string, unknown>
|
|
74
|
+
readonly loader?: RouteLoader
|
|
75
|
+
readonly action?: RouteLoader
|
|
76
|
+
readonly children?: readonly RouteRecord[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type RouteQueryInput = Record<string, unknown> | URLSearchParams
|
|
80
|
+
|
|
81
|
+
export interface RouteLocationRaw {
|
|
82
|
+
readonly path?: string
|
|
83
|
+
readonly name?: string
|
|
84
|
+
readonly params?: Record<string, unknown>
|
|
85
|
+
readonly query?: RouteQueryInput
|
|
86
|
+
readonly hash?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type RouteTarget = string | RouteLocationRaw
|
|
90
|
+
|
|
91
|
+
export type NavigationGuardResult = void | boolean | RouteTarget
|
|
92
|
+
export type NavigationGuard = (
|
|
93
|
+
to: RouteLocation,
|
|
94
|
+
from: RouteLocation
|
|
95
|
+
) => NavigationGuardResult | PromiseLike<NavigationGuardResult>
|
|
96
|
+
|
|
97
|
+
export class NavigationCancelledError extends Error {
|
|
98
|
+
readonly code = 'NAVIGATION_CANCELLED'
|
|
99
|
+
|
|
100
|
+
constructor() {
|
|
101
|
+
super('Vobs Router: 导航已被更新的导航取消')
|
|
102
|
+
this.name = 'NavigationCancelledError'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class NavigationRedirectError extends Error {
|
|
107
|
+
readonly code = 'NAVIGATION_REDIRECT_LIMIT'
|
|
108
|
+
|
|
109
|
+
constructor() {
|
|
110
|
+
super('Vobs Router: 导航重定向超过最大次数')
|
|
111
|
+
this.name = 'NavigationRedirectError'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RouterHistory {
|
|
116
|
+
readonly location: string
|
|
117
|
+
push(path: string): void
|
|
118
|
+
replace(path: string): void
|
|
119
|
+
back(): void
|
|
120
|
+
listen(listener: (path: string) => void): () => void
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RouterOptions {
|
|
124
|
+
readonly routes: readonly RouteRecord[]
|
|
125
|
+
readonly history?: RouterHistory
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface RouterViewState {
|
|
129
|
+
readonly status: 'ready' | 'loading' | 'error' | 'not-found'
|
|
130
|
+
readonly component?: RouteComponent
|
|
131
|
+
readonly layouts?: readonly RouteComponent[]
|
|
132
|
+
readonly error?: Error
|
|
133
|
+
readonly retry: () => void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RouteDebugNode {
|
|
137
|
+
readonly id: string
|
|
138
|
+
readonly path: string
|
|
139
|
+
readonly name?: string
|
|
140
|
+
readonly component: string
|
|
141
|
+
readonly lazy: boolean
|
|
142
|
+
readonly loader: boolean
|
|
143
|
+
readonly action: boolean
|
|
144
|
+
readonly status: 'ready' | 'loading' | 'error'
|
|
145
|
+
readonly meta: RouteMeta
|
|
146
|
+
readonly source?: string
|
|
147
|
+
readonly children: readonly RouteDebugNode[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface RouteErrorTrace {
|
|
151
|
+
readonly id: number
|
|
152
|
+
readonly phase: 'navigation' | 'render' | 'lazy' | 'loader' | 'action' | 'fetcher'
|
|
153
|
+
readonly route: string
|
|
154
|
+
readonly message: string
|
|
155
|
+
readonly stack?: string
|
|
156
|
+
readonly timestamp: number
|
|
157
|
+
readonly requestId?: number
|
|
158
|
+
readonly navigationId?: number
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface NavigationTrace {
|
|
162
|
+
readonly id: number
|
|
163
|
+
readonly from: string
|
|
164
|
+
readonly to: string
|
|
165
|
+
readonly status: 'success' | 'redirected' | 'cancelled' | 'error'
|
|
166
|
+
readonly source: 'push' | 'replace' | 'history'
|
|
167
|
+
readonly startedAt: number
|
|
168
|
+
readonly endedAt: number
|
|
169
|
+
readonly duration: number
|
|
170
|
+
readonly redirect?: string
|
|
171
|
+
readonly error?: string
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface NavigationState {
|
|
175
|
+
readonly status: 'idle' | 'loading' | 'error'
|
|
176
|
+
readonly from: string
|
|
177
|
+
readonly to: string
|
|
178
|
+
readonly traceId?: number
|
|
179
|
+
readonly error?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface RouterPerformanceMetrics {
|
|
183
|
+
readonly navigationCount: number
|
|
184
|
+
readonly averageNavigationDuration: number
|
|
185
|
+
readonly slowNavigationCount: number
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type RouterDataRequestKind = 'loader' | 'action' | 'fetcher'
|
|
189
|
+
|
|
190
|
+
export interface RouterDataRequestTrace {
|
|
191
|
+
readonly id: number
|
|
192
|
+
readonly kind: RouterDataRequestKind
|
|
193
|
+
readonly key: string
|
|
194
|
+
readonly route?: string
|
|
195
|
+
readonly status: 'loading' | 'success' | 'error' | 'cancelled'
|
|
196
|
+
readonly startedAt: number
|
|
197
|
+
readonly endedAt?: number
|
|
198
|
+
readonly duration?: number
|
|
199
|
+
readonly result?: unknown
|
|
200
|
+
readonly error?: string
|
|
201
|
+
readonly navigationId?: number
|
|
202
|
+
readonly trigger?: 'navigation' | 'revalidate' | 'manual' | 'resource'
|
|
203
|
+
readonly environment?: 'client' | 'server'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface RouterDataRequestOptions {
|
|
207
|
+
readonly route?: string
|
|
208
|
+
readonly navigationId?: number
|
|
209
|
+
readonly trigger?: 'navigation' | 'revalidate' | 'manual' | 'resource'
|
|
210
|
+
readonly environment?: 'client' | 'server'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type RouterDevToolsEvent = 'navigation:start' | 'navigation:end' | 'route:update' | 'data-request' | 'error'
|
|
214
|
+
|
|
215
|
+
export interface RouterDevToolsAPI {
|
|
216
|
+
getRouteTree(): readonly RouteDebugNode[]
|
|
217
|
+
getCurrentRoute(): RouteLocation
|
|
218
|
+
getNavigationState(): NavigationState
|
|
219
|
+
getNavigationHistory(): readonly NavigationTrace[]
|
|
220
|
+
getPerformanceMetrics(): RouterPerformanceMetrics
|
|
221
|
+
getDataRequests(): readonly RouterDataRequestTrace[]
|
|
222
|
+
getErrors(): readonly RouteErrorTrace[]
|
|
223
|
+
trackDataRequest<T>(
|
|
224
|
+
kind: RouterDataRequestKind,
|
|
225
|
+
key: string,
|
|
226
|
+
task: () => T | PromiseLike<T>,
|
|
227
|
+
options?: RouterDataRequestOptions | string
|
|
228
|
+
): Promise<T>
|
|
229
|
+
runAction<T>(key: string, task: () => T | PromiseLike<T>): Promise<T>
|
|
230
|
+
runFetcher<T>(key: string, task: () => T | PromiseLike<T>): Promise<T>
|
|
231
|
+
reportError(phase: RouteErrorTrace['phase'], error: unknown, route?: string, context?: { readonly requestId?: number; readonly navigationId?: number }): void
|
|
232
|
+
revalidate(route?: string): Promise<void>
|
|
233
|
+
subscribe(event: RouterDevToolsEvent, callback: (payload: unknown) => void): () => void
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface Router {
|
|
237
|
+
readonly currentRoute: Signal<RouteLocation>
|
|
238
|
+
readonly history: RouterHistory
|
|
239
|
+
resolve(to: RouteTarget): RouteLocation
|
|
240
|
+
push(to: RouteTarget): Promise<RouteLocation | false>
|
|
241
|
+
replace(to: RouteTarget): Promise<RouteLocation | false>
|
|
242
|
+
back(): void
|
|
243
|
+
beforeEach(guard: NavigationGuard): () => void
|
|
244
|
+
getViewState(route: RouteLocation): RouterViewState
|
|
245
|
+
readonly devtools: RouterDevToolsAPI
|
|
246
|
+
destroy(): void
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface RouterViewProps {
|
|
250
|
+
readonly router?: Router
|
|
251
|
+
readonly loading?: () => VobsNode | null | undefined
|
|
252
|
+
readonly notFound?: (route: RouteLocation) => VobsNode | null | undefined
|
|
253
|
+
readonly error?: (error: Error, retry: () => void) => VobsNode | null | undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface RouterPluginOptions {
|
|
257
|
+
readonly router?: Router
|
|
258
|
+
readonly routes?: readonly RouteRecord[]
|
|
259
|
+
readonly history?: RouterHistory
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const ROUTER_KEY: InjectionKey<Router> = createInjectionKey<Router>('vobs.router')
|
|
263
|
+
|
|
264
|
+
export function lazy(loader: RouteComponentLoader): LazyRouteComponent {
|
|
265
|
+
return {
|
|
266
|
+
kind: 'vobs-lazy-route',
|
|
267
|
+
load: loader
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createMemoryHistory(initial = '/'): RouterHistory {
|
|
272
|
+
let entries = [normalizeHistoryPath(initial)]
|
|
273
|
+
let index = 0
|
|
274
|
+
const listeners = new Set<(path: string) => void>()
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
get location(): string {
|
|
278
|
+
return entries[index]
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
push(path: string): void {
|
|
282
|
+
const next = normalizeHistoryPath(path)
|
|
283
|
+
entries = entries.slice(0, index + 1)
|
|
284
|
+
entries.push(next)
|
|
285
|
+
index++
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
replace(path: string): void {
|
|
289
|
+
entries[index] = normalizeHistoryPath(path)
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
back(): void {
|
|
293
|
+
if (index === 0) return
|
|
294
|
+
index--
|
|
295
|
+
notifyListeners(listeners, entries[index])
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
listen(listener: (path: string) => void): () => void {
|
|
299
|
+
listeners.add(listener)
|
|
300
|
+
return () => listeners.delete(listener)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function createBrowserHistory(base = ''): RouterHistory {
|
|
306
|
+
if (typeof window === 'undefined') {
|
|
307
|
+
throw new Error('Vobs Router: createBrowserHistory 需要浏览器环境')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const normalizedBase = normalizeBase(base)
|
|
311
|
+
const listeners = new Set<(path: string) => void>()
|
|
312
|
+
const onPopState = (): void => {
|
|
313
|
+
notifyListeners(listeners, readBrowserLocation(normalizedBase))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
get location(): string {
|
|
318
|
+
return readBrowserLocation(normalizedBase)
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
push(path: string): void {
|
|
322
|
+
window.history.pushState(null, '', withBase(normalizeHistoryPath(path), normalizedBase))
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
replace(path: string): void {
|
|
326
|
+
window.history.replaceState(null, '', withBase(normalizeHistoryPath(path), normalizedBase))
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
back(): void {
|
|
330
|
+
window.history.back()
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
listen(listener: (path: string) => void): () => void {
|
|
334
|
+
if (listeners.size === 0) window.addEventListener('popstate', onPopState)
|
|
335
|
+
listeners.add(listener)
|
|
336
|
+
return () => {
|
|
337
|
+
listeners.delete(listener)
|
|
338
|
+
if (listeners.size === 0) window.removeEventListener('popstate', onPopState)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function createRouter(options: RouterOptions): Router {
|
|
345
|
+
const matchers = normalizeRoutes(options.routes)
|
|
346
|
+
const history = options.history ?? defaultHistory()
|
|
347
|
+
const routerDebugId = createRouterDebugId()
|
|
348
|
+
matchers.sort(compareMatchers)
|
|
349
|
+
const currentRoute = state<RouteLocation>(resolvePath(history.location))
|
|
350
|
+
const lazyStates = new Map<RouteRecord, LazyState>()
|
|
351
|
+
const guards: NavigationGuard[] = []
|
|
352
|
+
const navigationHistory: NavigationTrace[] = []
|
|
353
|
+
const dataRequests: RouterDataRequestTrace[] = []
|
|
354
|
+
const errors: RouteErrorTrace[] = []
|
|
355
|
+
const dataLoaders = new Map<string, { kind: RouterDataRequestKind; route: string; task: () => unknown | PromiseLike<unknown> }>()
|
|
356
|
+
const dataRequestContexts = new Map<number, RuntimeDebugContext>()
|
|
357
|
+
const routerListeners = new Map<RouterDevToolsEvent, Set<(payload: unknown) => void>>()
|
|
358
|
+
let navigationState: NavigationState = { status: 'idle', from: currentRoute.value.fullPath, to: currentRoute.value.fullPath }
|
|
359
|
+
let navigationCount = 0
|
|
360
|
+
let totalNavigationDuration = 0
|
|
361
|
+
let slowNavigationCount = 0
|
|
362
|
+
let nextDataRequestId = 1
|
|
363
|
+
let nextErrorId = 1
|
|
364
|
+
let navigationId = 0
|
|
365
|
+
let destroyed = false
|
|
366
|
+
const viewRevision = state(0)
|
|
367
|
+
|
|
368
|
+
function resolve(to: RouteTarget): RouteLocation {
|
|
369
|
+
ensureActive()
|
|
370
|
+
const target = typeof to === 'string' ? parseTargetString(to) : normalizeTarget(to, matchers)
|
|
371
|
+
return resolvePath(buildTargetPath(target.path, target.query, target.hash))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolvePath(rawPath: string): RouteLocation {
|
|
375
|
+
const parsed = parseTargetString(rawPath)
|
|
376
|
+
const matched = matchers.find(matcher => matcher.regex.exec(parsed.path))
|
|
377
|
+
const params = matched ? extractParams(matched, parsed.path) : {}
|
|
378
|
+
const record = matched?.record ?? null
|
|
379
|
+
const query = parsed.query
|
|
380
|
+
const hash = parsed.hash
|
|
381
|
+
return {
|
|
382
|
+
path: parsed.path,
|
|
383
|
+
fullPath: buildTargetPath(parsed.path, query, hash),
|
|
384
|
+
params,
|
|
385
|
+
query,
|
|
386
|
+
hash,
|
|
387
|
+
name: record?.name,
|
|
388
|
+
meta: matched?.meta ?? {},
|
|
389
|
+
record,
|
|
390
|
+
matched: matched?.chain ?? EMPTY_MATCHED
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function navigate(
|
|
395
|
+
to: RouteTarget,
|
|
396
|
+
replaceHistory: boolean,
|
|
397
|
+
fromHistory: boolean
|
|
398
|
+
): Promise<RouteLocation | false> {
|
|
399
|
+
ensureActive()
|
|
400
|
+
const id = ++navigationId
|
|
401
|
+
const from = currentRoute.value
|
|
402
|
+
let target = resolve(to)
|
|
403
|
+
const source: NavigationTrace['source'] = fromHistory ? 'history' : replaceHistory ? 'replace' : 'push'
|
|
404
|
+
const startedAt = now()
|
|
405
|
+
const initialTarget = target.fullPath
|
|
406
|
+
let terminalRecorded = false
|
|
407
|
+
navigationState = { status: 'loading', from: from.fullPath, to: target.fullPath, traceId: id }
|
|
408
|
+
emitRouter('navigation:start', navigationState)
|
|
409
|
+
if (target.fullPath === from.fullPath && !fromHistory) {
|
|
410
|
+
navigationState = { status: 'idle', from: from.fullPath, to: target.fullPath }
|
|
411
|
+
return from
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
for (let redirectCount = 0; ; redirectCount++) {
|
|
416
|
+
ensureNavigationIsCurrent(id)
|
|
417
|
+
let redirect: RouteTarget | undefined
|
|
418
|
+
for (const guard of [...guards]) {
|
|
419
|
+
let result: NavigationGuardResult
|
|
420
|
+
try {
|
|
421
|
+
result = await guard(target, from)
|
|
422
|
+
} catch (reason) {
|
|
423
|
+
if (reason instanceof NavigationCancelledError) throw reason
|
|
424
|
+
const error = toError(reason)
|
|
425
|
+
reportError('navigation', error, target.fullPath)
|
|
426
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
|
|
427
|
+
terminalRecorded = true
|
|
428
|
+
throw reason
|
|
429
|
+
}
|
|
430
|
+
ensureNavigationIsCurrent(id)
|
|
431
|
+
if (result === false) {
|
|
432
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'cancelled', source, startedAt, endedAt: now(), duration: now() - startedAt })
|
|
433
|
+
terminalRecorded = true
|
|
434
|
+
return false
|
|
435
|
+
}
|
|
436
|
+
if (typeof result === 'string' || isRouteLocationRaw(result)) {
|
|
437
|
+
redirect = result
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const record of target.matched) {
|
|
443
|
+
if (!record.loader) continue
|
|
444
|
+
// 上一 loader 期间被新导航抢占时立即取消,跳过剩余 loader。
|
|
445
|
+
ensureNavigationIsCurrent(id)
|
|
446
|
+
await trackDataRequest(
|
|
447
|
+
'loader',
|
|
448
|
+
`${target.fullPath}#${record.path ?? record.name ?? 'route'}`,
|
|
449
|
+
context => record.loader!({ route: target, navigationId: id, dataRequestId: context.dataRequestId }),
|
|
450
|
+
{ route: target.fullPath, navigationId: id, trigger: 'navigation' }
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// loader 完成后、提交前必须重新校验:飞行期间被抢占的导航不允许覆盖 currentRoute 与 history。
|
|
455
|
+
ensureNavigationIsCurrent(id)
|
|
456
|
+
|
|
457
|
+
if (redirect !== undefined) {
|
|
458
|
+
if (redirectCount >= 10) {
|
|
459
|
+
const error = new NavigationRedirectError()
|
|
460
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
|
|
461
|
+
terminalRecorded = true
|
|
462
|
+
throw error
|
|
463
|
+
}
|
|
464
|
+
const redirected = resolve(redirect)
|
|
465
|
+
if (redirected.fullPath === target.fullPath) return false
|
|
466
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'redirected', source, startedAt, endedAt: now(), duration: now() - startedAt, redirect: redirected.fullPath })
|
|
467
|
+
target = redirected
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (target.fullPath === from.fullPath) return from
|
|
472
|
+
if (!fromHistory) {
|
|
473
|
+
if (replaceHistory) history.replace(target.fullPath)
|
|
474
|
+
else history.push(target.fullPath)
|
|
475
|
+
}
|
|
476
|
+
currentRoute.value = target
|
|
477
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'success', source, startedAt, endedAt: now(), duration: now() - startedAt, redirect: target.fullPath !== initialTarget ? target.fullPath : undefined })
|
|
478
|
+
terminalRecorded = true
|
|
479
|
+
return target
|
|
480
|
+
}
|
|
481
|
+
} catch (reason) {
|
|
482
|
+
if (reason instanceof NavigationCancelledError) {
|
|
483
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'cancelled', source, startedAt, endedAt: now(), duration: now() - startedAt })
|
|
484
|
+
terminalRecorded = true
|
|
485
|
+
} else if (!terminalRecorded) {
|
|
486
|
+
const error = toError(reason)
|
|
487
|
+
reportError('navigation', error, target.fullPath)
|
|
488
|
+
recordNavigation({ id, from: from.fullPath, to: target.fullPath, status: 'error', source, startedAt, endedAt: now(), duration: now() - startedAt, error: error.message })
|
|
489
|
+
terminalRecorded = true
|
|
490
|
+
}
|
|
491
|
+
throw reason
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function now(): number {
|
|
496
|
+
return typeof performance === 'undefined' ? Date.now() : performance.now()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function emitRouter(event: RouterDevToolsEvent, payload: unknown): void {
|
|
500
|
+
for (const callback of routerListeners.get(event) ?? []) {
|
|
501
|
+
try { callback(payload) } catch { /* diagnostics must not affect navigation */ }
|
|
502
|
+
}
|
|
503
|
+
emitRouterDebug(routerDebugId, event, payload, getRuntimeDebugContext() ?? undefined)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function recordNavigation(trace: NavigationTrace): void {
|
|
507
|
+
navigationHistory.push(Object.freeze(trace))
|
|
508
|
+
if (navigationHistory.length > 100) navigationHistory.shift()
|
|
509
|
+
navigationCount++
|
|
510
|
+
totalNavigationDuration += trace.duration
|
|
511
|
+
if (trace.duration >= 16) slowNavigationCount++
|
|
512
|
+
if (trace.id === navigationId) {
|
|
513
|
+
navigationState = trace.status === 'error'
|
|
514
|
+
? { status: 'error', from: trace.from, to: trace.to, traceId: trace.id, error: trace.error }
|
|
515
|
+
: { status: 'idle', from: trace.from, to: trace.to, traceId: trace.id }
|
|
516
|
+
}
|
|
517
|
+
emitRouter('navigation:end', trace)
|
|
518
|
+
emitRouter('route:update', currentRoute.value)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function trackDataRequest<T>(
|
|
522
|
+
kind: RouterDataRequestKind,
|
|
523
|
+
key: string,
|
|
524
|
+
task: ((context: { readonly dataRequestId: number }) => T | PromiseLike<T>) | (() => T | PromiseLike<T>),
|
|
525
|
+
optionsOrRoute: RouterDataRequestOptions | string = {}
|
|
526
|
+
): Promise<T> {
|
|
527
|
+
ensureActive()
|
|
528
|
+
const options = typeof optionsOrRoute === 'string' ? { route: optionsOrRoute } : optionsOrRoute
|
|
529
|
+
const id = nextDataRequestId++
|
|
530
|
+
const startedAt = now()
|
|
531
|
+
const context = getRuntimeDebugContext()
|
|
532
|
+
const route = options.route ?? currentRoute.value.fullPath
|
|
533
|
+
const requestContext: RuntimeDebugContext = {
|
|
534
|
+
...context,
|
|
535
|
+
environment: options.environment ?? context?.environment,
|
|
536
|
+
route,
|
|
537
|
+
navigationId: options.navigationId ?? context?.navigationId,
|
|
538
|
+
dataRequestId: id,
|
|
539
|
+
source: kind
|
|
540
|
+
}
|
|
541
|
+
const loading: RouterDataRequestTrace = {
|
|
542
|
+
id,
|
|
543
|
+
kind,
|
|
544
|
+
key,
|
|
545
|
+
route,
|
|
546
|
+
status: 'loading',
|
|
547
|
+
startedAt,
|
|
548
|
+
navigationId: requestContext.navigationId,
|
|
549
|
+
trigger: options.trigger,
|
|
550
|
+
environment: requestContext.environment
|
|
551
|
+
}
|
|
552
|
+
dataRequests.push(loading)
|
|
553
|
+
if (dataRequests.length > 100) dataRequests.shift()
|
|
554
|
+
dataRequestContexts.set(id, requestContext)
|
|
555
|
+
emitRouter('route:update', currentRoute.value)
|
|
556
|
+
emitRouter('data-request', loading)
|
|
557
|
+
emitRouterDebug(routerDebugId, 'data-request', { phase: 'start', trace: loading }, requestContext)
|
|
558
|
+
dataLoaders.set(key, { kind, route, task: task as () => unknown | PromiseLike<unknown> })
|
|
559
|
+
try {
|
|
560
|
+
const result = await runWithRuntimeDebugContext(requestContext, () => (task as (context: { readonly dataRequestId: number }) => T | PromiseLike<T>)({ dataRequestId: id }))
|
|
561
|
+
const endedAt = now()
|
|
562
|
+
replaceDataRequest(id, { ...loading, status: 'success', endedAt, duration: endedAt - startedAt, result })
|
|
563
|
+
return result
|
|
564
|
+
} catch (reason) {
|
|
565
|
+
const endedAt = now()
|
|
566
|
+
const error = toError(reason)
|
|
567
|
+
const status = isAbortError(reason) ? 'cancelled' : 'error'
|
|
568
|
+
if (status === 'error') reportError(kind, error, route, { requestId: id, navigationId: requestContext.navigationId })
|
|
569
|
+
replaceDataRequest(id, { ...loading, status, endedAt, duration: endedAt - startedAt, error: status === 'error' ? error.message : undefined })
|
|
570
|
+
throw reason
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function replaceDataRequest(id: number, trace: RouterDataRequestTrace): void {
|
|
575
|
+
const index = dataRequests.findIndex(item => item.id === id)
|
|
576
|
+
if (index >= 0) dataRequests[index] = Object.freeze(trace)
|
|
577
|
+
emitRouter('route:update', currentRoute.value)
|
|
578
|
+
emitRouter('data-request', trace)
|
|
579
|
+
emitRouterDebug(routerDebugId, 'data-request', { phase: 'end', trace }, dataRequestContexts.get(id))
|
|
580
|
+
dataRequestContexts.delete(id)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function reportError(
|
|
584
|
+
phase: RouteErrorTrace['phase'],
|
|
585
|
+
reason: unknown,
|
|
586
|
+
route = currentRoute.value.fullPath,
|
|
587
|
+
context: { readonly requestId?: number; readonly navigationId?: number } = {}
|
|
588
|
+
): void {
|
|
589
|
+
const error = toError(reason)
|
|
590
|
+
errors.push(Object.freeze({
|
|
591
|
+
id: nextErrorId++,
|
|
592
|
+
phase,
|
|
593
|
+
route,
|
|
594
|
+
message: error.message,
|
|
595
|
+
stack: error.stack,
|
|
596
|
+
timestamp: now(),
|
|
597
|
+
requestId: context.requestId,
|
|
598
|
+
navigationId: context.navigationId
|
|
599
|
+
}))
|
|
600
|
+
if (errors.length > 100) errors.shift()
|
|
601
|
+
emitRouter('error', errors[errors.length - 1]!)
|
|
602
|
+
emitRouter('route:update', currentRoute.value)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function routeTree(): readonly RouteDebugNode[] {
|
|
606
|
+
const statuses = new Map<string, LazyState['status']>()
|
|
607
|
+
for (const matcher of matchers) {
|
|
608
|
+
for (const record of matcher.chain) {
|
|
609
|
+
if (record.component && isLazyRouteComponent(record.component)) {
|
|
610
|
+
const debugId = routeDebugIds.get(record)
|
|
611
|
+
if (debugId) statuses.set(debugId, lazyStates.get(record)?.status ?? 'loading')
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return buildRouteDebugTree(options.routes, statuses)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function handleHistoryNavigation(path: string): void {
|
|
619
|
+
void navigate(path, false, true).then(result => {
|
|
620
|
+
if (destroyed) return
|
|
621
|
+
if (result === false) {
|
|
622
|
+
history.replace(currentRoute.value.fullPath)
|
|
623
|
+
} else if (result.fullPath !== normalizeHistoryPath(path)) {
|
|
624
|
+
history.replace(result.fullPath)
|
|
625
|
+
}
|
|
626
|
+
}).catch(error => {
|
|
627
|
+
if (!(error instanceof NavigationCancelledError) && !destroyed) {
|
|
628
|
+
const current = currentRoute.value.fullPath
|
|
629
|
+
const failed = toError(error)
|
|
630
|
+
reportError('navigation', failed, normalizeHistoryPath(path))
|
|
631
|
+
navigationState = { status: 'error', from: current, to: normalizeHistoryPath(path), error: failed.message }
|
|
632
|
+
emitRouter('navigation:end', { status: 'error', from: current, to: normalizeHistoryPath(path), error: failed.message })
|
|
633
|
+
history.replace(currentRoute.value.fullPath)
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const stopHistory = history.listen(handleHistoryNavigation)
|
|
639
|
+
|
|
640
|
+
const router: Router = {
|
|
641
|
+
currentRoute,
|
|
642
|
+
history,
|
|
643
|
+
|
|
644
|
+
resolve,
|
|
645
|
+
|
|
646
|
+
push(to: RouteTarget): Promise<RouteLocation | false> {
|
|
647
|
+
return navigate(to, false, false)
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
replace(to: RouteTarget): Promise<RouteLocation | false> {
|
|
651
|
+
return navigate(to, true, false)
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
back(): void {
|
|
655
|
+
ensureActive()
|
|
656
|
+
history.back()
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
beforeEach(guard: NavigationGuard): () => void {
|
|
660
|
+
ensureActive()
|
|
661
|
+
guards.push(guard)
|
|
662
|
+
return () => {
|
|
663
|
+
const index = guards.indexOf(guard)
|
|
664
|
+
if (index >= 0) guards.splice(index, 1)
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
getViewState(route: RouteLocation): RouterViewState {
|
|
669
|
+
viewRevision.value
|
|
670
|
+
const records = route.matched.length > 0 ? route.matched : route.record ? [route.record] : []
|
|
671
|
+
const entries = records
|
|
672
|
+
.map(record => ({ record, definition: record.component }))
|
|
673
|
+
.filter((entry): entry is { record: RouteRecord; definition: RouteComponentDefinition } =>
|
|
674
|
+
isRouteComponentDefinition(entry.definition))
|
|
675
|
+
if (!route.record || entries.length === 0) return { status: 'not-found', retry: () => undefined }
|
|
676
|
+
|
|
677
|
+
const loaded: RouteComponent[] = []
|
|
678
|
+
const lazyRecords: RouteRecord[] = []
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
const { record, definition } = entry
|
|
681
|
+
if (!isLazyRouteComponent(definition)) {
|
|
682
|
+
loaded.push(definition)
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
lazyRecords.push(record)
|
|
686
|
+
const lazyState = ensureLazyState(record, definition)
|
|
687
|
+
if (lazyState.status === 'loading') return { status: 'loading', retry: () => retryLazyRoutes(lazyRecords) }
|
|
688
|
+
if (lazyState.status === 'error') {
|
|
689
|
+
return { status: 'error', error: lazyState.error, retry: () => retryLazyRoutes(lazyRecords) }
|
|
690
|
+
}
|
|
691
|
+
if (lazyState.component) loaded.push(lazyState.component)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const component = loaded[loaded.length - 1]
|
|
695
|
+
if (!component) return { status: 'not-found', retry: () => undefined }
|
|
696
|
+
return {
|
|
697
|
+
status: 'ready',
|
|
698
|
+
component,
|
|
699
|
+
layouts: loaded.slice(0, -1),
|
|
700
|
+
retry: () => retryLazyRoutes(lazyRecords)
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
devtools: {
|
|
705
|
+
getRouteTree: routeTree,
|
|
706
|
+
getCurrentRoute: () => currentRoute.value,
|
|
707
|
+
getNavigationState: () => navigationState,
|
|
708
|
+
getNavigationHistory: () => [...navigationHistory],
|
|
709
|
+
getPerformanceMetrics: () => ({
|
|
710
|
+
navigationCount,
|
|
711
|
+
averageNavigationDuration: navigationCount === 0 ? 0 : totalNavigationDuration / navigationCount,
|
|
712
|
+
slowNavigationCount
|
|
713
|
+
}),
|
|
714
|
+
getDataRequests: () => [...dataRequests],
|
|
715
|
+
getErrors: () => [...errors],
|
|
716
|
+
trackDataRequest,
|
|
717
|
+
runAction: (key, task) => trackDataRequest('action', key, task, { trigger: 'manual' }),
|
|
718
|
+
runFetcher: (key, task) => trackDataRequest('fetcher', key, task, { trigger: 'manual' }),
|
|
719
|
+
reportError,
|
|
720
|
+
revalidate: async (route) => {
|
|
721
|
+
await Promise.all([...dataLoaders.entries()]
|
|
722
|
+
.filter(([, loader]) => loader.kind === 'loader' && (route === undefined || loader.route === route))
|
|
723
|
+
.map(([key, loader]) => trackDataRequest(loader.kind, key, loader.task, { route: loader.route, trigger: 'revalidate' })))
|
|
724
|
+
},
|
|
725
|
+
subscribe(event, callback) {
|
|
726
|
+
let listeners = routerListeners.get(event)
|
|
727
|
+
if (!listeners) { listeners = new Set(); routerListeners.set(event, listeners) }
|
|
728
|
+
listeners.add(callback)
|
|
729
|
+
return () => listeners?.delete(callback)
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
destroy(): void {
|
|
734
|
+
if (destroyed) return
|
|
735
|
+
destroyed = true
|
|
736
|
+
navigationId++
|
|
737
|
+
stopHistory()
|
|
738
|
+
guards.length = 0
|
|
739
|
+
routerListeners.clear()
|
|
740
|
+
lazyStates.clear()
|
|
741
|
+
errors.length = 0
|
|
742
|
+
dataRequestContexts.clear()
|
|
743
|
+
currentRoute.dispose()
|
|
744
|
+
viewRevision.dispose()
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function ensureLazyState(record: RouteRecord, definition: LazyRouteComponent): LazyState {
|
|
749
|
+
let lazyState = lazyStates.get(record)
|
|
750
|
+
if (!lazyState) {
|
|
751
|
+
lazyState = { status: 'loading' }
|
|
752
|
+
lazyStates.set(record, lazyState)
|
|
753
|
+
void loadRouteComponent(definition).then(component => {
|
|
754
|
+
if (destroyed) return
|
|
755
|
+
lazyState!.status = 'ready'
|
|
756
|
+
lazyState!.component = component
|
|
757
|
+
viewRevision.value++
|
|
758
|
+
emitRouter('route:update', currentRoute.value)
|
|
759
|
+
}).catch(reason => {
|
|
760
|
+
if (destroyed) return
|
|
761
|
+
lazyState!.status = 'error'
|
|
762
|
+
lazyState!.error = toError(reason)
|
|
763
|
+
reportError('lazy', reason, currentRoute.value.fullPath)
|
|
764
|
+
viewRevision.value++
|
|
765
|
+
emitRouter('route:update', currentRoute.value)
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
return lazyState
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function retryLazyRoutes(records: readonly RouteRecord[]): void {
|
|
772
|
+
for (const record of records) lazyStates.delete(record)
|
|
773
|
+
viewRevision.value++
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function ensureActive(): void {
|
|
777
|
+
if (destroyed) throw new Error('Vobs Router: 已销毁的 Router 不能继续使用')
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function ensureNavigationIsCurrent(id: number): void {
|
|
781
|
+
if (id !== navigationId) throw new NavigationCancelledError()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return router
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export function RouterView(props: RouterViewProps = {}): VobsNode {
|
|
788
|
+
const router = props.router ?? inject(ROUTER_KEY)
|
|
789
|
+
if (!router) throw new Error('Vobs Router: RouterView 找不到 Router,请安装 routerPlugin')
|
|
790
|
+
|
|
791
|
+
return createFragment((parent, anchor) => {
|
|
792
|
+
let routeRetry: () => void = () => undefined
|
|
793
|
+
insertBoundary(parent, anchor, {
|
|
794
|
+
resetKey: () => router.currentRoute.value.fullPath,
|
|
795
|
+
onRetry: () => routeRetry(),
|
|
796
|
+
fallback: (error, retry) => {
|
|
797
|
+
router.devtools.reportError('render', error, router.currentRoute.value.fullPath)
|
|
798
|
+
return props.error?.(error, () => { void retry() }) ?? null
|
|
799
|
+
},
|
|
800
|
+
children: () => {
|
|
801
|
+
const route = router.currentRoute.value
|
|
802
|
+
const view = router.getViewState(route)
|
|
803
|
+
routeRetry = view.retry
|
|
804
|
+
if (view.status === 'loading') return props.loading?.() ?? null
|
|
805
|
+
if (view.status === 'not-found') return props.notFound?.(route) ?? null
|
|
806
|
+
if (view.status === 'error') {
|
|
807
|
+
throw view.error ?? new Error('路由组件加载失败')
|
|
808
|
+
}
|
|
809
|
+
if (!view.component) return null
|
|
810
|
+
let node = createComponent(view.component, {
|
|
811
|
+
route,
|
|
812
|
+
params: route.params,
|
|
813
|
+
query: route.query
|
|
814
|
+
})
|
|
815
|
+
for (let index = (view.layouts?.length ?? 0) - 1; index >= 0; index--) {
|
|
816
|
+
node = createComponent(view.layouts![index]!, {
|
|
817
|
+
route,
|
|
818
|
+
params: route.params,
|
|
819
|
+
query: route.query,
|
|
820
|
+
children: node
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
return node
|
|
824
|
+
}})
|
|
825
|
+
})
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function useRouter(): Router {
|
|
829
|
+
const router = inject(ROUTER_KEY)
|
|
830
|
+
if (!router) throw new Error('Vobs Router: useRouter 找不到 Router,请安装 routerPlugin')
|
|
831
|
+
return router
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function useRoute(): Signal<RouteLocation> {
|
|
835
|
+
return useRouter().currentRoute
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function routerPlugin(options: RouterPluginOptions = {}): VobsPlugin {
|
|
839
|
+
return {
|
|
840
|
+
name: '@vobs/router',
|
|
841
|
+
version: '0.1.0',
|
|
842
|
+
install(context) {
|
|
843
|
+
const ownedRouter = options.router ? undefined : createRouter({
|
|
844
|
+
routes: options.routes ?? [],
|
|
845
|
+
history: options.history
|
|
846
|
+
})
|
|
847
|
+
const router = options.router ?? ownedRouter!
|
|
848
|
+
context.provide(ROUTER_KEY, router)
|
|
849
|
+
return () => ownedRouter?.destroy()
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
interface RouteMatcher {
|
|
855
|
+
readonly record: RouteRecord
|
|
856
|
+
readonly debugId: string
|
|
857
|
+
readonly chain: readonly RouteRecord[]
|
|
858
|
+
readonly meta: RouteMeta
|
|
859
|
+
readonly regex: RegExp
|
|
860
|
+
readonly keys: readonly string[]
|
|
861
|
+
readonly score: number
|
|
862
|
+
readonly order: number
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
interface LazyState {
|
|
866
|
+
status: 'loading' | 'ready' | 'error'
|
|
867
|
+
component?: RouteComponent
|
|
868
|
+
error?: Error
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
interface ParsedTarget {
|
|
872
|
+
readonly path: string
|
|
873
|
+
readonly query: RouteQuery
|
|
874
|
+
readonly hash: string
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function defaultHistory(): RouterHistory {
|
|
878
|
+
return typeof window === 'undefined' ? createMemoryHistory('/') : createBrowserHistory()
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const EMPTY_MATCHED: readonly RouteRecord[] = Object.freeze([])
|
|
882
|
+
const routeDebugIds = new WeakMap<RouteRecord, string>()
|
|
883
|
+
|
|
884
|
+
function buildRouteDebugTree(routes: readonly RouteRecord[], lazyStatuses: ReadonlyMap<string, LazyState['status']> = new Map()): readonly RouteDebugNode[] {
|
|
885
|
+
const visit = (records: readonly RouteRecord[], parentPath: string, parentId: string): RouteDebugNode[] => records.map((record, index) => {
|
|
886
|
+
const path = record.path === undefined ? parentPath || '/' : resolveChildPath(parentPath, record.path)
|
|
887
|
+
const id = `${parentId}.${index}`
|
|
888
|
+
const definition = record.component
|
|
889
|
+
const lazyDefinition = definition !== undefined && isLazyRouteComponent(definition)
|
|
890
|
+
const componentName = definition === undefined
|
|
891
|
+
? 'Route'
|
|
892
|
+
: lazyDefinition
|
|
893
|
+
? 'lazy(...)'
|
|
894
|
+
: typeof definition === 'function'
|
|
895
|
+
? definition.name || 'Anonymous'
|
|
896
|
+
: 'Route'
|
|
897
|
+
return {
|
|
898
|
+
id,
|
|
899
|
+
path,
|
|
900
|
+
name: record.name,
|
|
901
|
+
component: componentName,
|
|
902
|
+
source: record.source,
|
|
903
|
+
lazy: lazyDefinition,
|
|
904
|
+
loader: record.loader !== undefined,
|
|
905
|
+
action: record.action !== undefined,
|
|
906
|
+
status: lazyDefinition ? (lazyStatuses.get(id) ?? 'loading') : 'ready',
|
|
907
|
+
meta: Object.freeze({ ...(record.meta ?? {}) }),
|
|
908
|
+
children: visit(record.children ?? [], path, id)
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
return Object.freeze(visit(routes, '', 'route'))
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function normalizeRoutes(routes: readonly RouteRecord[]): RouteMatcher[] {
|
|
915
|
+
const matchers: RouteMatcher[] = []
|
|
916
|
+
let order = 0
|
|
917
|
+
|
|
918
|
+
function visit(records: readonly RouteRecord[], parentPath: string, parentChain: readonly RouteRecord[], parentMeta: RouteMeta, parentId = 'route'): void {
|
|
919
|
+
records.forEach((record, index) => {
|
|
920
|
+
const debugId = `${parentId}.${index}`
|
|
921
|
+
const children = record.children ?? []
|
|
922
|
+
const path = record.path === undefined
|
|
923
|
+
? parentPath
|
|
924
|
+
: resolveChildPath(parentPath, record.path)
|
|
925
|
+
const normalized: RouteRecord = {
|
|
926
|
+
...record,
|
|
927
|
+
path: record.path === undefined
|
|
928
|
+
? (children.length > 0 ? undefined : (path || '/'))
|
|
929
|
+
: path,
|
|
930
|
+
meta: record.meta ? { ...record.meta } : {}
|
|
931
|
+
}
|
|
932
|
+
routeDebugIds.set(normalized, debugId)
|
|
933
|
+
const chain = [...parentChain, normalized]
|
|
934
|
+
const meta = Object.freeze({ ...parentMeta, ...(normalized.meta ?? {}) })
|
|
935
|
+
if (children.length > 0) {
|
|
936
|
+
visit(children, path, chain, meta, debugId)
|
|
937
|
+
} else if (normalized.component) {
|
|
938
|
+
matchers.push(createMatcher(normalized, chain, meta, order++, debugId))
|
|
939
|
+
} else if (normalized.path === undefined) {
|
|
940
|
+
throw new Error(`Vobs Router: 第 ${index + 1} 个路由缺少 path 或 children`)
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
visit(routes, '', [], {})
|
|
946
|
+
return matchers
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function resolveChildPath(parentPath: string, childPath: string): string {
|
|
950
|
+
const normalizedChild = normalizePath(childPath)
|
|
951
|
+
if (!parentPath || normalizedChild === '/') return normalizedChild === '/' ? (parentPath || '/') : normalizedChild
|
|
952
|
+
if (childPath.startsWith('/')) return normalizedChild
|
|
953
|
+
return normalizePath(`${parentPath}/${childPath}`)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function createMatcher(record: RouteRecord, chain: readonly RouteRecord[], meta: RouteMeta, order: number, debugId: string): RouteMatcher {
|
|
957
|
+
const path = record.path ?? '/'
|
|
958
|
+
const segments = path === '/' ? [] : path.slice(1).split('/')
|
|
959
|
+
const keys: string[] = []
|
|
960
|
+
let score = 0
|
|
961
|
+
const pattern = segments.map(segment => {
|
|
962
|
+
if (segment === '*') {
|
|
963
|
+
keys.push('pathMatch')
|
|
964
|
+
return '(.*)'
|
|
965
|
+
}
|
|
966
|
+
if (segment.startsWith(':')) {
|
|
967
|
+
const key = segment.slice(1)
|
|
968
|
+
if (!key) throw new Error(`Vobs Router: 路由 ${path} 的参数名不能为空`)
|
|
969
|
+
if (keys.includes(key)) throw new Error(`Vobs Router: 路由 ${path} 存在重复参数 ${key}`)
|
|
970
|
+
keys.push(key)
|
|
971
|
+
score += 1
|
|
972
|
+
return '([^/]+)'
|
|
973
|
+
}
|
|
974
|
+
score += 3
|
|
975
|
+
return escapeRegExp(segment)
|
|
976
|
+
}).join('/')
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
record,
|
|
980
|
+
debugId,
|
|
981
|
+
chain: Object.freeze([...chain]),
|
|
982
|
+
meta,
|
|
983
|
+
regex: new RegExp(segments.length === 0 ? '^/?$' : `^/${pattern}/?$`),
|
|
984
|
+
keys,
|
|
985
|
+
score,
|
|
986
|
+
order
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function compareMatchers(left: RouteMatcher, right: RouteMatcher): number {
|
|
991
|
+
return right.score - left.score || left.order - right.order
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function extractParams(matcher: RouteMatcher, path: string): RouteParams {
|
|
995
|
+
const match = matcher.regex.exec(path)
|
|
996
|
+
if (!match) return {}
|
|
997
|
+
const params: Record<string, string> = {}
|
|
998
|
+
matcher.keys.forEach((key, index) => {
|
|
999
|
+
params[key] = decodeRoutePart(match[index + 1] ?? '')
|
|
1000
|
+
})
|
|
1001
|
+
return Object.freeze(params)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function parseTargetString(raw: string): ParsedTarget {
|
|
1005
|
+
const hashIndex = raw.indexOf('#')
|
|
1006
|
+
const hash = hashIndex >= 0 ? normalizeHash(raw.slice(hashIndex + 1)) : ''
|
|
1007
|
+
const withoutHash = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw
|
|
1008
|
+
const queryIndex = withoutHash.indexOf('?')
|
|
1009
|
+
const path = normalizePath(queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash)
|
|
1010
|
+
const query = queryIndex >= 0 ? parseQuery(withoutHash.slice(queryIndex + 1)) : {}
|
|
1011
|
+
return { path, query, hash }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function normalizeTarget(target: RouteLocationRaw, matchers: readonly RouteMatcher[]): ParsedTarget {
|
|
1015
|
+
let path = target.path
|
|
1016
|
+
if (!path && target.name) {
|
|
1017
|
+
const matcher = matchers.find(candidate => candidate.record.name === target.name)
|
|
1018
|
+
if (!matcher) throw new Error(`Vobs Router: 找不到名为 ${target.name} 的路由`)
|
|
1019
|
+
path = fillRouteParams(matcher.record.path ?? '/', target.params ?? {})
|
|
1020
|
+
}
|
|
1021
|
+
if (!path) throw new Error('Vobs Router: 导航目标必须提供 path 或 name')
|
|
1022
|
+
|
|
1023
|
+
const parsed = parseTargetString(path)
|
|
1024
|
+
const filledPath = fillRouteParams(parsed.path, target.params ?? {})
|
|
1025
|
+
const query = target.query === undefined ? parsed.query : normalizeQuery(target.query)
|
|
1026
|
+
const hash = target.hash === undefined ? parsed.hash : normalizeHash(target.hash)
|
|
1027
|
+
return { path: filledPath, query, hash }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function fillRouteParams(path: string, params: Record<string, unknown>): string {
|
|
1031
|
+
return path.replace(/:([A-Za-z0-9_]+)|\*/g, (token, key: string | undefined) => {
|
|
1032
|
+
const value = key ? params[key] : params.pathMatch
|
|
1033
|
+
if (value === undefined || value === null) return token
|
|
1034
|
+
return encodeURIComponent(String(value))
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function buildTargetPath(path: string, query: RouteQuery, hash: string): string {
|
|
1039
|
+
const params = new URLSearchParams()
|
|
1040
|
+
for (const key of Object.keys(query).sort()) {
|
|
1041
|
+
const value = query[key]
|
|
1042
|
+
if (typeof value === 'string') {
|
|
1043
|
+
params.set(key, value)
|
|
1044
|
+
} else {
|
|
1045
|
+
for (const item of value) params.append(key, item)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const serialized = params.toString()
|
|
1049
|
+
return `${path}${serialized ? `?${serialized}` : ''}${hash}`
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function parseQuery(raw: string): RouteQuery {
|
|
1053
|
+
const params = new URLSearchParams(raw)
|
|
1054
|
+
const result: Record<string, RouteQueryValue> = {}
|
|
1055
|
+
params.forEach((value, key) => {
|
|
1056
|
+
const previous = result[key]
|
|
1057
|
+
if (previous === undefined) result[key] = value
|
|
1058
|
+
else if (typeof previous === 'string') result[key] = [previous, value]
|
|
1059
|
+
else result[key] = [...previous, value]
|
|
1060
|
+
})
|
|
1061
|
+
for (const key of Object.keys(result)) {
|
|
1062
|
+
if (Array.isArray(result[key])) result[key] = Object.freeze(result[key] as string[])
|
|
1063
|
+
}
|
|
1064
|
+
return Object.freeze(result)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function normalizeQuery(input: RouteQueryInput): RouteQuery {
|
|
1068
|
+
if (input instanceof URLSearchParams) return parseQuery(input.toString())
|
|
1069
|
+
const result: Record<string, RouteQueryValue> = {}
|
|
1070
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1071
|
+
if (value === undefined || value === null) continue
|
|
1072
|
+
if (Array.isArray(value)) result[key] = Object.freeze(value.map(item => String(item)))
|
|
1073
|
+
else result[key] = String(value)
|
|
1074
|
+
}
|
|
1075
|
+
return Object.freeze(result)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function normalizePath(path: string): string {
|
|
1079
|
+
if (!path) return '/'
|
|
1080
|
+
const withoutQuery = path.split(/[?#]/, 1)[0] || '/'
|
|
1081
|
+
const withLeadingSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
|
|
1082
|
+
if (withLeadingSlash === '/*' || withLeadingSlash === '/') return withLeadingSlash
|
|
1083
|
+
return withLeadingSlash.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function normalizeHistoryPath(path: string): string {
|
|
1087
|
+
const parsed = parseTargetString(path)
|
|
1088
|
+
return buildTargetPath(parsed.path, parsed.query, parsed.hash)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function normalizeHash(hash: string): string {
|
|
1092
|
+
if (!hash) return ''
|
|
1093
|
+
return hash.startsWith('#') ? hash : `#${hash}`
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function normalizeBase(base: string): string {
|
|
1097
|
+
if (!base || base === '/') return ''
|
|
1098
|
+
return `/${base.replace(/^\/+|\/+$/g, '')}`
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function readBrowserLocation(base: string): string {
|
|
1102
|
+
const pathname = window.location.pathname
|
|
1103
|
+
const path = base && (pathname === base || pathname.startsWith(`${base}/`))
|
|
1104
|
+
? pathname.slice(base.length) || '/'
|
|
1105
|
+
: pathname
|
|
1106
|
+
return normalizeHistoryPath(`${path}${window.location.search}${window.location.hash}`)
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function withBase(path: string, base: string): string {
|
|
1110
|
+
return `${base}${path === '/' ? '/' : path}` || '/'
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function notifyListeners(listeners: Set<(path: string) => void>, path: string): void {
|
|
1114
|
+
for (const listener of [...listeners]) listener(path)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function escapeRegExp(value: string): string {
|
|
1118
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function decodeRoutePart(value: string): string {
|
|
1122
|
+
try {
|
|
1123
|
+
return decodeURIComponent(value)
|
|
1124
|
+
} catch {
|
|
1125
|
+
return value
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function isLazyRouteComponent(value: RouteComponentDefinition): value is LazyRouteComponent {
|
|
1130
|
+
return typeof value === 'object' && value !== null && value.kind === 'vobs-lazy-route'
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function isRouteComponentDefinition(value: unknown): value is RouteComponentDefinition {
|
|
1134
|
+
return typeof value === 'function' || isLazyRouteComponent(value as RouteComponentDefinition)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function loadRouteComponent(loader: LazyRouteComponent): Promise<RouteComponent> {
|
|
1138
|
+
const module = await loader.load()
|
|
1139
|
+
const component = typeof module === 'function' ? module : module.default
|
|
1140
|
+
if (typeof component !== 'function') throw new Error('Vobs Router: 懒加载模块没有默认组件导出')
|
|
1141
|
+
return component
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function isRouteLocationRaw(value: unknown): value is RouteLocationRaw {
|
|
1145
|
+
return Boolean(value) && typeof value === 'object'
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function toError(reason: unknown): Error {
|
|
1149
|
+
return reason instanceof Error ? reason : new Error(String(reason))
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function isAbortError(reason: unknown): boolean {
|
|
1153
|
+
return Boolean(reason) && typeof reason === 'object'
|
|
1154
|
+
&& ((reason as { readonly name?: unknown }).name === 'AbortError'
|
|
1155
|
+
|| (reason as { readonly code?: unknown }).code === 'ERR_CANCELED')
|
|
1156
|
+
}
|