generator-mico-cli 0.2.2 → 0.2.4
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/generators/micro-react/templates/apps/layout/docs/feature-/345/276/256/345/211/215/347/253/257/346/250/241/345/274/217.md +90 -8
- package/generators/micro-react/templates/apps/layout/src/app.tsx +8 -3
- package/generators/micro-react/templates/apps/layout/src/common/micro/index.ts +34 -0
- package/generators/micro-react/templates/apps/layout/src/common/route-guard.ts +316 -0
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/index.tsx +59 -185
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/micro-app-manager.ts +493 -0
- package/generators/micro-react/templates/apps/layout/src/hooks/useRoutePermissionRefresh.ts +26 -23
- package/generators/micro-react/templates/apps/layout/src/layouts/index.tsx +55 -42
- package/generators/micro-react/templates/apps/layout/src/pages/403/index.tsx +6 -1
- package/package.json +1 -1
- package/generators/micro-react/templates/apps/layout/src/components/MicroAppLoader/container-manager.ts +0 -334
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# 微前端模式
|
|
2
2
|
|
|
3
3
|
> 创建时间:2025-12-26
|
|
4
|
+
> 更新时间:2025-01-25(加载健壮性增强)
|
|
4
5
|
|
|
5
6
|
## 功能概述
|
|
6
7
|
|
|
@@ -27,14 +28,16 @@
|
|
|
27
28
|
|
|
28
29
|
### 核心文件
|
|
29
30
|
|
|
30
|
-
| 文件路径
|
|
31
|
-
|
|
|
32
|
-
| `src/components/MicroAppLoader/index.tsx`
|
|
33
|
-
| `src/components/MicroAppLoader/
|
|
34
|
-
| `src/
|
|
35
|
-
| `src/common/menu/
|
|
36
|
-
| `src/
|
|
37
|
-
| `
|
|
31
|
+
| 文件路径 | 说明 |
|
|
32
|
+
| --------------------------------------------------- | -------------------------------- |
|
|
33
|
+
| `src/components/MicroAppLoader/index.tsx` | qiankun 微应用加载器组件 |
|
|
34
|
+
| `src/components/MicroAppLoader/container-manager.ts`| 容器生命周期管理、会话 ID 机制 |
|
|
35
|
+
| `src/components/MicroAppLoader/index.less` | 加载器样式 |
|
|
36
|
+
| `src/common/menu/parser.ts` | 菜单解析,包含加载类型判断 |
|
|
37
|
+
| `src/common/menu/types.ts` | 类型定义 |
|
|
38
|
+
| `src/layouts/index.tsx` | 主布局,集成微应用渲染 |
|
|
39
|
+
| `src/app.tsx` | qiankun 全局错误处理 |
|
|
40
|
+
| `config/config.ts` | qiankun master 配置 |
|
|
38
41
|
|
|
39
42
|
## API / 组件接口
|
|
40
43
|
|
|
@@ -430,3 +433,82 @@ export default function HomePage() {
|
|
|
430
433
|
有 htmlUrl 或 jsUrls → microapp (使用 qiankun 加载)
|
|
431
434
|
无 htmlUrl 且无 jsUrls → internal (使用 Outlet 渲染)
|
|
432
435
|
```
|
|
436
|
+
|
|
437
|
+
## 加载健壮性机制
|
|
438
|
+
|
|
439
|
+
> 更新于 2025-01-25
|
|
440
|
+
|
|
441
|
+
### 解决的问题
|
|
442
|
+
|
|
443
|
+
| 原问题 | 风险 | 修复方案 |
|
|
444
|
+
|--------|------|----------|
|
|
445
|
+
| 无全局错误处理,子应用异常可能导致页面崩溃 | 高 | 添加 `addGlobalUncaughtErrorHandler` |
|
|
446
|
+
| unmount 使用 rAF 时序不可靠 | 高 | 使用 `queueMicrotask` |
|
|
447
|
+
| 同名应用并发加载可能冲突 | 高 | 会话 ID 锁 + `waitForUnmount` |
|
|
448
|
+
| unmount 可能永久卡死 | 中 | 10 秒超时机制 |
|
|
449
|
+
| 加载期间 Props 变化被跳过 | 中 | 加载完成后重新同步 Props |
|
|
450
|
+
|
|
451
|
+
### 全局错误处理
|
|
452
|
+
|
|
453
|
+
在 `src/app.tsx` 中注册 qiankun 全局错误处理器,捕获子应用运行时未捕获的异常:
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
import { addGlobalUncaughtErrorHandler } from 'qiankun';
|
|
457
|
+
|
|
458
|
+
addGlobalUncaughtErrorHandler((event: Event | string) => {
|
|
459
|
+
// 捕获子应用 JS 运行时错误
|
|
460
|
+
// 捕获子应用生命周期钩子异常
|
|
461
|
+
// 捕获资源加载失败
|
|
462
|
+
console.error('[qiankun] Global uncaught error:', event);
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### container-manager API
|
|
467
|
+
|
|
468
|
+
容器管理器提供会话 ID 机制防止并发加载冲突:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
/** 开始加载会话,返回唯一会话 ID */
|
|
472
|
+
function startLoadSession(appName: string): number;
|
|
473
|
+
|
|
474
|
+
/** 检查会话是否仍然有效(被新加载覆盖时返回 false) */
|
|
475
|
+
function isLoadSessionValid(appName: string, sessionId: number): boolean;
|
|
476
|
+
|
|
477
|
+
/** 标记加载完成 */
|
|
478
|
+
function markLoadComplete(appName: string, sessionId: number): boolean;
|
|
479
|
+
|
|
480
|
+
/** 等待当前卸载操作完成(如果有) */
|
|
481
|
+
async function waitForUnmount(appName: string): Promise<void>;
|
|
482
|
+
|
|
483
|
+
/** 获取当前加载状态 */
|
|
484
|
+
function getLoadingStatus(appName: string): 'idle' | 'loading' | 'mounted' | 'unmounting';
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### 快速切换时序
|
|
488
|
+
|
|
489
|
+
```
|
|
490
|
+
场景:A → B → A 快速切换
|
|
491
|
+
|
|
492
|
+
A1 开始加载 (sessionId=1)
|
|
493
|
+
↓
|
|
494
|
+
切换到 B,A1 cleanup 在 queueMicrotask 中执行
|
|
495
|
+
↓
|
|
496
|
+
切换回 A,A2 开始加载 (sessionId=2)
|
|
497
|
+
↓
|
|
498
|
+
A2 调用 waitForUnmount(),等待 A1 完全卸载
|
|
499
|
+
↓
|
|
500
|
+
A1 卸载完成(带 10 秒超时保护)
|
|
501
|
+
↓
|
|
502
|
+
A2 继续加载,每个异步步骤检查 isLoadSessionValid(sessionId)
|
|
503
|
+
↓
|
|
504
|
+
加载完成后立即同步最新 Props(locale/timezone 等)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### 设计决策
|
|
508
|
+
|
|
509
|
+
| 决策点 | 选择 | 理由 |
|
|
510
|
+
|--------|------|------|
|
|
511
|
+
| 卸载时机 | `queueMicrotask` | 在当前事件循环末尾执行,比 `requestAnimationFrame` 更快更可靠 |
|
|
512
|
+
| 并发控制 | 会话 ID 机制 | 简单高效,无需复杂的锁机制 |
|
|
513
|
+
| 超时时间 | 10 秒 | 平衡等待时间与异常检测速度 |
|
|
514
|
+
| Props 同步 | 加载后立即同步 | 确保加载期间的变化不丢失 |
|
|
@@ -13,6 +13,7 @@ import { extractRoutes, getWindowMenus } from './common/menu';
|
|
|
13
13
|
import { ensureSsoSession } from './common/request/sso';
|
|
14
14
|
import {
|
|
15
15
|
clearMicroAppProps,
|
|
16
|
+
getAppNameFromEntry,
|
|
16
17
|
type IMicroAppProps,
|
|
17
18
|
setMicroAppProps,
|
|
18
19
|
} from './common/micro';
|
|
@@ -55,11 +56,14 @@ if (typeof window !== 'undefined') {
|
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
// 检查是否是子应用加载失败(资源 404 等)
|
|
59
|
+
// 只匹配明确的关键词,避免误判业务错误(如 "upload failed")
|
|
58
60
|
const isLoadError =
|
|
59
61
|
errorMessage.includes('Failed to fetch') ||
|
|
60
62
|
errorMessage.includes('Loading chunk') ||
|
|
61
|
-
errorMessage.includes('
|
|
62
|
-
errorMessage.includes('Script error')
|
|
63
|
+
errorMessage.includes('ChunkLoadError') ||
|
|
64
|
+
errorMessage.includes('Script error') ||
|
|
65
|
+
errorMessage.includes('Loading CSS chunk') ||
|
|
66
|
+
(event instanceof ErrorEvent && event.type === 'error');
|
|
63
67
|
|
|
64
68
|
if (isLoadError) {
|
|
65
69
|
console.error(
|
|
@@ -112,10 +116,11 @@ const prefetchMicroApps = () => {
|
|
|
112
116
|
const routes = extractRoutes(menus);
|
|
113
117
|
|
|
114
118
|
// 筛选出所有微应用路由
|
|
119
|
+
// 使用 getAppNameFromEntry 生成 name,与 loadMicroApp 保持一致,确保预加载缓存命中
|
|
115
120
|
const microApps = routes
|
|
116
121
|
.filter((route) => route.loadType === 'microapp' && route.entry)
|
|
117
122
|
.map((route) => ({
|
|
118
|
-
name: route.
|
|
123
|
+
name: getAppNameFromEntry(route.entry!),
|
|
119
124
|
entry: route.entry!,
|
|
120
125
|
}));
|
|
121
126
|
|
|
@@ -32,6 +32,40 @@ export type {
|
|
|
32
32
|
MicroAppErrorSource,
|
|
33
33
|
} from './types';
|
|
34
34
|
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// 微应用名称生成
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从 entry URL 中提取微应用标识
|
|
42
|
+
* 同一个 entry 的所有路由使用相同的标识,避免频繁卸载/重载微应用
|
|
43
|
+
*
|
|
44
|
+
* 注意:使用完整的 origin + pathname 作为标识,避免同一 host 上多个子应用冲突
|
|
45
|
+
* 例如:http://localhost:8010/app1/ 和 http://localhost:8010/app2/ 会有不同的标识
|
|
46
|
+
*
|
|
47
|
+
* @param entry 微应用入口 URL
|
|
48
|
+
* @returns 微应用标识(字母数字和连字符组成)
|
|
49
|
+
*/
|
|
50
|
+
export const getAppNameFromEntry = (entry: string): string => {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(entry, window.location.href);
|
|
53
|
+
// 使用 origin + pathname 作为标识,确保不同路径的子应用有不同标识
|
|
54
|
+
// 如 "localhost-8010" 或 "localhost-8010-app1"
|
|
55
|
+
const identifier = url.host + url.pathname;
|
|
56
|
+
return identifier
|
|
57
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
58
|
+
.replace(/-+/g, '-')
|
|
59
|
+
.replace(/^-|-$/g, '');
|
|
60
|
+
} catch {
|
|
61
|
+
// fallback:使用 entry 的 hash
|
|
62
|
+
return entry
|
|
63
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
64
|
+
.replace(/-+/g, '-')
|
|
65
|
+
.replace(/^-|-$/g, '');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
35
69
|
// ============================================
|
|
36
70
|
// 环境检测
|
|
37
71
|
// ============================================
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由守卫 - 自动检测用户意图
|
|
3
|
+
*
|
|
4
|
+
* 核心问题:
|
|
5
|
+
* 当用户快速切换微应用(A→B→A)时,子应用 B 的代码可能在后台继续执行,
|
|
6
|
+
* 并调用 history.pushState('/route-b') 试图修改路由,导致用户界面混乱。
|
|
7
|
+
*
|
|
8
|
+
* 解决方案:
|
|
9
|
+
* 自动检测"用户触发的导航"和"程序触发的导航":
|
|
10
|
+
* - 用户触发:在 click/keydown 等交互事件的同步调用栈中
|
|
11
|
+
* - 程序触发:在异步回调、定时器、微应用生命周期中
|
|
12
|
+
*
|
|
13
|
+
* 对于程序触发的导航,如果目标路径与当前用户意图不匹配,则拦截。
|
|
14
|
+
*
|
|
15
|
+
* 使用方式:
|
|
16
|
+
* 1. 在应用入口处导入此文件(import '@/common/route-guard')
|
|
17
|
+
* 2. 业务代码无需任何改动,正常使用 history.push、react-router、<a> 标签等
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { microAppLogger } from './logger';
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// 类型定义
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
interface UserIntent {
|
|
27
|
+
/** 用户意图的目标路径 */
|
|
28
|
+
path: string;
|
|
29
|
+
/** 意图设置时间 */
|
|
30
|
+
timestamp: number;
|
|
31
|
+
/** 是否来自用户交互(点击/键盘) */
|
|
32
|
+
fromInteraction: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// 配置常量
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** 用户意图有效期(毫秒) */
|
|
40
|
+
const INTENT_TTL = 5000;
|
|
41
|
+
|
|
42
|
+
/** 交互事件结束后的保护窗口期(毫秒)
|
|
43
|
+
* 用于处理 click → setTimeout(() => navigate(), 0) 这种微延迟情况
|
|
44
|
+
*/
|
|
45
|
+
const INTERACTION_GRACE_PERIOD = 100;
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// 状态
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/** 当前用户意图 */
|
|
52
|
+
let currentIntent: UserIntent | null = null;
|
|
53
|
+
|
|
54
|
+
/** 是否正在用户交互中(click/keydown 事件处理期间) */
|
|
55
|
+
let isInUserInteraction = false;
|
|
56
|
+
|
|
57
|
+
/** 交互结束时间(用于 grace period) */
|
|
58
|
+
let interactionEndTime = 0;
|
|
59
|
+
|
|
60
|
+
/** 调试模式 */
|
|
61
|
+
const DEBUG = true;
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// 工具函数
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
function log(...args: unknown[]): void {
|
|
68
|
+
if (DEBUG) {
|
|
69
|
+
console.log('🛡️[RouteGuard]', ...args);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function warn(...args: unknown[]): void {
|
|
74
|
+
console.warn('🛡️[RouteGuard]', ...args);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 获取路径的基础部分(第一级路径)
|
|
79
|
+
* /lineup/list → /lineup
|
|
80
|
+
* /permission/role → /permission
|
|
81
|
+
*/
|
|
82
|
+
function getBasePath(path: string): string {
|
|
83
|
+
const normalized = path.startsWith('/') ? path : '/' + path;
|
|
84
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
85
|
+
return '/' + (segments[0] || '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 解析 URL 获取 pathname
|
|
90
|
+
*/
|
|
91
|
+
function parsePathname(url: string | URL | null | undefined): string | null {
|
|
92
|
+
if (!url) return null;
|
|
93
|
+
|
|
94
|
+
if (typeof url === 'string') {
|
|
95
|
+
// 相对路径
|
|
96
|
+
if (url.startsWith('/')) return url.split('?')[0].split('#')[0];
|
|
97
|
+
// 绝对 URL
|
|
98
|
+
try {
|
|
99
|
+
return new URL(url, window.location.origin).pathname;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return url.pathname;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 检查意图是否有效
|
|
110
|
+
*/
|
|
111
|
+
function isIntentValid(): boolean {
|
|
112
|
+
if (!currentIntent) return false;
|
|
113
|
+
return Date.now() - currentIntent.timestamp < INTENT_TTL;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 是否在用户交互上下文中
|
|
118
|
+
* 包括:正在交互 或 刚刚结束交互(grace period 内)
|
|
119
|
+
*/
|
|
120
|
+
function isInInteractionContext(): boolean {
|
|
121
|
+
if (isInUserInteraction) return true;
|
|
122
|
+
if (Date.now() - interactionEndTime < INTERACTION_GRACE_PERIOD) return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// 公开 API
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 设置用户意图(供 MicroAppManager 内部调用)
|
|
132
|
+
* 业务代码不需要调用此函数,路由守卫会自动检测用户交互
|
|
133
|
+
*/
|
|
134
|
+
export function setUserIntent(path: string): void {
|
|
135
|
+
log('设置意图:', path);
|
|
136
|
+
currentIntent = {
|
|
137
|
+
path,
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
fromInteraction: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 刷新意图时间戳(供 MicroAppManager 内部调用)
|
|
145
|
+
* 在长时间操作(如加载微应用)过程中保持意图有效
|
|
146
|
+
*/
|
|
147
|
+
export function refreshUserIntent(): void {
|
|
148
|
+
if (currentIntent) {
|
|
149
|
+
currentIntent.timestamp = Date.now();
|
|
150
|
+
log('刷新意图:', currentIntent.path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 清除用户意图
|
|
156
|
+
*/
|
|
157
|
+
export function clearUserIntent(): void {
|
|
158
|
+
log('清除意图');
|
|
159
|
+
currentIntent = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 获取当前意图信息(调试用)
|
|
164
|
+
*/
|
|
165
|
+
export function getIntentDebugInfo(): object {
|
|
166
|
+
return {
|
|
167
|
+
intent: currentIntent,
|
|
168
|
+
isValid: isIntentValid(),
|
|
169
|
+
isInInteraction: isInUserInteraction,
|
|
170
|
+
interactionEndTime,
|
|
171
|
+
isInContext: isInInteractionContext(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// 路由守卫核心逻辑
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 判断是否应该拦截此次导航
|
|
181
|
+
*/
|
|
182
|
+
function shouldIntercept(targetPath: string): boolean {
|
|
183
|
+
// 1. 在用户交互上下文中,允许导航并更新意图
|
|
184
|
+
if (isInInteractionContext()) {
|
|
185
|
+
log('用户交互触发导航:', targetPath);
|
|
186
|
+
currentIntent = {
|
|
187
|
+
path: targetPath,
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
fromInteraction: true,
|
|
190
|
+
};
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. 没有有效意图,允许导航
|
|
195
|
+
if (!isIntentValid()) {
|
|
196
|
+
log('无有效意图,允许导航:', targetPath);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. 有有效意图,检查目标路径是否匹配
|
|
201
|
+
const targetBase = getBasePath(targetPath);
|
|
202
|
+
const intentBase = getBasePath(currentIntent!.path);
|
|
203
|
+
|
|
204
|
+
if (targetBase === intentBase) {
|
|
205
|
+
log('目标匹配意图,允许导航:', { targetPath, intentPath: currentIntent!.path });
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 4. 不匹配,拦截
|
|
210
|
+
warn('❌ 拦截程序触发的导航:', {
|
|
211
|
+
targetPath,
|
|
212
|
+
targetBase,
|
|
213
|
+
intentPath: currentIntent!.path,
|
|
214
|
+
intentBase,
|
|
215
|
+
intentAge: Date.now() - currentIntent!.timestamp + 'ms',
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// 初始化
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
if (typeof window !== 'undefined') {
|
|
225
|
+
// 保存原始方法
|
|
226
|
+
const originalPushState = window.history.pushState.bind(window.history);
|
|
227
|
+
const originalReplaceState = window.history.replaceState.bind(window.history);
|
|
228
|
+
|
|
229
|
+
// 拦截 pushState
|
|
230
|
+
window.history.pushState = function (
|
|
231
|
+
state: unknown,
|
|
232
|
+
unused: string,
|
|
233
|
+
url?: string | URL | null,
|
|
234
|
+
) {
|
|
235
|
+
const targetPath = parsePathname(url);
|
|
236
|
+
log('pushState 被调用:', { url, targetPath, isInInteraction: isInUserInteraction });
|
|
237
|
+
|
|
238
|
+
if (targetPath && shouldIntercept(targetPath)) {
|
|
239
|
+
// 拦截,不执行导航
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return originalPushState(state, unused, url);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// 拦截 replaceState
|
|
247
|
+
window.history.replaceState = function (
|
|
248
|
+
state: unknown,
|
|
249
|
+
unused: string,
|
|
250
|
+
url?: string | URL | null,
|
|
251
|
+
) {
|
|
252
|
+
const targetPath = parsePathname(url);
|
|
253
|
+
log('replaceState 被调用:', { url, targetPath, isInInteraction: isInUserInteraction });
|
|
254
|
+
|
|
255
|
+
if (targetPath && shouldIntercept(targetPath)) {
|
|
256
|
+
// 拦截,不执行导航
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return originalReplaceState(state, unused, url);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// 监听用户交互事件
|
|
264
|
+
// 使用 capture 阶段确保在任何其他处理器之前执行
|
|
265
|
+
const markInteractionStart = (event: Event) => {
|
|
266
|
+
// 只处理可能触发导航的交互
|
|
267
|
+
if (event.type === 'click' || event.type === 'keydown') {
|
|
268
|
+
const keyEvent = event as KeyboardEvent;
|
|
269
|
+
// 只有 Enter 键可能触发导航
|
|
270
|
+
if (event.type === 'keydown' && keyEvent.key !== 'Enter') {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log('用户交互开始:', event.type);
|
|
275
|
+
isInUserInteraction = true;
|
|
276
|
+
|
|
277
|
+
// 在当前事件循环结束后重置标记
|
|
278
|
+
// 使用 setTimeout 0 确保同步代码执行完毕
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
isInUserInteraction = false;
|
|
281
|
+
interactionEndTime = Date.now();
|
|
282
|
+
log('用户交互结束');
|
|
283
|
+
}, 0);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
document.addEventListener('click', markInteractionStart, { capture: true });
|
|
288
|
+
document.addEventListener('keydown', markInteractionStart, { capture: true });
|
|
289
|
+
|
|
290
|
+
// 监听 popstate 事件(浏览器前进/后退)
|
|
291
|
+
window.addEventListener('popstate', () => {
|
|
292
|
+
// 浏览器前进后退是用户行为,更新意图
|
|
293
|
+
const newPath = window.location.pathname;
|
|
294
|
+
log('popstate 事件:', newPath);
|
|
295
|
+
currentIntent = {
|
|
296
|
+
path: newPath,
|
|
297
|
+
timestamp: Date.now(),
|
|
298
|
+
fromInteraction: true,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
microAppLogger.log('RouteGuard initialized');
|
|
303
|
+
|
|
304
|
+
// 暴露调试接口
|
|
305
|
+
(window as unknown as Record<string, unknown>).__ROUTE_GUARD__ = {
|
|
306
|
+
getDebugInfo: getIntentDebugInfo,
|
|
307
|
+
setIntent: setUserIntent,
|
|
308
|
+
clearIntent: clearUserIntent,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default {
|
|
313
|
+
setUserIntent,
|
|
314
|
+
clearUserIntent,
|
|
315
|
+
getIntentDebugInfo,
|
|
316
|
+
};
|