vitarx-router 4.0.0-beta.24 → 4.0.0-beta.26

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
@@ -291,6 +291,7 @@ if (hasSuccess(result)) {
291
291
  | `cancelled` | 4 | 导航被新导航取消 |
292
292
  | `duplicated` | 8 | 重复导航 |
293
293
  | `notfound` | 16 | 路由未匹配 |
294
+ | `external` | 32 | 外部跳转 |
294
295
 
295
296
  ## 路由未匹配处理
296
297
 
@@ -536,20 +537,31 @@ declare module 'vitarx-router' {
536
537
 
537
538
  ### 助手函数
538
539
 
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)` | 删除路径末尾的斜杠 |
540
+ | 函数 | 说明 |
541
+ |------------------------------------------------|---------------------------|
542
+ | `createRouter(options)` | 创建路由器实例 |
543
+ | `createWebRouter(options)` | 创建 Web 模式路由器 |
544
+ | `createMemoryRouter(options)` | 创建 Memory 模式路由器 |
545
+ | `createRouteManager(routes, options?)` | 创建路由管理器 |
546
+ | `defineRoutes(...routes)` | 定义路由表 |
547
+ | `createMissingRoute(component, target, meta?)` | 创建未匹配路由的 RouteLocation |
548
+ | `cloneRouteLocation(location)` | 克隆 RouteLocation |
549
+ | `useRouter()` | 获取路由器实例 |
550
+ | `useRoute(global?)` | 获取当前路由信息 |
551
+ | `useLink(options)` | 创建链接助手 |
552
+ | `onBeforeRouteLeave(guard)` | 注册离开守卫 |
553
+ | `onBeforeRouteUpdate(callback)` | 注册更新钩子 |
554
+ | `removeTrailingSlash(path)` | 删除路径末尾的斜杠 |
555
+ | `normalizePath(path, removeTrailingSlash?)` | 规范化路径 |
556
+ | `parseQuery(queryString)` | 解析查询字符串 |
557
+ | `stringifyQuery(query)` | 序列化查询对象 |
558
+ | `isNavTarget(value)` | 检查一个值是否为导航目标对象 |
559
+ | `isNavIndex(value)` | 检查一个值是否为有效的导航索引 |
560
+ | `isRouteLocation(value)` | 检查一个值是否为 RouteLocation 对象 |
561
+ | `isRoutePath(index)` | 检查一个值是否为有效的路由路径 |
562
+ | `isExternalLink(href)` | 检查一个值是否为外部链接 |
563
+ | `isPathExactMatch(currentPath, targetPath)` | 判断路径是否完全匹配 |
564
+ | `isPathPrefixMatch(currentPath, targetPath)` | 判断路径是否前缀匹配 |
553
565
 
554
566
  ### Router 实例方法
555
567
 
@@ -1,11 +1,11 @@
1
- import { ElementView, type ValidChildren, type WithProps } from 'vitarx';
1
+ import { type CodeLocation, ElementView, type RenderChildren, type WithProps } from 'vitarx';
2
2
  import { type NavigateResult } from '../core/index.js';
3
3
  import { type UseLinkOptions } from '../core/shared/index.js';
4
4
  export interface RouterLinkProps extends UseLinkOptions, WithProps<'a'> {
5
5
  /**
6
6
  * 子节点插槽
7
7
  */
8
- children?: ValidChildren;
8
+ children?: RenderChildren;
9
9
  /**
10
10
  * 是否禁用
11
11
  *
@@ -26,10 +26,12 @@ export interface RouterLinkProps extends UseLinkOptions, WithProps<'a'> {
26
26
  * 当链接完全匹配时应用的类名
27
27
  */
28
28
  exactActiveClass?: string;
29
+ /**
30
+ * 当链接禁用时应用的类名
31
+ */
32
+ disabledClass?: string;
29
33
  /**
30
34
  * Value passed to the attribute `aria-current` when the link is exact active.
31
- *
32
- * @defaultValue `'page'`
33
35
  */
34
36
  ariaCurrentValue?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false';
35
37
  }
@@ -40,6 +42,7 @@ export interface RouterLinkProps extends UseLinkOptions, WithProps<'a'> {
40
42
  * 动态设置激活状态类名、`href` 属性以及 `aria-current` 无障碍属性。
41
43
  *
42
44
  * @param {RouterLinkProps} props - 组件属性
45
+ * @param {CodeLocation} [location] - 代码位置信息
43
46
  * @returns {ElementView<'a'>} 返回一个锚点元素视图
44
47
  *
45
48
  * @example
@@ -54,6 +57,8 @@ export interface RouterLinkProps extends UseLinkOptions, WithProps<'a'> {
54
57
  * <RouterLink to="/about" disabled>关于我们</RouterLink>
55
58
  * // 透传属性
56
59
  * <RouterLink to="/about" class="nav-link">关于我们</RouterLink>
60
+ * // 带参数的导航
61
+ * <RouterLink to={{ index: '/user', query: { id: 123 } }}>用户信息</RouterLink>
57
62
  * ```
58
63
  */
59
- export declare function RouterLink(props: RouterLinkProps): ElementView<'a'>;
64
+ export declare function RouterLink(props: RouterLinkProps, location?: CodeLocation): ElementView<'a'>;
@@ -1,5 +1,19 @@
1
- import { createView, isFunction } from 'vitarx';
1
+ import { createView, isFunction, isPlainObject, logger } from 'vitarx';
2
+ import { isExternalLink } from '../core/index.js';
2
3
  import { useLink } from '../core/shared/index.js';
4
+ const EXTRA_PROPS = [
5
+ 'to',
6
+ 'replace',
7
+ 'viewTransition',
8
+ 'exactMatchMode',
9
+ 'disabled',
10
+ 'callback',
11
+ 'onclick',
12
+ 'activeClass',
13
+ 'exactActiveClass',
14
+ 'disabledClass',
15
+ 'ariaCurrentValue'
16
+ ];
3
17
  /**
4
18
  * 路由链接组件,渲染为一个 `<a>` 标签,用于在应用内进行声明式导航。
5
19
  *
@@ -7,6 +21,7 @@ import { useLink } from '../core/shared/index.js';
7
21
  * 动态设置激活状态类名、`href` 属性以及 `aria-current` 无障碍属性。
8
22
  *
9
23
  * @param {RouterLinkProps} props - 组件属性
24
+ * @param {CodeLocation} [location] - 代码位置信息
10
25
  * @returns {ElementView<'a'>} 返回一个锚点元素视图
11
26
  *
12
27
  * @example
@@ -21,10 +36,17 @@ import { useLink } from '../core/shared/index.js';
21
36
  * <RouterLink to="/about" disabled>关于我们</RouterLink>
22
37
  * // 透传属性
23
38
  * <RouterLink to="/about" class="nav-link">关于我们</RouterLink>
39
+ * // 带参数的导航
40
+ * <RouterLink to={{ index: '/user', query: { id: 123 } }}>用户信息</RouterLink>
24
41
  * ```
25
42
  */
26
- export function RouterLink(props) {
43
+ export function RouterLink(props, location) {
27
44
  const link = useLink(props);
45
+ if (__VITARX_DEV__) {
46
+ if (!link.route.value && !isExternalLink(link.href.value)) {
47
+ logger.warn(`[RouterLink] No match found for to: ${isPlainObject(props.to) ? JSON.stringify(props.to) : String(props.to)}`, location);
48
+ }
49
+ }
28
50
  const isDisabled = () => props.disabled ?? false;
29
51
  const navigate = async (e) => {
30
52
  if (isDisabled())
@@ -36,28 +58,26 @@ export function RouterLink(props) {
36
58
  const aProps = {
37
59
  onClick: navigate,
38
60
  children: props.children,
39
- 'v-bind': [
40
- props,
41
- ['to', 'children', 'href', 'disabled', 'callback', 'onClick', 'onclick', 'aria-current']
42
- ],
61
+ 'v-bind': [props, EXTRA_PROPS],
43
62
  get class() {
44
63
  return [
45
- !isDisabled() && link.isActive.value ? props.activeClass : undefined,
46
- !isDisabled() && link.isExactActive.value ? props.exactActiveClass : undefined
64
+ props.activeClass && link.isActive.value ? props.activeClass : undefined,
65
+ props.exactActiveClass && link.isExactActive.value ? props.exactActiveClass : undefined,
66
+ props.disabledClass && isDisabled() ? props.disabledClass : undefined
47
67
  ].filter(Boolean);
48
68
  },
49
69
  get href() {
50
70
  return link.href.value;
51
- },
52
- get draggable() {
53
- return props.draggable ?? false;
54
- },
55
- get 'aria-current'() {
56
- return link.isActive.value && !isDisabled() ? props.ariaCurrentValue || 'page' : undefined;
57
- },
58
- get disabled() {
59
- return isDisabled() ? '' : undefined;
60
71
  }
61
72
  };
73
+ if ('ariaCurrentValue' in props) {
74
+ Object.defineProperty(aProps, 'aria-current', {
75
+ enumerable: true,
76
+ configurable: true,
77
+ get() {
78
+ return link.isExactActive.value ? props.ariaCurrentValue : undefined;
79
+ }
80
+ });
81
+ }
62
82
  return createView('a', aProps);
63
83
  }
@@ -8,17 +8,18 @@ export interface RouterViewOptions {
8
8
  */
9
9
  name?: string;
10
10
  /**
11
- * 渲染页面组件
11
+ * 自定义渲染当前路由匹配到的组件
12
12
  *
13
- * 接收两个参数:
13
+ * 接收三个参数:
14
14
  * - `component: Computed<Component | null>`:当前要渲染的组件,
15
15
  * - `props: Computed<AnyProps | null>`:要注入给组件的属性对象
16
+ * - `route: Computed<RouteRecord | null>`:当前匹配的路由记录
16
17
  *
17
18
  * @example
18
19
  * ```jsx
19
20
  * // 搭配 Freeze 使用
20
21
  * <RouterView>
21
- * {(component, props, path) => <Freeze is={component} props={props}/>}
22
+ * {(component, props) => <Freeze is={component} props={props}/>}
22
23
  * </RouterView>
23
24
  * ```
24
25
  *
@@ -21,6 +21,7 @@ export function RouterView(props) {
21
21
  provide(__ROUTER_VIEW_DEPTH_KEY__, index); // 向子组件提供当前索引
22
22
  // 匹配的路由线路
23
23
  const viewName = computed(() => props.name || 'default');
24
+ // 匹配的路由记录
24
25
  const matchedRoute = computed(() => {
25
26
  return router.route.matched[index] ?? null;
26
27
  });
@@ -7,28 +7,15 @@
7
7
  * 4. cancelled: 导航被取消
8
8
  * 8. duplicated: 重复导航
9
9
  * 16. notfound: 路由未匹配
10
+ * 32. external: 外部链接(不由路由器处理,useLink navigate 内使用)
10
11
  */
11
12
  export declare enum NavState {
12
- /**
13
- * 导航成功 (二进制: 0001)
14
- */
15
- success = 1,// 1
16
- /**
17
- * 导航被阻止 (二进制: 0010)
18
- */
19
- aborted = 2,// 2
20
- /**
21
- * 导航被取消 (二进制: 0100)
22
- */
23
- cancelled = 4,// 4
24
- /**
25
- * 重复导航 (二进制: 1000)
26
- */
27
- duplicated = 8,// 8
28
- /**
29
- * 路由未匹配 (二进制: 10000)
30
- */
31
- notfound = 16
13
+ success = 1,// 1 - 导航成功
14
+ aborted = 2,// 2 - 导航被阻止
15
+ cancelled = 4,// 4 - 导航被取消
16
+ duplicated = 8,// 8 - 重复导航
17
+ notfound = 16,// 16 - 路由未匹配
18
+ external = 32
32
19
  }
33
20
  /**
34
21
  * 路由器注入键
@@ -7,29 +7,16 @@
7
7
  * 4. cancelled: 导航被取消
8
8
  * 8. duplicated: 重复导航
9
9
  * 16. notfound: 路由未匹配
10
+ * 32. external: 外部链接(不由路由器处理,useLink navigate 内使用)
10
11
  */
11
12
  export var NavState;
12
13
  (function (NavState) {
13
- /**
14
- * 导航成功 (二进制: 0001)
15
- */
16
14
  NavState[NavState["success"] = 1] = "success";
17
- /**
18
- * 导航被阻止 (二进制: 0010)
19
- */
20
15
  NavState[NavState["aborted"] = 2] = "aborted";
21
- /**
22
- * 导航被取消 (二进制: 0100)
23
- */
24
16
  NavState[NavState["cancelled"] = 4] = "cancelled";
25
- /**
26
- * 重复导航 (二进制: 1000)
27
- */
28
17
  NavState[NavState["duplicated"] = 8] = "duplicated";
29
- /**
30
- * 路由未匹配 (二进制: 10000)
31
- */
32
- NavState[NavState["notfound"] = 16] = "notfound"; // 16
18
+ NavState[NavState["notfound"] = 16] = "notfound";
19
+ NavState[NavState["external"] = 32] = "external"; // 32 - 外部链接(不由路由器处理)
33
20
  })(NavState || (NavState = {}));
34
21
  /**
35
22
  * 路由器注入键
@@ -1,12 +1,19 @@
1
1
  import { type AnyCallback } from 'vitarx';
2
2
  import type { GuardResult, NavTarget, RouteIndex, RouteLocation, RoutePath, URLHash, URLQuery } from '../types/index.js';
3
+ /**
4
+ * 判断是否为外部链接
5
+ *
6
+ * @param href - 链接地址
7
+ * @returns 是否为外部链接
8
+ */
9
+ export declare function isExternalLink(href: string): boolean;
3
10
  /**
4
11
  * 检查给定的值是否为一个合法的导航配置对象
5
12
  *
6
13
  * @param val 要检查的未知类型值
7
14
  * @returns {boolean} 如果值是一个导航目标对象则返回true,否则返回false
8
15
  */
9
- export declare function hasValidNavTarget(val: unknown): val is NavTarget;
16
+ export declare function isNavTarget(val: unknown): val is NavTarget;
10
17
  /**
11
18
  * 检查给定的值是否为 RouteLocation 对象
12
19
  *
@@ -24,7 +31,7 @@ export declare function isRouteLocation(val: unknown): val is RouteLocation;
24
31
  * @returns {boolean} 返回一个布尔值,表示值是否为有效的路由索引
25
32
  * 同时使用类型谓词(val is RouteIndex)来缩小类型范围
26
33
  */
27
- export declare function hasValidRouteIndex(val: unknown): val is RouteIndex;
34
+ export declare function isNavIndex(val: unknown): val is RouteIndex;
28
35
  /**
29
36
  * 判断两个路由位置对象是否只有 hash 不同
30
37
  *
@@ -38,7 +45,7 @@ export declare function hasOnlyChangeHash(route1: RouteLocation, route2: RouteLo
38
45
  * @param index - 要判断的索引
39
46
  * @returns {boolean} - 如果索引为路径索引则返回true,否则返回false
40
47
  */
41
- export declare function isValidPath(index: unknown): index is RoutePath;
48
+ export declare function isRoutePath(index: unknown): index is RoutePath;
42
49
  /**
43
50
  * 移除路径字符串中的指定后缀
44
51
  * @param path - 原始路径字符串
@@ -1,13 +1,22 @@
1
1
  import { isDeepEqual, isPlainObject, isString } from 'vitarx';
2
2
  import { normalizePath, parseQuery } from '../shared/utils.js';
3
+ /**
4
+ * 判断是否为外部链接
5
+ *
6
+ * @param href - 链接地址
7
+ * @returns 是否为外部链接
8
+ */
9
+ export function isExternalLink(href) {
10
+ return href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
11
+ }
3
12
  /**
4
13
  * 检查给定的值是否为一个合法的导航配置对象
5
14
  *
6
15
  * @param val 要检查的未知类型值
7
16
  * @returns {boolean} 如果值是一个导航目标对象则返回true,否则返回false
8
17
  */
9
- export function hasValidNavTarget(val) {
10
- return isPlainObject(val) && 'index' in val && hasValidRouteIndex(val.index);
18
+ export function isNavTarget(val) {
19
+ return isPlainObject(val) && 'index' in val && isNavIndex(val.index);
11
20
  }
12
21
  /**
13
22
  * 检查给定的值是否为 RouteLocation 对象
@@ -28,7 +37,7 @@ export function isRouteLocation(val) {
28
37
  * @returns {boolean} 返回一个布尔值,表示值是否为有效的路由索引
29
38
  * 同时使用类型谓词(val is RouteIndex)来缩小类型范围
30
39
  */
31
- export function hasValidRouteIndex(val) {
40
+ export function isNavIndex(val) {
32
41
  // 检查值是否为字符串类型或者是否为symbol类型
33
42
  return isString(val) || typeof val === 'symbol';
34
43
  }
@@ -40,7 +49,7 @@ export function hasValidRouteIndex(val) {
40
49
  */
41
50
  export function hasOnlyChangeHash(route1, route2) {
42
51
  return (route1.hash !== route2.hash &&
43
- route1.path === route2.path &&
52
+ route1.matched.at(-1) === route2.matched.at(-1) &&
44
53
  isDeepEqual(route1.query, route2.query));
45
54
  }
46
55
  /**
@@ -49,7 +58,7 @@ export function hasOnlyChangeHash(route1, route2) {
49
58
  * @param index - 要判断的索引
50
59
  * @returns {boolean} - 如果索引为路径索引则返回true,否则返回false
51
60
  */
52
- export function isValidPath(index) {
61
+ export function isRoutePath(index) {
53
62
  return isString(index) && index.startsWith('/');
54
63
  }
55
64
  /**
@@ -98,7 +107,7 @@ export function processGuardResult(res) {
98
107
  if ((res && isString(res)) || typeof res === 'symbol') {
99
108
  return { index: res };
100
109
  }
101
- if (hasValidNavTarget(res))
110
+ if (isNavTarget(res))
102
111
  return res;
103
112
  return true;
104
113
  }
@@ -111,7 +120,7 @@ export function resolveNavTarget(index) {
111
120
  if (isString(index) || typeof index === 'symbol') {
112
121
  return { index };
113
122
  }
114
- if (hasValidNavTarget(index)) {
123
+ if (isNavTarget(index)) {
115
124
  return index;
116
125
  }
117
126
  if (isPlainObject(index) && index.path && index.matched) {
@@ -1,4 +1,5 @@
1
1
  export * from './common/constant.js';
2
+ export { isNavTarget, isNavIndex, isRouteLocation, isRoutePath, isExternalLink } from './common/utils.js';
2
3
  export * from './router/index.js';
3
4
  export * from './shared/index.js';
4
5
  export type * from './types/index.js';
@@ -1,3 +1,4 @@
1
1
  export * from './common/constant.js';
2
+ export { isNavTarget, isNavIndex, isRouteLocation, isRoutePath, isExternalLink } from './common/utils.js';
2
3
  export * from './router/index.js';
3
4
  export * from './shared/index.js';
@@ -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, hasValidRouteIndex, isValidPath } from '../common/utils.js';
3
+ import { isNavIndex, isNavTarget, isRoutePath } 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
  /**
@@ -288,7 +288,7 @@ export class RouteManager {
288
288
  * @returns 匹配结果对象,包含路由记录和解析后的参数;未匹配返回 null
289
289
  */
290
290
  match(index, params) {
291
- if (isValidPath(index)) {
291
+ if (isRoutePath(index)) {
292
292
  return this.matchByPath(index);
293
293
  }
294
294
  return this.matchByName(index, params);
@@ -474,9 +474,7 @@ export class RouteManager {
474
474
  record.props = props;
475
475
  }
476
476
  if (route.redirect &&
477
- (isFunction(route.redirect) ||
478
- hasValidRouteIndex(route.redirect) ||
479
- hasValidNavTarget(route.redirect))) {
477
+ (isFunction(route.redirect) || isNavIndex(route.redirect) || isNavTarget(route.redirect))) {
480
478
  record.redirect = route.redirect;
481
479
  }
482
480
  if (!component && !route.redirect && !isGroup) {
@@ -132,8 +132,8 @@ export declare abstract class Router {
132
132
  /**
133
133
  * 判断路由器是否已准备就绪
134
134
  *
135
- * 返回一个 Promise,它会在路由器完成初始导航之后被解析,
136
- * 如果初始导航已经完成,则该 Promise 会被立刻解析。
135
+ * 返回一个 Promise,它会在路由器完成首次导航之后被解析,
136
+ * 如果首次导航已经完成,则该 Promise 会被立刻解析。
137
137
  *
138
138
  * @returns {Promise<void>} - 导航结果
139
139
  */
@@ -425,6 +425,7 @@ export declare abstract class Router {
425
425
  * 导航到指定位置
426
426
  *
427
427
  * 作为导航流程的编排器,按顺序协调各场景处理方法的执行:
428
+ * 0. 处理外部链接
428
429
  * 1. 创建导航上下文(路由匹配、并发控制初始化)
429
430
  * 2. 处理 404 场景(路由未匹配)
430
431
  * 3. 处理重复路由场景
@@ -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, hasValidRouteIndex, isRouteLocation, isValidPath, processGuardResult, registerHookTool, removePathSuffix, resolveNavTarget } from '../common/utils.js';
4
+ import { hasOnlyChangeHash, isExternalLink, isNavIndex, isNavTarget, isRouteLocation, isRoutePath, 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';
@@ -186,8 +186,8 @@ export class Router {
186
186
  /**
187
187
  * 判断路由器是否已准备就绪
188
188
  *
189
- * 返回一个 Promise,它会在路由器完成初始导航之后被解析,
190
- * 如果初始导航已经完成,则该 Promise 会被立刻解析。
189
+ * 返回一个 Promise,它会在路由器完成首次导航之后被解析,
190
+ * 如果首次导航已经完成,则该 Promise 会被立刻解析。
191
191
  *
192
192
  * @returns {Promise<void>} - 导航结果
193
193
  */
@@ -446,7 +446,7 @@ export class Router {
446
446
  const to = this.matchRoute(target, redirectFrom);
447
447
  // name-based 导航匹配失败视为编程错误,直接抛出异常
448
448
  // 因为名称导航是编程式调用,name 不存在或参数校验失败属于代码 bug
449
- if (!to && !isValidPath(target.index)) {
449
+ if (!to && !isRoutePath(target.index)) {
450
450
  const name = target.index;
451
451
  const route = this.manager.findByName(name);
452
452
  if (route) {
@@ -590,6 +590,8 @@ export class Router {
590
590
  if (!context.to)
591
591
  return null;
592
592
  if (hasOnlyChangeHash(context.to, context.from)) {
593
+ // 更新路由位置的 hash 值并触发 hashUpdate 回调
594
+ this._routeLocation.hash = context.to.hash;
593
595
  this._routeLocation.href = context.to.href;
594
596
  this.hashUpdate?.(context.to);
595
597
  context.result.state = NavState.success;
@@ -621,12 +623,12 @@ export class Router {
621
623
  if (!redirect)
622
624
  return null;
623
625
  // 重定向目标为路由索引(路径或名称)
624
- if (hasValidRouteIndex(redirect)) {
626
+ if (isNavIndex(redirect)) {
625
627
  context.checkRedirectLoop(String(redirect));
626
628
  return this.navigate({ index: redirect }, context.from, context.redirectFrom ?? to);
627
629
  }
628
630
  // 重定向目标为完整的导航目标对象
629
- if (hasValidNavTarget(redirect)) {
631
+ if (isNavTarget(redirect)) {
630
632
  context.checkRedirectLoop(String(redirect.index));
631
633
  return this.navigate(redirect, context.from, context.redirectFrom ?? to);
632
634
  }
@@ -696,7 +698,7 @@ export class Router {
696
698
  return context.result;
697
699
  }
698
700
  // 守卫重定向
699
- if (hasValidNavTarget(guardResult)) {
701
+ if (isNavTarget(guardResult)) {
700
702
  context.checkRedirectLoop(String(guardResult.index));
701
703
  return this.navigate(guardResult, context.from, context.redirectFrom ?? context.to ?? undefined);
702
704
  }
@@ -724,6 +726,7 @@ export class Router {
724
726
  * 导航到指定位置
725
727
  *
726
728
  * 作为导航流程的编排器,按顺序协调各场景处理方法的执行:
729
+ * 0. 处理外部链接
727
730
  * 1. 创建导航上下文(路由匹配、并发控制初始化)
728
731
  * 2. 处理 404 场景(路由未匹配)
729
732
  * 3. 处理重复路由场景
@@ -738,6 +741,14 @@ export class Router {
738
741
  * @returns - 返回导航结果
739
742
  */
740
743
  async navigate(target, fromRoute, redirectFrom) {
744
+ if (isString(target.index) && isExternalLink(target.index)) {
745
+ return {
746
+ state: NavState.external,
747
+ message: `Navigate to the external link ${target.index}`,
748
+ to: null,
749
+ from: fromRoute ?? cloneRouteLocation(this._routeLocation)
750
+ };
751
+ }
741
752
  // 创建导航上下文
742
753
  const context = this.createNavigationContext(target, fromRoute, redirectFrom);
743
754
  // 路由未匹配 (404)
@@ -836,7 +847,7 @@ export class Router {
836
847
  if (isRouteLocation(result))
837
848
  return result;
838
849
  // 判断 NavTarget(有 index 属性)
839
- if (hasValidNavTarget(result))
850
+ if (isNavTarget(result))
840
851
  return result;
841
852
  // 字符串或 symbol 包装为 NavTarget
842
853
  if (isString(result) || typeof result === 'symbol') {
@@ -1017,7 +1028,7 @@ export class Router {
1017
1028
  */
1018
1029
  matchRoute(target, redirectFrom) {
1019
1030
  let matchTarget = target.index;
1020
- const isPath = isValidPath(matchTarget);
1031
+ const isPath = isRoutePath(matchTarget);
1021
1032
  // 如果配置了后缀且目标是路径,则去除后缀
1022
1033
  if (this.config.suffix && isPath) {
1023
1034
  // 去除路径后缀
@@ -140,9 +140,14 @@ export class WebRouter extends Router {
140
140
  if ('el' in target && target.el) {
141
141
  const { el, ...rest } = target;
142
142
  if (isString(el)) {
143
- // 对选择器字符串进行转义
144
- const escapedSelector = el.replace(/([!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, '\\$1');
145
- const element = document.querySelector(escapedSelector);
143
+ let element = null;
144
+ try {
145
+ element = document.querySelector(el);
146
+ }
147
+ catch (e) {
148
+ logger.warn(`[Router] Invalid selector "${el}", skipping scroll to element`, e);
149
+ return;
150
+ }
146
151
  if (element) {
147
152
  if (element.scrollIntoView) {
148
153
  element.scrollIntoView(rest);
@@ -1,7 +1,7 @@
1
1
  import { type Computed } from 'vitarx';
2
- import type { NavigateResult, NavTarget, RouteIndex, RouteLocation, RoutePath, URLHash } from '../types/index.js';
3
- export type HTTPUrl = `http://${string}` | `https://${string}`;
4
- type LinkToTarget<T extends RouteIndex = RouteIndex> = NavTarget<T> | T | `${RoutePath}?${string}` | URLHash | HTTPUrl;
2
+ import type { NavigateResult, NavTarget, RouteIndex, RouteLocation } from '../types/index.js';
3
+ type LinkToTarget<T extends RouteIndex = RouteIndex> = NavTarget<T> | T | string;
4
+ export type LinkExactMatchMode = 'path' | 'href' | 'hash' | 'query';
5
5
  export interface UseLinkOptions<T extends RouteIndex = RouteIndex> {
6
6
  /**
7
7
  * 要跳转的目标
@@ -23,6 +23,17 @@ export interface UseLinkOptions<T extends RouteIndex = RouteIndex> {
23
23
  * @default false
24
24
  */
25
25
  viewTransition?: boolean;
26
+ /**
27
+ * 精确匹配模式
28
+ *
29
+ * - 'path':精确匹配路径
30
+ * - 'href':精确匹配完整链接
31
+ * - 'hash':精确匹配路径和锚点
32
+ * - 'query':精确匹配路径和查询参数
33
+ *
34
+ * @default 'path'
35
+ */
36
+ exactMatchMode?: LinkExactMatchMode;
26
37
  }
27
38
  export interface UseLinkReturn {
28
39
  /**
@@ -46,15 +57,40 @@ export interface UseLinkReturn {
46
57
  *
47
58
  * @param [e] 点击事件
48
59
  */
49
- navigate: (e?: MouseEvent) => Promise<NavigateResult | void>;
60
+ navigate: (e?: MouseEvent) => Promise<NavigateResult>;
50
61
  }
51
62
  /**
52
- * 判断是否为外部链接
63
+ * 判断当前路径是否以目标路径为前缀(路径段级别匹配)
64
+ *
65
+ * 去除尾部斜杠后,判断 currentPath 是否等于 targetPath 或以 targetPath/ 开头,
66
+ * 避免如 `/users-admin` 错误匹配 `/users` 的问题。
67
+ *
68
+ * @example
69
+ * isPathPrefixMatch('/users/123', '/users') // true
70
+ * isPathPrefixMatch('/users', '/users') // true
71
+ * isPathPrefixMatch('/users-admin', '/users') // false
72
+ * isPathPrefixMatch('/', '/') // true
73
+ *
74
+ * @param currentPath - 当前路由路径
75
+ * @param targetPath - 目标路由路径
76
+ * @returns 是否为前缀匹配
77
+ */
78
+ export declare function isPathPrefixMatch(currentPath: string, targetPath: string): boolean;
79
+ /**
80
+ * 判断当前路径是否与目标路径完全匹配
81
+ *
82
+ * 去除尾部斜杠后进行严格相等比较。
83
+ *
84
+ * @example
85
+ * isPathExactMatch('/users', '/users') // true
86
+ * isPathExactMatch('/users/', '/users') // true
87
+ * isPathExactMatch('/users/123', '/users') // false
53
88
  *
54
- * @param href - 链接地址
55
- * @returns 是否为外部链接
89
+ * @param currentPath - 当前路由路径
90
+ * @param targetPath - 目标路由路径
91
+ * @returns 是否为精确匹配
56
92
  */
57
- export declare function isExternalLink(href: string): boolean;
93
+ export declare function isPathExactMatch(currentPath: string, targetPath: string): boolean;
58
94
  /**
59
95
  * 创建一个链接助手,用于处理路由导航、生成链接属性及判断激活状态。
60
96
  *
@@ -63,6 +99,7 @@ export declare function isExternalLink(href: string): boolean;
63
99
  * @param props.to - 要跳转的目标,可以是路由目标对象、路由索引、带查询参数的路径字符串、哈希值或 HTTP/HTTPS 链接。
64
100
  * @param [props.replace] - 是否使用 `router.replace()` 而不是 `router.push()`。优先级低于 `to.replace`。默认为 `false`。
65
101
  * @param [props.viewTransition] - 如果支持则使用 `document.startViewTransition()` 进行视图过渡。默认为 `false`。
102
+ * @param [props.exactMatchMode] - 精确匹配模式。可选值有:'path'、'href'、'hash'、'query'。默认为 'path'。
66
103
  * @returns 返回一个包含链接属性和导航方法的对象。
67
104
  * @returns {Computed<string>} returns.href - 链接的 `href` 属性值。
68
105
  * @returns {Computed<RouteLocation | null>} returns.route - 匹配的路由信息,如果未匹配则返回 `null`。
@@ -1,27 +1,59 @@
1
- import { computed, isPlainObject, isString, logger } from 'vitarx';
2
- import { hasValidNavTarget, isValidPath } from '../common/utils.js';
1
+ import { computed, isPlainObject, isString } from 'vitarx';
2
+ import { NavState } from '../common/constant.js';
3
+ import { isExternalLink, isNavTarget, isRoutePath } from '../common/utils.js';
3
4
  import { useRouter } from './inject.js';
4
- import { cloneRouteLocation, parseQuery } from './utils.js';
5
+ import { cloneRouteLocation, parseQuery, removeTrailingSlash, stringifyQuery } from './utils.js';
5
6
  /**
6
7
  * 处理视图转换
7
8
  * @param callback
8
9
  */
9
- const handleTransition = async (callback) => {
10
+ async function handleTransition(callback) {
10
11
  if (typeof document === 'undefined' || typeof document.startViewTransition !== 'function') {
11
12
  await callback();
12
13
  return;
13
14
  }
14
15
  const transition = document.startViewTransition(callback);
15
16
  await transition.finished;
16
- };
17
+ }
18
+ /**
19
+ * 判断当前路径是否以目标路径为前缀(路径段级别匹配)
20
+ *
21
+ * 去除尾部斜杠后,判断 currentPath 是否等于 targetPath 或以 targetPath/ 开头,
22
+ * 避免如 `/users-admin` 错误匹配 `/users` 的问题。
23
+ *
24
+ * @example
25
+ * isPathPrefixMatch('/users/123', '/users') // true
26
+ * isPathPrefixMatch('/users', '/users') // true
27
+ * isPathPrefixMatch('/users-admin', '/users') // false
28
+ * isPathPrefixMatch('/', '/') // true
29
+ *
30
+ * @param currentPath - 当前路由路径
31
+ * @param targetPath - 目标路由路径
32
+ * @returns 是否为前缀匹配
33
+ */
34
+ export function isPathPrefixMatch(currentPath, targetPath) {
35
+ const current = removeTrailingSlash(currentPath);
36
+ const target = removeTrailingSlash(targetPath);
37
+ if (target === '/')
38
+ return current.startsWith('/');
39
+ return current === target || current.startsWith(target + '/');
40
+ }
17
41
  /**
18
- * 判断是否为外部链接
42
+ * 判断当前路径是否与目标路径完全匹配
43
+ *
44
+ * 去除尾部斜杠后进行严格相等比较。
19
45
  *
20
- * @param href - 链接地址
21
- * @returns 是否为外部链接
46
+ * @example
47
+ * isPathExactMatch('/users', '/users') // true
48
+ * isPathExactMatch('/users/', '/users') // true
49
+ * isPathExactMatch('/users/123', '/users') // false
50
+ *
51
+ * @param currentPath - 当前路由路径
52
+ * @param targetPath - 目标路由路径
53
+ * @returns 是否为精确匹配
22
54
  */
23
- export function isExternalLink(href) {
24
- return href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
55
+ export function isPathExactMatch(currentPath, targetPath) {
56
+ return removeTrailingSlash(currentPath) === removeTrailingSlash(targetPath);
25
57
  }
26
58
  /**
27
59
  * 创建一个链接助手,用于处理路由导航、生成链接属性及判断激活状态。
@@ -31,6 +63,7 @@ export function isExternalLink(href) {
31
63
  * @param props.to - 要跳转的目标,可以是路由目标对象、路由索引、带查询参数的路径字符串、哈希值或 HTTP/HTTPS 链接。
32
64
  * @param [props.replace] - 是否使用 `router.replace()` 而不是 `router.push()`。优先级低于 `to.replace`。默认为 `false`。
33
65
  * @param [props.viewTransition] - 如果支持则使用 `document.startViewTransition()` 进行视图过渡。默认为 `false`。
66
+ * @param [props.exactMatchMode] - 精确匹配模式。可选值有:'path'、'href'、'hash'、'query'。默认为 'path'。
34
67
  * @returns 返回一个包含链接属性和导航方法的对象。
35
68
  * @returns {Computed<string>} returns.href - 链接的 `href` 属性值。
36
69
  * @returns {Computed<RouteLocation | null>} returns.route - 匹配的路由信息,如果未匹配则返回 `null`。
@@ -48,7 +81,7 @@ export function useLink(props) {
48
81
  const to = props.to;
49
82
  let target = null;
50
83
  // 验证目标类型
51
- if (hasValidNavTarget(to)) {
84
+ if (isNavTarget(to)) {
52
85
  target = to;
53
86
  }
54
87
  else if (isString(to)) {
@@ -61,12 +94,9 @@ export function useLink(props) {
61
94
  target = { index: to };
62
95
  }
63
96
  else {
64
- if (__VITARX_DEV__) {
65
- logger.warn(`[RouterLink] Invalid "to" prop: ${isPlainObject(to) ? JSON.stringify(to) : String(to)}`);
66
- }
67
97
  return null;
68
98
  }
69
- // 处理字符串目标
99
+ // 处理路由路径
70
100
  if (isString(target.index)) {
71
101
  // 兼容纯锚点连接跳转
72
102
  if (target.index.startsWith('#')) {
@@ -88,28 +118,19 @@ export function useLink(props) {
88
118
  target.query = parseQuery(queryString);
89
119
  }
90
120
  }
91
- const route = router.matchRoute(target);
92
- if (route)
93
- return route;
94
- if (__VITARX_DEV__) {
95
- logger.warn(`[RouterLink] No match found for to ${isPlainObject(to) ? JSON.stringify(to) : String(to)}`);
96
- }
97
- return null;
121
+ return router.matchRoute(target);
98
122
  });
99
123
  /**
100
124
  * 计算属性:生成链接的 href 属性
101
125
  * @returns 返回路由的 href 或原始字符串,默认返回 'javascript:void(0)'
102
126
  */
103
127
  const href = computed(() => {
104
- if (route.value?.href) {
128
+ if (route.value?.href)
105
129
  return route.value.href;
106
- }
107
- if (isPlainObject(props.to) && isValidPath(props.to.index)) {
130
+ if (isPlainObject(props.to) && isRoutePath(props.to.index))
108
131
  return props.to.index;
109
- }
110
- if (isString(props.to)) {
132
+ if (isString(props.to))
111
133
  return props.to;
112
- }
113
134
  return 'javascript:void(0)';
114
135
  });
115
136
  /**
@@ -120,7 +141,7 @@ export function useLink(props) {
120
141
  const matchedRoute = route.value;
121
142
  if (!matchedRoute)
122
143
  return false;
123
- return router.route.href.startsWith(matchedRoute.path);
144
+ return isPathPrefixMatch(router.route.path, matchedRoute.path);
124
145
  });
125
146
  /**
126
147
  * 计算属性:判断当前路由是否是精确激活状态
@@ -130,39 +151,60 @@ export function useLink(props) {
130
151
  const matchedRoute = route.value;
131
152
  if (!matchedRoute)
132
153
  return false;
133
- return router.route.path === matchedRoute.path;
154
+ const isMatched = isPathExactMatch(router.route.path, matchedRoute.path);
155
+ if (!isMatched)
156
+ return false;
157
+ const mode = props.exactMatchMode || 'path';
158
+ switch (mode) {
159
+ case 'href':
160
+ return router.route.href === matchedRoute.href;
161
+ case 'hash':
162
+ return router.route.hash === matchedRoute.hash;
163
+ case 'query':
164
+ return stringifyQuery(router.route.query) === stringifyQuery(matchedRoute.query);
165
+ default:
166
+ return true;
167
+ }
134
168
  });
135
169
  /**
136
170
  * 导航处理函数
137
171
  * @param e - 可选的鼠标事件对象
138
- * @returns 返回导航结果,如果没有匹配路由则返回 undefined
172
+ * @returns {Promise<NavigateResult | void>} 如果
139
173
  */
140
174
  const navigate = async (e) => {
141
- const matchedRoute = route.value;
142
- // 如果没有匹配的路由,则返回 void 0
143
- if (!matchedRoute) {
144
- if (href.value === 'javascript:void(0)') {
145
- logger.warn(`[RouterLink] No match found for to ${isPlainObject(props.to) ? JSON.stringify(props.to) : String(props.to)}`);
146
- e?.preventDefault();
147
- }
148
- return void 0;
175
+ const routeHref = href.value;
176
+ // 如果是无效的链接或外部链接都返回模拟的未匹配路由结果
177
+ if (routeHref === 'javascript:void(0)') {
178
+ return {
179
+ state: NavState.notfound,
180
+ message: `No match found for target: ${isPlainObject(props.to) ? JSON.stringify(props.to) : String(props.to)}`,
181
+ to: null,
182
+ from: cloneRouteLocation(router.route)
183
+ };
184
+ }
185
+ if (isExternalLink(routeHref)) {
186
+ return {
187
+ state: NavState.external,
188
+ message: `Open External link ${routeHref}`,
189
+ to: null,
190
+ from: cloneRouteLocation(router.route)
191
+ };
149
192
  }
150
193
  // 阻止默认行为
151
194
  e?.preventDefault();
152
195
  const defaultReplace = props.replace ?? false;
153
- const isReplace = hasValidNavTarget(props.to)
154
- ? (props.to.replace ?? defaultReplace)
155
- : defaultReplace;
196
+ const isReplace = isNavTarget(props.to) ? (props.to.replace ?? defaultReplace) : defaultReplace;
156
197
  let result;
198
+ const routeTarget = route.value || routeHref;
157
199
  // 处理视图过渡
158
200
  if (!__VITARX_SSR__ && props.viewTransition) {
159
201
  await handleTransition(async () => {
160
- result = isReplace ? await router.replace(matchedRoute) : await router.push(matchedRoute);
202
+ result = isReplace ? await router.replace(routeTarget) : await router.push(routeTarget);
161
203
  await router.waitViewRender();
162
204
  });
163
205
  }
164
206
  else {
165
- result = isReplace ? await router.replace(matchedRoute) : await router.push(matchedRoute);
207
+ result = isReplace ? await router.replace(routeTarget) : await router.push(routeTarget);
166
208
  }
167
209
  return result;
168
210
  };
@@ -24,7 +24,7 @@ export declare function stringifyQuery(obj: Record<string, string>): `?${string}
24
24
  * @param {string} str - 路径字符串
25
25
  * @return {string} - 去除末尾斜杠后的路径字符串
26
26
  */
27
- export declare function removePathEndSlash<T extends string>(str: T): T;
27
+ export declare function removeTrailingSlash<T extends string>(str: T): T;
28
28
  /**
29
29
  * 归一化path
30
30
  *
@@ -35,7 +35,7 @@ export function stringifyQuery(obj) {
35
35
  * @param {string} str - 路径字符串
36
36
  * @return {string} - 去除末尾斜杠后的路径字符串
37
37
  */
38
- export function removePathEndSlash(str) {
38
+ export function removeTrailingSlash(str) {
39
39
  if (str === '/')
40
40
  return str;
41
41
  return str.endsWith('/') ? str.slice(0, -1) : str;
@@ -60,7 +60,7 @@ export function normalizePath(path, removeEndSlash = false) {
60
60
  // 去除所有空格 处理重复//
61
61
  let normalizedPath = `/${path.trim()}`.replace(/\s+/g, '').replace(/\/+/g, '/');
62
62
  if (removeEndSlash) {
63
- normalizedPath = removePathEndSlash(normalizedPath);
63
+ normalizedPath = removeTrailingSlash(normalizedPath);
64
64
  }
65
65
  // 去除尾随斜杠
66
66
  return normalizedPath;
@@ -120,7 +120,7 @@ export interface NavigateResult {
120
120
  /**
121
121
  * 目标路由位置
122
122
  *
123
- * `state !== NavState.notfound` 时存在
123
+ * `state !== NavState.notfound` 和 `state !== NavState.external` 时存在
124
124
  */
125
125
  to: RouteLocation | null;
126
126
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitarx-router",
3
- "version": "4.0.0-beta.24",
3
+ "version": "4.0.0-beta.26",
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",
@@ -55,6 +55,9 @@
55
55
  "peerDependenciesMeta": {
56
56
  "vite": {
57
57
  "optional": true
58
+ },
59
+ "vitarx": {
60
+ "optional": true
58
61
  }
59
62
  },
60
63
  "dependencies": {