vitarx-router 4.0.0-beta.21 → 4.0.0-beta.23

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/README.md CHANGED
@@ -292,6 +292,70 @@ if (hasSuccess(result)) {
292
292
  | `duplicated` | 8 | 重复导航 |
293
293
  | `notfound` | 16 | 路由未匹配 |
294
294
 
295
+ ## 路由未匹配处理
296
+
297
+ 当路由匹配失败(404)时,可以通过 `onNotFound` 钩子进行自定义处理。
298
+
299
+ ### onNotFound 钩子
300
+
301
+ ```typescript
302
+ import { createRouter } from 'vitarx-router'
303
+
304
+ const router = createRouter({
305
+ routes: [],
306
+ onNotFound(target) {
307
+ // target.index 为用户尝试访问的目标
308
+ console.log('路由未匹配:', target.index)
309
+ }
310
+ })
311
+ ```
312
+
313
+ **返回值说明:**
314
+
315
+ | 返回值 | 说明 |
316
+ |----------------------------|----------------------|
317
+ | `NavTarget` / `RouteIndex` | 重定向到新目标 |
318
+ | `RouteLocation` | 作为未匹配路由的位置对象(渲染指定组件) |
319
+ | `void` | 不处理,返回 `notfound` 状态 |
320
+
321
+ ### 重定向到 404 页面
322
+
323
+ ```typescript
324
+ import { createRouter } from 'vitarx-router'
325
+
326
+ const router = createRouter({
327
+ routes: [],
328
+ onNotFound(target) {
329
+ return { index: '/404' }
330
+ }
331
+ })
332
+ ```
333
+
334
+ ### 渲染 404 组件(使用 createMissingRoute)
335
+
336
+ ```typescript
337
+ import { createRouter, createMissingRoute } from 'vitarx-router'
338
+ import NotFoundPage from './pages/NotFound.jsx'
339
+
340
+ const router = createRouter({
341
+ routes: [],
342
+ onNotFound(target) {
343
+ return createMissingRoute(NotFoundPage, target, {
344
+ title: '页面未找到'
345
+ })
346
+ }
347
+ })
348
+ ```
349
+
350
+ ### 名称导航不匹配
351
+
352
+ 名称导航(name-based)匹配失败时,路由器会直接抛出错误,因为名称导航是编程式调用,name 不存在属于代码 bug:
353
+
354
+ ```typescript
355
+ // 如果 'userDetail' 路由不存在,将抛出错误
356
+ router.push({ index: 'userDetail', params: { id: '123' } })
357
+ ```
358
+
295
359
  ## 导航守卫
296
360
 
297
361
  ### 全局前置守卫
@@ -472,18 +536,20 @@ declare module 'vitarx-router' {
472
536
 
473
537
  ### 助手函数
474
538
 
475
- | 函数 | 说明 |
476
- |----------------------------------------|-----------------|
477
- | `createRouter(options)` | 创建路由器实例 |
478
- | `createWebRouter(options)` | 创建 Web 模式路由器 |
479
- | `createMemoryRouter(options)` | 创建 Memory 模式路由器 |
480
- | `createRouteManager(routes, options?)` | 创建路由管理器 |
481
- | `defineRoutes(...routes)` | 定义路由表 |
482
- | `useRouter()` | 获取路由器实例 |
483
- | `useRoute(global?)` | 获取当前路由信息 |
484
- | `useLink(options)` | 创建链接助手 |
485
- | `onBeforeRouteLeave(guard)` | 注册离开守卫 |
486
- | `onBeforeRouteUpdate(callback)` | 注册更新钩子 |
539
+ | 函数 | 说明 |
540
+ |------------------------------------------------|------------------------|
541
+ | `createRouter(options)` | 创建路由器实例 |
542
+ | `createWebRouter(options)` | 创建 Web 模式路由器 |
543
+ | `createMemoryRouter(options)` | 创建 Memory 模式路由器 |
544
+ | `createRouteManager(routes, options?)` | 创建路由管理器 |
545
+ | `defineRoutes(...routes)` | 定义路由表 |
546
+ | `createMissingRoute(component, target, meta?)` | 创建未匹配路由的 RouteLocation |
547
+ | `useRouter()` | 获取路由器实例 |
548
+ | `useRoute(global?)` | 获取当前路由信息 |
549
+ | `useLink(options)` | 创建链接助手 |
550
+ | `onBeforeRouteLeave(guard)` | 注册离开守卫 |
551
+ | `onBeforeRouteUpdate(callback)` | 注册更新钩子 |
552
+ | `removePathEndSlash(path)` | 删除路径末尾的斜杠 |
487
553
 
488
554
  ### Router 实例方法
489
555
 
@@ -7,6 +7,16 @@ import type { GuardResult, NavTarget, RouteIndex, RouteLocation, RoutePath, URLH
7
7
  * @returns {boolean} 如果值是一个导航目标对象则返回true,否则返回false
8
8
  */
9
9
  export declare function hasValidNavTarget(val: unknown): val is NavTarget;
10
+ /**
11
+ * 检查给定的值是否为 RouteLocation 对象
12
+ *
13
+ * 通过检测 `matched` 和 `path` 属性来区分 RouteLocation 与 NavTarget,
14
+ * 因为 NavTarget 不包含这两个字段,而 RouteLocation 必定包含。
15
+ *
16
+ * @param val - 需要检查的值
17
+ * @returns {val is RouteLocation} 如果是 RouteLocation 对象则返回 true
18
+ */
19
+ export declare function isRouteLocation(val: unknown): val is RouteLocation;
10
20
  /**
11
21
  * 检查给定的值是否为有效的路由索引
12
22
  *
@@ -28,7 +38,7 @@ export declare function hasOnlyChangeHash(route1: RouteLocation, route2: RouteLo
28
38
  * @param index - 要判断的索引
29
39
  * @returns {boolean} - 如果索引为路径索引则返回true,否则返回false
30
40
  */
31
- export declare function hasValidPath(index: any): index is RoutePath;
41
+ export declare function isValidPath(index: unknown): index is RoutePath;
32
42
  /**
33
43
  * 移除路径字符串中的指定后缀
34
44
  * @param path - 原始路径字符串
@@ -7,10 +7,20 @@ import { normalizePath, parseQuery } from '../shared/utils.js';
7
7
  * @returns {boolean} 如果值是一个导航目标对象则返回true,否则返回false
8
8
  */
9
9
  export function hasValidNavTarget(val) {
10
- // 首先检查值是否是一个普通对象
11
- // 然后检查该对象是否包含 'index' 属性
12
10
  return isPlainObject(val) && 'index' in val && hasValidRouteIndex(val.index);
13
11
  }
12
+ /**
13
+ * 检查给定的值是否为 RouteLocation 对象
14
+ *
15
+ * 通过检测 `matched` 和 `path` 属性来区分 RouteLocation 与 NavTarget,
16
+ * 因为 NavTarget 不包含这两个字段,而 RouteLocation 必定包含。
17
+ *
18
+ * @param val - 需要检查的值
19
+ * @returns {val is RouteLocation} 如果是 RouteLocation 对象则返回 true
20
+ */
21
+ export function isRouteLocation(val) {
22
+ return isPlainObject(val) && 'matched' in val && 'path' in val;
23
+ }
14
24
  /**
15
25
  * 检查给定的值是否为有效的路由索引
16
26
  *
@@ -39,7 +49,7 @@ export function hasOnlyChangeHash(route1, route2) {
39
49
  * @param index - 要判断的索引
40
50
  * @returns {boolean} - 如果索引为路径索引则返回true,否则返回false
41
51
  */
42
- export function hasValidPath(index) {
52
+ export function isValidPath(index) {
43
53
  return isString(index) && index.startsWith('/');
44
54
  }
45
55
  /**
@@ -71,7 +81,7 @@ export function parseHashContent(hashContent) {
71
81
  // 同样限制分割数为 2,防止路径中包含 ?
72
82
  const [pathname, queryString] = pathAndQuery.split('?', 2);
73
83
  // 3. 格式化路径
74
- path = normalizePath(pathname || '/');
84
+ path = pathname ? normalizePath(pathname) : '/';
75
85
  // 4. 解析查询参数
76
86
  if (queryString) {
77
87
  query = parseQuery(queryString);
@@ -97,7 +97,7 @@ export function mergePathVariable(path, params) {
97
97
  return String(value).replace(/\s+/g, '_');
98
98
  });
99
99
  // 使用 formatPath 处理可能出现的双斜杠 (如 /user//) 或首尾斜杠问题
100
- return normalizePath(fullPath);
100
+ return normalizePath(fullPath, true);
101
101
  }
102
102
  /**
103
103
  * 合并两个路由模式对象
@@ -1,4 +1,4 @@
1
- import { isComponent, isFunction } from 'vitarx';
1
+ import { isFunction } from 'vitarx';
2
2
  import { RouteManager } from './manager.js';
3
3
  /**
4
4
  * 检查路由器配置选项的合法性
@@ -110,10 +110,4 @@ export const checkRouterOptions = (options) => {
110
110
  });
111
111
  }
112
112
  }
113
- // 9. 检查 missing 组件的类型
114
- if ('missing' in options && options.missing !== undefined) {
115
- if (!isComponent(options.missing)) {
116
- throw new Error('[Router] "missing" must be a valid component.');
117
- }
118
- }
119
113
  };
@@ -9,7 +9,9 @@ export interface RouteManagerOptions {
9
9
  /**
10
10
  * 是否启用严格模式
11
11
  *
12
- * 启用后路径匹配将严格匹配,即路径末尾的斜杠会被视为重要字符
12
+ * 启用后路径匹配将严格匹配即 /user/ 结尾存在斜杠会匹配失败
13
+ *
14
+ * 禁用后路径匹配将宽匹配即 /user/ 会匹配 /user
13
15
  *
14
16
  * @default false
15
17
  */
@@ -23,8 +25,8 @@ export interface RouteManagerOptions {
23
25
  /**
24
26
  * 是否启用索引回退匹配。
25
27
  *
26
- * 开启后,当访问深层路径(如 /user/index)未匹配时,
27
- * 会尝试移除最后一段路径(/user)再次进行匹配。
28
+ * 开启后,当访问深层index路径(如 /user/index)未匹配时,
29
+ * 会尝试移除index路径(/user)再次进行匹配。
28
30
  * 匹配成功则渲染组件,且保持 URL 不变。
29
31
  *
30
32
  * 仅支持匹配静态路径,不做动态路径匹配!
@@ -126,7 +128,7 @@ export declare class RouteManager {
126
128
  * @returns 规范化后的路径(根据 ignoreCase 配置转换为小写)
127
129
  * @private
128
130
  */
129
- private normalizePath;
131
+ private toLookupPath;
130
132
  /**
131
133
  * 根据路径查找路由
132
134
  *
@@ -1,6 +1,6 @@
1
1
  import { isArray, isFunction, isString, logger, markRaw } from 'vitarx';
2
2
  import { resolveComponent, resolvePattern, resolveProps } from '../common/resolve.js';
3
- import { hasValidNavTarget, hasValidPath, hasValidRouteIndex } from '../common/utils.js';
3
+ import { hasValidNavTarget, hasValidRouteIndex, isValidPath } from '../common/utils.js';
4
4
  import { isVariablePath, mergePathVariable, mergePattern, optionalVariableCount, validateAliasVariables } from '../common/variable.js';
5
5
  import { normalizePath } from '../shared/utils.js';
6
6
  /**
@@ -127,7 +127,7 @@ export class RouteManager {
127
127
  * @returns 规范化后的路径(根据 ignoreCase 配置转换为小写)
128
128
  * @private
129
129
  */
130
- normalizePath(path) {
130
+ toLookupPath(path) {
131
131
  return this.config.ignoreCase ? path.toLowerCase() : path;
132
132
  }
133
133
  /**
@@ -137,7 +137,7 @@ export class RouteManager {
137
137
  * @returns 路由记录对象,如果未找到则返回 undefined
138
138
  */
139
139
  findByPath(path) {
140
- const normalizedPath = this.normalizePath(path);
140
+ const normalizedPath = this.toLookupPath(path);
141
141
  return this.staticRoutes.get(normalizedPath) ?? this.aliasRoutes.get(normalizedPath) ?? null;
142
142
  }
143
143
  /**
@@ -183,9 +183,7 @@ export class RouteManager {
183
183
  normalizedPath = normalizedPath.slice(0, -1);
184
184
  }
185
185
  // 1. 标准化路径
186
- const lookupPath = this.config.ignoreCase
187
- ? normalizedPath.toLowerCase()
188
- : normalizedPath;
186
+ const lookupPath = this.toLookupPath(normalizedPath);
189
187
  // 2. 静态路由精确匹配
190
188
  const staticRoute = this.staticRoutes.get(lookupPath);
191
189
  if (staticRoute) {
@@ -197,14 +195,14 @@ export class RouteManager {
197
195
  return { path, route: aliasRoute, params: {} };
198
196
  }
199
197
  // 3. 动态路由匹配
200
- const pathSegments = normalizedPath.split('/').filter(Boolean);
198
+ const pathSegments = path.split('/').filter(Boolean);
201
199
  const segmentCount = pathSegments.length;
202
200
  const dynamicCandidates = this.dynamicRoutes.get(segmentCount);
203
201
  if (dynamicCandidates) {
204
202
  for (const { regex, route } of dynamicCandidates) {
205
203
  regex.lastIndex = 0;
206
204
  // 执行正则匹配
207
- const regexResult = regex.exec(normalizedPath);
205
+ const regexResult = regex.exec(path);
208
206
  if (regexResult) {
209
207
  const params = {};
210
208
  // 解析捕获组参数
@@ -222,6 +220,7 @@ export class RouteManager {
222
220
  }
223
221
  }
224
222
  }
223
+ // 4. 结尾斜杠索引回退匹配
225
224
  if (lookupPath.endsWith('/index') && this.config.fallbackIndex) {
226
225
  const fallbackPath = lookupPath.slice(0, -6);
227
226
  const fallbackRoute = this.staticRoutes.get(fallbackPath || '/');
@@ -289,7 +288,7 @@ export class RouteManager {
289
288
  * @returns 匹配结果对象,包含路由记录和解析后的参数;未匹配返回 null
290
289
  */
291
290
  match(index, params) {
292
- if (hasValidPath(index)) {
291
+ if (isValidPath(index)) {
293
292
  return this.matchByPath(index);
294
293
  }
295
294
  return this.matchByName(index, params);
@@ -354,14 +353,14 @@ export class RouteManager {
354
353
  // 删除当前路由
355
354
  this.routes.delete(route);
356
355
  // 删除静态路由映射
357
- this.staticRoutes.delete(this.normalizePath(route.path));
356
+ this.staticRoutes.delete(this.toLookupPath(route.path));
358
357
  // 删除命名路由映射
359
358
  if (route.name)
360
359
  this.namedRoutes.delete(route.name);
361
360
  // 删除别名映射
362
361
  if (route.aliases) {
363
362
  for (const alias of route.aliases) {
364
- this.aliasRoutes.delete(this.normalizePath(alias));
363
+ this.aliasRoutes.delete(this.toLookupPath(alias));
365
364
  // 如果别名是动态路由,也需要从 dynamicRoutes 中移除
366
365
  if (isVariablePath(alias)) {
367
366
  this.removeAliasDynamicRoute(alias, route);
@@ -462,7 +461,7 @@ export class RouteManager {
462
461
  record.parent = parent;
463
462
  }
464
463
  const rawPath = route.path.trim();
465
- record.path = normalizePath(parent ? `${parent.path}/${rawPath}` : `/${rawPath}`);
464
+ record.path = normalizePath(parent ? `${parent.path}/${rawPath}` : `/${rawPath}`, true);
466
465
  if (route.name) {
467
466
  record.name = route.name;
468
467
  }
@@ -513,13 +512,13 @@ export class RouteManager {
513
512
  aliasPath = parent.path;
514
513
  }
515
514
  else if (rawAlias.startsWith('/')) {
516
- aliasPath = normalizePath(rawAlias);
515
+ aliasPath = normalizePath(rawAlias, true);
517
516
  }
518
517
  else if (!parent) {
519
- aliasPath = normalizePath(`/${rawAlias}`);
518
+ aliasPath = normalizePath(`/${rawAlias}`, true);
520
519
  }
521
520
  else {
522
- aliasPath = normalizePath(`${parent.path}/${rawAlias}`);
521
+ aliasPath = normalizePath(`${parent.path}/${rawAlias}`, true);
523
522
  }
524
523
  record.aliases.push(aliasPath);
525
524
  }
@@ -552,7 +551,7 @@ export class RouteManager {
552
551
  this.registerDynamicRoute(route, route.fullPattern);
553
552
  }
554
553
  else {
555
- const path = this.normalizePath(route.path);
554
+ const path = this.toLookupPath(route.path);
556
555
  // 检查是否已存在相同路径的路由
557
556
  if (this.staticRoutes.has(path)) {
558
557
  // 抛出错误:检测到重复的路由路径
@@ -564,7 +563,6 @@ export class RouteManager {
564
563
  // 注册别名
565
564
  if (route.aliases && route.aliases.length > 0) {
566
565
  for (const alias of route.aliases) {
567
- const aliasPath = this.normalizePath(alias);
568
566
  if (isVariablePath(alias)) {
569
567
  if (!validateAliasVariables(route.pattern, alias)) {
570
568
  throw new Error(`[Router] Alias "${alias}" variables do not match route "${route.path}" pattern`);
@@ -573,6 +571,7 @@ export class RouteManager {
573
571
  this.registerDynamicRoute(route, regex, alias);
574
572
  }
575
573
  else {
574
+ const aliasPath = this.toLookupPath(alias);
576
575
  if (route.pattern && route.pattern.length > 0) {
577
576
  throw new Error(`[Router] Alias "${alias}" for dynamic route "${route.path}" must contain variables`);
578
577
  }
@@ -319,13 +319,24 @@ export declare abstract class Router {
319
319
  * @returns 导航上下文对象
320
320
  */
321
321
  private createNavigationContext;
322
+ /**
323
+ * 执行导航流程
324
+ *
325
+ * 统一处理重复路由检测、hash 变化、重定向、守卫和导航完成。
326
+ * 被 navigate 和 handleNotFound(onNotFound 返回 RouteLocation 时)共用。
327
+ *
328
+ * @param context - 导航上下文
329
+ * @returns 导航结果
330
+ */
331
+ private processNavigation;
322
332
  /**
323
333
  * 处理路由未匹配(404)场景
324
334
  *
325
335
  * 当目标路由无法匹配时执行:
326
336
  * 1. 触发全局 onNotFound 钩子
327
- * 2. 如果钩子返回了新的导航目标,进行重定向
328
- * 3. 否则返回 notfound 状态
337
+ * 2. 如果钩子返回了 RouteLocation,将其设为导航目标继续正常导航流程
338
+ * 3. 如果钩子返回了新的导航目标(NavTarget),进行重定向
339
+ * 4. 否则返回 notfound 状态
329
340
  *
330
341
  * @param context - 导航上下文
331
342
  * @param target - 原始导航目标
@@ -437,6 +448,16 @@ export declare abstract class Router {
437
448
  private runScrollBehavior;
438
449
  /**
439
450
  * 处理 404 错误
451
+ *
452
+ * 执行 onNotFound 钩子链,按注册顺序依次调用,
453
+ * 第一个返回有效值的钩子会终止遍历。
454
+ *
455
+ * 返回值类型:
456
+ * - RouteLocation: 作为未匹配路由的位置对象(优先判断,因为结构更具体)
457
+ * - NavTarget: 重定向到新目标
458
+ * - string | symbol: 包装为 NavTarget 后重定向
459
+ * - void: 继续执行下一个钩子
460
+ *
440
461
  * @param target - 导航目标对象
441
462
  * @returns - 返回处理结果
442
463
  */
@@ -487,15 +508,6 @@ export declare abstract class Router {
487
508
  * @private
488
509
  */
489
510
  private reportError;
490
- /**
491
- * 创建缺失的路由
492
- * @param component - 路由组件
493
- * @param path - 路径
494
- * @param query - 查询参数
495
- * @param hash - 哈希值
496
- * @returns {RouteLocation} - 返回创建的路由位置对象
497
- */
498
- private createMissingRoute;
499
511
  /**
500
512
  * 构建完整URL路径
501
513
  *
@@ -1,7 +1,7 @@
1
1
  import { getLazyLoader, isArray, isFunction, isPlainObject, isPromise, isString, logger, nextTick, preloadComponent, readonly, shallowReactive } from 'vitarx';
2
2
  import { __ROUTER_KEY__, NavState } from '../common/constant.js';
3
3
  import { updateRouteLocation } from '../common/update.js';
4
- import { hasOnlyChangeHash, hasValidNavTarget, hasValidPath, hasValidRouteIndex, processGuardResult, registerHookTool, removePathSuffix, resolveNavTarget } from '../common/utils.js';
4
+ import { hasOnlyChangeHash, hasValidNavTarget, hasValidRouteIndex, isRouteLocation, isValidPath, processGuardResult, registerHookTool, removePathSuffix, resolveNavTarget } from '../common/utils.js';
5
5
  import { cloneRouteLocation, normalizePath, stringifyQuery } from '../shared/utils.js';
6
6
  import { checkRouterOptions } from './checkOptions.js';
7
7
  import { RouteManager } from './manager.js';
@@ -430,6 +430,16 @@ export class Router {
430
430
  }
431
431
  // 解析目标路由
432
432
  const to = this.matchRoute(target, redirectFrom);
433
+ // name-based 导航匹配失败视为编程错误,直接抛出异常
434
+ // 因为名称导航是编程式调用,name 不存在或参数校验失败属于代码 bug
435
+ if (!to && !isValidPath(target.index)) {
436
+ const name = target.index;
437
+ const route = this.manager.findByName(name);
438
+ if (route) {
439
+ throw new Error(`[Router] Route "${String(name)}" matched but params validation failed. Check required params and their formats.`);
440
+ }
441
+ throw new Error(`[Router] Route not found: "${String(name)}". Name-based navigation must target a registered route.`);
442
+ }
433
443
  // 确定来源路由
434
444
  const from = fromRoute ?? cloneRouteLocation(this._routeLocation);
435
445
  // 构建导航结果基础对象
@@ -470,22 +480,61 @@ export class Router {
470
480
  }
471
481
  };
472
482
  }
483
+ /**
484
+ * 执行导航流程
485
+ *
486
+ * 统一处理重复路由检测、hash 变化、重定向、守卫和导航完成。
487
+ * 被 navigate 和 handleNotFound(onNotFound 返回 RouteLocation 时)共用。
488
+ *
489
+ * @param context - 导航上下文
490
+ * @returns 导航结果
491
+ */
492
+ async processNavigation(context) {
493
+ // 首次导航后才需检测重复和 hash 变化(首次导航无 from,不存在重复场景)
494
+ if (context.from.matched.length) {
495
+ // 重复路由:目标与当前路由完全一致,直接返回
496
+ const duplicatedResult = this.handleDuplicatedRoute(context);
497
+ if (duplicatedResult)
498
+ return duplicatedResult;
499
+ // 仅 hash 变化:路径和参数不变,仅 hash 不同,走轻量处理
500
+ const hashOnlyResult = this.handleHashOnlyChange(context);
501
+ if (hashOnlyResult)
502
+ return hashOnlyResult;
503
+ }
504
+ // 重定向:路由配置了 redirect,递归导航到新目标
505
+ const redirectResult = await this.handleRedirect(context);
506
+ if (redirectResult)
507
+ return redirectResult;
508
+ // 守卫流程:执行 beforeEach 和 beforeEnter,可能中止导航
509
+ const guardResult = await this.executeGuards(context);
510
+ if (guardResult)
511
+ return guardResult;
512
+ // 守卫通过,完成导航:更新状态、触发 afterEach、渲染组件
513
+ return this.finalizeNavigation(context);
514
+ }
473
515
  /**
474
516
  * 处理路由未匹配(404)场景
475
517
  *
476
518
  * 当目标路由无法匹配时执行:
477
519
  * 1. 触发全局 onNotFound 钩子
478
- * 2. 如果钩子返回了新的导航目标,进行重定向
479
- * 3. 否则返回 notfound 状态
520
+ * 2. 如果钩子返回了 RouteLocation,将其设为导航目标继续正常导航流程
521
+ * 3. 如果钩子返回了新的导航目标(NavTarget),进行重定向
522
+ * 4. 否则返回 notfound 状态
480
523
  *
481
524
  * @param context - 导航上下文
482
525
  * @param target - 原始导航目标
483
526
  * @returns 导航结果
484
527
  */
485
- handleNotFound(context, target) {
528
+ async handleNotFound(context, target) {
486
529
  const notFoundResult = this.runNotFoundHook(target);
487
- // 钩子返回了新的导航目标,进行重定向
488
530
  if (notFoundResult) {
531
+ // 钩子返回了 RouteLocation,将其设为导航目标,继续正常导航流程
532
+ if (isRouteLocation(notFoundResult)) {
533
+ context.to = notFoundResult;
534
+ context.result.to = notFoundResult;
535
+ return this.processNavigation(context);
536
+ }
537
+ // 钩子返回了 NavTarget,进行重定向
489
538
  context.checkRedirectLoop(String(notFoundResult.index));
490
539
  return this.navigate(notFoundResult, context.from);
491
540
  }
@@ -677,31 +726,12 @@ export class Router {
677
726
  async navigate(target, fromRoute, redirectFrom) {
678
727
  // 创建导航上下文
679
728
  const context = this.createNavigationContext(target, fromRoute, redirectFrom);
680
- // 场景1: 路由未匹配 (404)
729
+ // 路由未匹配 (404)
681
730
  if (!context.to) {
682
731
  return this.handleNotFound(context, target);
683
732
  }
684
- // 仅在路由第一次路由后检测重复路由
685
- if (context.from.matched.length) {
686
- // 场景2: 重复路由
687
- const duplicatedResult = this.handleDuplicatedRoute(context);
688
- if (duplicatedResult)
689
- return duplicatedResult;
690
- // 场景3: 仅hash变化
691
- const hashOnlyResult = this.handleHashOnlyChange(context);
692
- if (hashOnlyResult)
693
- return hashOnlyResult;
694
- }
695
- // 场景4: 路由重定向
696
- const redirectResult = await this.handleRedirect(context);
697
- if (redirectResult)
698
- return redirectResult;
699
- // 场景5: 执行守卫流程
700
- const guardResult = await this.executeGuards(context);
701
- if (guardResult)
702
- return guardResult;
703
- // 场景6: 完成导航
704
- return this.finalizeNavigation(context);
733
+ // 处理导航
734
+ return this.processNavigation(context);
705
735
  }
706
736
  // ============================================================
707
737
  // 导航完成与滚动行为
@@ -769,6 +799,16 @@ export class Router {
769
799
  // ============================================================
770
800
  /**
771
801
  * 处理 404 错误
802
+ *
803
+ * 执行 onNotFound 钩子链,按注册顺序依次调用,
804
+ * 第一个返回有效值的钩子会终止遍历。
805
+ *
806
+ * 返回值类型:
807
+ * - RouteLocation: 作为未匹配路由的位置对象(优先判断,因为结构更具体)
808
+ * - NavTarget: 重定向到新目标
809
+ * - string | symbol: 包装为 NavTarget 后重定向
810
+ * - void: 继续执行下一个钩子
811
+ *
772
812
  * @param target - 导航目标对象
773
813
  * @returns - 返回处理结果
774
814
  */
@@ -778,8 +818,13 @@ export class Router {
778
818
  for (const hook of this._hooks.onNotFound) {
779
819
  try {
780
820
  const result = hook.call(this, target);
821
+ // 优先判断 RouteLocation(有 matched 和 path 属性)
822
+ if (isRouteLocation(result))
823
+ return result;
824
+ // 判断 NavTarget(有 index 属性)
781
825
  if (hasValidNavTarget(result))
782
826
  return result;
827
+ // 字符串或 symbol 包装为 NavTarget
783
828
  if (isString(result) || typeof result === 'symbol') {
784
829
  return {
785
830
  index: result
@@ -928,25 +973,6 @@ export class Router {
928
973
  // ============================================================
929
974
  // 路由匹配与URL构建
930
975
  // ============================================================
931
- /**
932
- * 创建缺失的路由
933
- * @param component - 路由组件
934
- * @param path - 路径
935
- * @param query - 查询参数
936
- * @param hash - 哈希值
937
- * @returns {RouteLocation} - 返回创建的路由位置对象
938
- */
939
- createMissingRoute(component, path, query = {}, hash = '') {
940
- return {
941
- href: this.buildUrl(path, query, hash),
942
- path,
943
- hash,
944
- params: {},
945
- query,
946
- meta: {},
947
- matched: [{ path, isGroup: false, component: { default: component } }]
948
- };
949
- }
950
976
  /**
951
977
  * 构建完整URL路径
952
978
  *
@@ -965,7 +991,7 @@ export class Router {
965
991
  const queryStr = stringifyQuery(query);
966
992
  const href = `${path}${queryStr}${hashStr}`;
967
993
  return this.config.mode === 'hash'
968
- ? normalizePath(`${this.config.base}/#${href}`, true)
994
+ ? normalizePath(`${this.config.base}#${href}`)
969
995
  : normalizePath(`${this.config.base}${href}`);
970
996
  }
971
997
  /**
@@ -977,7 +1003,7 @@ export class Router {
977
1003
  */
978
1004
  matchRoute(target, redirectFrom) {
979
1005
  let matchTarget = target.index;
980
- const isPath = hasValidPath(matchTarget);
1006
+ const isPath = isValidPath(matchTarget);
981
1007
  // 如果配置了后缀且目标是路径,则去除后缀
982
1008
  if (this.config.suffix && isPath) {
983
1009
  // 去除路径后缀
@@ -987,9 +1013,6 @@ export class Router {
987
1013
  let match;
988
1014
  if (isPath) {
989
1015
  match = this.manager.matchByPath(matchTarget);
990
- if (!match && this.config.missing) {
991
- return this.createMissingRoute(this.config.missing, matchTarget, target.query, target.hash);
992
- }
993
1016
  }
994
1017
  else {
995
1018
  match = this.manager.matchByName(matchTarget, target.params);
@@ -1,5 +1,5 @@
1
1
  import { computed, isPlainObject, isString, logger } from 'vitarx';
2
- import { hasValidNavTarget, hasValidPath } from '../common/utils.js';
2
+ import { hasValidNavTarget, isValidPath } from '../common/utils.js';
3
3
  import { useRouter } from './inject.js';
4
4
  import { cloneRouteLocation, parseQuery } from './utils.js';
5
5
  /**
@@ -104,7 +104,7 @@ export function useLink(props) {
104
104
  if (route.value?.href) {
105
105
  return route.value.href;
106
106
  }
107
- if (isPlainObject(props.to) && hasValidPath(props.to.index)) {
107
+ if (isPlainObject(props.to) && isValidPath(props.to.index)) {
108
108
  return props.to.index;
109
109
  }
110
110
  if (isString(props.to)) {
@@ -130,7 +130,7 @@ export function useLink(props) {
130
130
  const matchedRoute = route.value;
131
131
  if (!matchedRoute)
132
132
  return false;
133
- return router.route.href === matchedRoute.href;
133
+ return router.route.path === matchedRoute.path;
134
134
  });
135
135
  /**
136
136
  * 导航处理函数
@@ -1,5 +1,5 @@
1
1
  import { type DeepReadonly } from 'vitarx';
2
- import type { RouteLocation } from '../types/index.js';
2
+ import type { NotFoundTarget, RouteLocation, RouteMetaData, RouteViewComponent } from '../types/index.js';
3
3
  /**
4
4
  * 将 query 字符串转为对象
5
5
  *
@@ -14,22 +14,34 @@ export declare function parseQuery(queryString: string): Record<string, string>;
14
14
  * @return {string} 转换后的查询字符串(如 ?key1=value1&key2=value2)
15
15
  */
16
16
  export declare function stringifyQuery(obj: Record<string, string>): `?${string}` | '';
17
+ /**
18
+ * 去除路径末尾的斜杠
19
+ *
20
+ * @example
21
+ * removePathEndSlash('/foo/') // '/foo'
22
+ * removePathEndSlash('/foo') // '/foo'
23
+ *
24
+ * @param {string} str - 路径字符串
25
+ * @return {string} - 去除末尾斜杠后的路径字符串
26
+ */
27
+ export declare function removePathEndSlash<T extends string>(str: T): T;
17
28
  /**
18
29
  * 归一化path
19
30
  *
20
- * 去除所有空格、替换重复的斜杠、去除尾部的斜杠
31
+ * 去除所有空格、替换重复的斜杠
21
32
  *
22
33
  * @example
23
34
  * normalizePath('/ foo') // '/foo'
24
- * normalizePath('/foo/') // '/foo'
35
+ * normalizePath('/foo/') // '/foo/'
25
36
  * normalizePath('/foo/bar') // '/foo/bar'
26
37
  * normalizePath('foo/') // '/foo'
38
+ * normalizePath('/foo/',true) // '/foo'
27
39
  *
28
40
  * @param {string} path - 路径字符串
29
- * @param [hashMode = false] - 是否为hash模式,如果是则兼容`/#/`
41
+ * @param {boolean} removeEndSlash - 是否去除尾随斜杠
30
42
  * @return {string} - 格式化后的路径字符串
31
43
  */
32
- export declare function normalizePath(path: string, hashMode?: boolean): `/${string}`;
44
+ export declare function normalizePath(path: string, removeEndSlash?: boolean): `/${string}`;
33
45
  /**
34
46
  * 克隆路由位置对象
35
47
  *
@@ -37,3 +49,27 @@ export declare function normalizePath(path: string, hashMode?: boolean): `/${str
37
49
  * @return {RouteLocation} - 克隆过后的对象
38
50
  */
39
51
  export declare function cloneRouteLocation(route: RouteLocation | DeepReadonly<RouteLocation>): RouteLocation;
52
+ /**
53
+ * 创建未匹配路由的 RouteLocation 对象
54
+ *
55
+ * 用于在 onNotFound 钩子中快速创建一个可渲染的 RouteLocation,
56
+ * 使路由匹配失败时仍能渲染指定组件(如 404 页面)。
57
+ *
58
+ * 该函数要求 target.index 必须为路径(以 `/` 开头),
59
+ * 因为名称导航(name-based)匹配失败属于编程错误,应直接抛出异常,
60
+ * 而非创建伪路由位置。
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * // 在 onNotFound 钩子中使用
65
+ * onNotFound(target) {
66
+ * return createMissingRoute(NotFoundPage, target, { title: '页面未找到' })
67
+ * }
68
+ * ```
69
+ *
70
+ * @param component - 未匹配时要渲染的组件
71
+ * @param target - 用户的原始导航意图(index 必须为路径)
72
+ * @param meta - 可选的自定义 meta 信息,默认为空对象
73
+ * @returns {RouteLocation} 可在 onNotFound 钩子中直接返回的 RouteLocation
74
+ */
75
+ export declare function createMissingRoute(component: RouteViewComponent, target: NotFoundTarget, meta?: RouteMetaData): RouteLocation;
@@ -25,27 +25,45 @@ export function stringifyQuery(obj) {
25
25
  // 如果对象为空或没有任何有效查询参数,返回空字符串
26
26
  return queryString ? `?${queryString}` : '';
27
27
  }
28
+ /**
29
+ * 去除路径末尾的斜杠
30
+ *
31
+ * @example
32
+ * removePathEndSlash('/foo/') // '/foo'
33
+ * removePathEndSlash('/foo') // '/foo'
34
+ *
35
+ * @param {string} str - 路径字符串
36
+ * @return {string} - 去除末尾斜杠后的路径字符串
37
+ */
38
+ export function removePathEndSlash(str) {
39
+ if (str === '/')
40
+ return str;
41
+ return str.endsWith('/') ? str.slice(0, -1) : str;
42
+ }
28
43
  /**
29
44
  * 归一化path
30
45
  *
31
- * 去除所有空格、替换重复的斜杠、去除尾部的斜杠
46
+ * 去除所有空格、替换重复的斜杠
32
47
  *
33
48
  * @example
34
49
  * normalizePath('/ foo') // '/foo'
35
- * normalizePath('/foo/') // '/foo'
50
+ * normalizePath('/foo/') // '/foo/'
36
51
  * normalizePath('/foo/bar') // '/foo/bar'
37
52
  * normalizePath('foo/') // '/foo'
53
+ * normalizePath('/foo/',true) // '/foo'
38
54
  *
39
55
  * @param {string} path - 路径字符串
40
- * @param [hashMode = false] - 是否为hash模式,如果是则兼容`/#/`
56
+ * @param {boolean} removeEndSlash - 是否去除尾随斜杠
41
57
  * @return {string} - 格式化后的路径字符串
42
58
  */
43
- export function normalizePath(path, hashMode = false) {
59
+ export function normalizePath(path, removeEndSlash = false) {
44
60
  // 去除所有空格 处理重复//
45
- const formated = `/${path.trim()}`.replace(/\s+/g, '').replace(/\/+/g, '/');
46
- if (formated === '/' || (hashMode && formated === '/#/'))
47
- return formated;
48
- return formated.replace(/\/$/, '');
61
+ let normalizedPath = `/${path.trim()}`.replace(/\s+/g, '').replace(/\/+/g, '/');
62
+ if (removeEndSlash) {
63
+ normalizedPath = removePathEndSlash(normalizedPath);
64
+ }
65
+ // 去除尾随斜杠
66
+ return normalizedPath;
49
67
  }
50
68
  /**
51
69
  * 克隆路由位置对象
@@ -57,3 +75,41 @@ export function cloneRouteLocation(route) {
57
75
  const { matched, ...rest } = toRaw(route);
58
76
  return Object.assign(deepClone(rest), { matched: Array.from(matched) });
59
77
  }
78
+ /**
79
+ * 创建未匹配路由的 RouteLocation 对象
80
+ *
81
+ * 用于在 onNotFound 钩子中快速创建一个可渲染的 RouteLocation,
82
+ * 使路由匹配失败时仍能渲染指定组件(如 404 页面)。
83
+ *
84
+ * 该函数要求 target.index 必须为路径(以 `/` 开头),
85
+ * 因为名称导航(name-based)匹配失败属于编程错误,应直接抛出异常,
86
+ * 而非创建伪路由位置。
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // 在 onNotFound 钩子中使用
91
+ * onNotFound(target) {
92
+ * return createMissingRoute(NotFoundPage, target, { title: '页面未找到' })
93
+ * }
94
+ * ```
95
+ *
96
+ * @param component - 未匹配时要渲染的组件
97
+ * @param target - 用户的原始导航意图(index 必须为路径)
98
+ * @param meta - 可选的自定义 meta 信息,默认为空对象
99
+ * @returns {RouteLocation} 可在 onNotFound 钩子中直接返回的 RouteLocation
100
+ */
101
+ export function createMissingRoute(component, target, meta) {
102
+ const path = target.index;
103
+ const query = target.query ?? {};
104
+ const hash = target.hash ?? '';
105
+ const params = target.params ?? {};
106
+ return {
107
+ href: path,
108
+ path,
109
+ hash,
110
+ params,
111
+ query,
112
+ meta: meta || {},
113
+ matched: [{ path, isGroup: false, component: { default: component } }]
114
+ };
115
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Router } from '../router/router.js';
2
2
  import type { NavTarget, RouteLocation } from './navigation.js';
3
- import type { RouteIndex } from './route.js';
3
+ import type { RouteIndex, RoutePath } from './route.js';
4
4
  /**
5
5
  * 守卫结果
6
6
  *
@@ -32,17 +32,26 @@ export type NavigationGuard = (this: Router, to: RouteLocation, from: RouteLocat
32
32
  * @param from - 上一个路由对象
33
33
  */
34
34
  export type AfterCallback = (this: Router, to: RouteLocation, from: RouteLocation) => void;
35
+ /**
36
+ * 未匹配路由目标类型
37
+ */
38
+ export type NotFoundTarget = NavTarget<RoutePath>;
35
39
  /**
36
40
  * 路由未匹配钩子
37
41
  *
38
42
  * 触发时机: 路由匹配失败 (404) 时。
39
- * 用途: 可用于统一跳转 404 页面或记录错误日志。
43
+ * 用途: 可用于统一跳转 404 页面、渲染 404 组件或记录错误日志。
44
+ *
45
+ * 返回值说明:
46
+ * - NavTarget | RouteIndex: 重定向到新目标
47
+ * - RouteLocation: 作为未匹配路由的位置对象(可配合 createMissingRoute 使用)
48
+ * - void: 不处理,返回 notfound 状态
40
49
  *
41
50
  * @param this - 路由器实例
42
51
  * @param target - 用户的原始导航意图
43
- * @returns {NavTarget | RouteIndex | void} 返回新目标表示重定向,无返回值则抛出错误
52
+ * @returns {NavTarget | RouteIndex | RouteLocation | void} 返回新目标表示重定向,返回 RouteLocation 表示渲染指定组件,无返回值则返回 notfound 状态
44
53
  */
45
- export type NotFoundHandler = (this: Router, target: NavTarget) => NavTarget | RouteIndex | void;
54
+ export type NotFoundHandler = (this: Router, target: NotFoundTarget) => NavTarget | RouteIndex | RouteLocation | void;
46
55
  /**
47
56
  * 路由错误处理钩子
48
57
  *
@@ -1,7 +1,7 @@
1
1
  import type { MakeRequired } from 'vitarx';
2
2
  import type { RouteManager } from '../router/manager.js';
3
3
  import type { AfterCallback, NavErrorListener, NavigationGuard, NotFoundHandler } from './hooks.js';
4
- import type { InjectPropsHandler, Route, RouteViewComponent } from './route.js';
4
+ import type { InjectPropsHandler, Route } from './route.js';
5
5
  import type { BeforeScrollCallback } from './scroll.js';
6
6
  /**
7
7
  * 定义路由器配置选项接口
@@ -87,14 +87,5 @@ export interface RouterOptions {
87
87
  * @param from - 源路由对象
88
88
  */
89
89
  onError?: NavErrorListener | NavErrorListener[];
90
- /**
91
- * 未匹配到路由时要渲染的组件
92
- *
93
- * 如果你需要在未匹配到路由时重定向到指定的页面,则不应该使用`missing`选项,
94
- * 而是应该使用 `onNotFound` 钩子指定重定向目标。
95
- *
96
- * > 注意:如果你设置了`missing`选项,`path` 导航不匹配时也会更新`URL`地址,然后渲染`missing`组件。
97
- */
98
- missing?: RouteViewComponent;
99
90
  }
100
91
  export type ResolvedRouterConfig = MakeRequired<Omit<RouterOptions, 'routes' | 'beforeEach' | 'afterEach' | 'onError' | 'onNotFound'>, 'mode' | 'base'>;
@@ -23,7 +23,11 @@ function extractAstLiteralValue(node) {
23
23
  for (const prop of node.properties) {
24
24
  if (prop.type !== 'ObjectProperty')
25
25
  continue;
26
- const key = prop.key.type === 'Identifier' ? prop.key.name : null;
26
+ const key = prop.key.type === 'Identifier'
27
+ ? prop.key.name
28
+ : prop.key.type === 'StringLiteral'
29
+ ? prop.key.value
30
+ : null;
27
31
  if (key) {
28
32
  obj[key] = extractAstLiteralValue(prop.value);
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitarx-router",
3
- "version": "4.0.0-beta.21",
3
+ "version": "4.0.0-beta.23",
4
4
  "description": "Official routing solution for Vitarx framework with declarative routing, navigation guards, dynamic routes, file-based routing with HMR, and full TypeScript support.",
5
5
  "author": "ZhuChonglin <8210856@qq.com>",
6
6
  "license": "MIT",