@vlian/router 0.1.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/README.md +1134 -0
- package/dist/adapters/create-app-router.cjs +1 -0
- package/dist/adapters/create-app-router.d.ts +4 -0
- package/dist/adapters/create-app-router.js +1 -0
- package/dist/adapters/index.cjs +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/core/guards/route-shell.cjs +1 -0
- package/dist/core/guards/route-shell.d.ts +13 -0
- package/dist/core/guards/route-shell.js +1 -0
- package/dist/core/index.cjs +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +1 -0
- package/dist/core/normalize/normalize-routes.cjs +1 -0
- package/dist/core/normalize/normalize-routes.d.ts +8 -0
- package/dist/core/normalize/normalize-routes.js +1 -0
- package/dist/core/normalize/route-id.cjs +1 -0
- package/dist/core/normalize/route-id.d.ts +2 -0
- package/dist/core/normalize/route-id.js +1 -0
- package/dist/core/patcher/create-navigation-patcher.cjs +1 -0
- package/dist/core/patcher/create-navigation-patcher.d.ts +12 -0
- package/dist/core/patcher/create-navigation-patcher.js +1 -0
- package/dist/core/runtime/route-registry.cjs +1 -0
- package/dist/core/runtime/route-registry.d.ts +9 -0
- package/dist/core/runtime/route-registry.js +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/runtime/AppRouterProvider.cjs +1 -0
- package/dist/runtime/AppRouterProvider.d.ts +6 -0
- package/dist/runtime/AppRouterProvider.js +1 -0
- package/dist/runtime/index.cjs +1 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/types/index.cjs +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/route.cjs +1 -0
- package/dist/types/route.d.ts +116 -0
- package/dist/types/route.js +1 -0
- package/package.json +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
# @vlian/router
|
|
2
|
+
|
|
3
|
+
基于 `react-router-dom` 的业务路由封装包。目标不是暴露更多路由细节,而是把静态路由标准化、动态补丁注入、导航阶段异步加载、基础鉴权包装这些复杂性收口到包内部,让业务方只维护两类输入:
|
|
4
|
+
|
|
5
|
+
- 静态路由配置
|
|
6
|
+
- 动态路由加载函数
|
|
7
|
+
|
|
8
|
+
## 1. 设计目标与边界
|
|
9
|
+
|
|
10
|
+
### 解决的问题
|
|
11
|
+
|
|
12
|
+
- 统一业务侧路由输入模型,业务方只写声明式静态路由
|
|
13
|
+
- 内部完成 `StaticRouteConfig -> react-router-dom RouteObject` 的标准化
|
|
14
|
+
- 在导航发生时通过 `patchRoutesOnNavigation` 按需注入动态路由
|
|
15
|
+
- 支持动态路由来源于本地模块、接口请求、权限码集合或混合来源
|
|
16
|
+
- 内置动态加载去重、导航级缓存、错误回调和基础降级
|
|
17
|
+
- 对 `layout`、`auth`、`redirect`、`meta` 等业务字段做统一消费
|
|
18
|
+
|
|
19
|
+
### 不解决的问题
|
|
20
|
+
|
|
21
|
+
- 不替代业务权限系统本身
|
|
22
|
+
- 不负责服务端菜单接口协议设计
|
|
23
|
+
- 不强行规定业务的菜单模型、埋点模型、面包屑模型
|
|
24
|
+
- 不试图绕过 `react-router-dom` 能力边界,仍以 data router 为底座
|
|
25
|
+
- 不内置复杂页面级权限请求流转,异步鉴权建议放在动态路由加载或 loader 中完成
|
|
26
|
+
|
|
27
|
+
## 2. 推荐 API 设计
|
|
28
|
+
|
|
29
|
+
### 主入口
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createAppRouter, defineRoutes } from "@vlian/router";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
根入口只暴露核心构建 API,用于尽量降低消费端打包时被额外带入的运行时代码。
|
|
36
|
+
|
|
37
|
+
如果需要直接渲染 provider,请显式从子路径导入:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { AppRouterProvider } from "@vlian/router/provider";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 核心 API
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const routes = defineRoutes([
|
|
47
|
+
{
|
|
48
|
+
id: "root",
|
|
49
|
+
path: "/",
|
|
50
|
+
component: RootPage,
|
|
51
|
+
children: [
|
|
52
|
+
{
|
|
53
|
+
path: "dashboard",
|
|
54
|
+
component: DashboardPage,
|
|
55
|
+
meta: { title: "Dashboard" },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const router = createAppRouter({
|
|
62
|
+
mode: "browser",
|
|
63
|
+
routes,
|
|
64
|
+
dynamic: {
|
|
65
|
+
defaultParentId: "root",
|
|
66
|
+
loader: async ({ path, signal }) => {
|
|
67
|
+
if (!path.startsWith("/system")) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await fetch("/api/routes", { signal });
|
|
72
|
+
const payload = await response.json();
|
|
73
|
+
|
|
74
|
+
return payload.routes.map((item: any) => ({
|
|
75
|
+
id: item.code,
|
|
76
|
+
path: item.path,
|
|
77
|
+
lazy: async () => {
|
|
78
|
+
const mod = await import(`./pages/${item.component}.tsx`);
|
|
79
|
+
return { Component: mod.default };
|
|
80
|
+
},
|
|
81
|
+
meta: { title: item.title, code: item.code },
|
|
82
|
+
}));
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 对外 API 说明
|
|
89
|
+
|
|
90
|
+
- `defineRoutes(routes)`
|
|
91
|
+
仅做类型收敛,帮助业务侧保留推断结果
|
|
92
|
+
|
|
93
|
+
- `createAppRouter(options)`
|
|
94
|
+
包主工厂。内部完成:
|
|
95
|
+
- 静态路由 normalize
|
|
96
|
+
- 动态 patch 创建
|
|
97
|
+
- `createBrowserRouter/createHashRouter/createMemoryRouter` 适配
|
|
98
|
+
|
|
99
|
+
- `AppRouterProvider`
|
|
100
|
+
针对“只想直接传 options 渲染”的场景,内部 `useMemo(createAppRouter)`。
|
|
101
|
+
为避免默认入口增重,请从 `@vlian/router/provider` 或 `@vlian/router/runtime` 单独导入
|
|
102
|
+
|
|
103
|
+
## 3. TypeScript 类型设计
|
|
104
|
+
|
|
105
|
+
### StaticRouteConfig
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
export interface StaticRouteConfig<Meta, Extra> extends Extra {
|
|
109
|
+
id?: string;
|
|
110
|
+
path?: string;
|
|
111
|
+
index?: boolean;
|
|
112
|
+
caseSensitive?: boolean;
|
|
113
|
+
component?: RouteComponent;
|
|
114
|
+
Component?: RouteComponent;
|
|
115
|
+
element?: ReactNode;
|
|
116
|
+
lazy?: RouteLazyLoader;
|
|
117
|
+
loader?: RouteObject["loader"];
|
|
118
|
+
action?: RouteObject["action"];
|
|
119
|
+
shouldRevalidate?: RouteObject["shouldRevalidate"];
|
|
120
|
+
errorElement?: ReactNode;
|
|
121
|
+
ErrorBoundary?: RouteObject["ErrorBoundary"];
|
|
122
|
+
hydrateFallbackElement?: ReactNode;
|
|
123
|
+
HydrateFallback?: RouteObject["HydrateFallback"];
|
|
124
|
+
handle?: RouteObject["handle"];
|
|
125
|
+
meta?: Meta;
|
|
126
|
+
layout?: RouteLayout<Meta, Extra>;
|
|
127
|
+
auth?: RouteAuthConfig;
|
|
128
|
+
redirect?: RouteRedirect;
|
|
129
|
+
children?: StaticRouteConfig<Meta, Extra>[];
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
设计要点:
|
|
134
|
+
|
|
135
|
+
- 业务优先写 `component`,不必理解 `Component` 与 `element` 差异
|
|
136
|
+
- 保留 `lazy`、`loader`、`action` 等 data router 原生能力
|
|
137
|
+
- `meta` 泛型可扩展
|
|
138
|
+
- `Extra` 泛型用于承接业务扩展字段
|
|
139
|
+
|
|
140
|
+
### DynamicRouteLoader
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
export type DynamicRouteLoader<Meta, Extra> = (
|
|
144
|
+
context: DynamicRouteLoaderContext<Meta, Extra>,
|
|
145
|
+
) => Promise<
|
|
146
|
+
| void
|
|
147
|
+
| StaticRouteConfig<Meta, Extra>[]
|
|
148
|
+
| DynamicRoutePatch<Meta, Extra>
|
|
149
|
+
| DynamicRoutePatch<Meta, Extra>[]
|
|
150
|
+
>;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### DynamicRouteLoaderContext
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
export interface DynamicRouteLoaderContext<Meta, Extra> {
|
|
157
|
+
path: string;
|
|
158
|
+
signal: AbortSignal;
|
|
159
|
+
matches: RouteMatch<string, AppRouteObject<Meta, Extra>>[];
|
|
160
|
+
router: DataRouter;
|
|
161
|
+
knownRouteIds: Set<string>;
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### DynamicRoutePatch
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
export interface DynamicRoutePatch<Meta, Extra> {
|
|
169
|
+
parentId?: string | null;
|
|
170
|
+
routes: StaticRouteConfig<Meta, Extra>[];
|
|
171
|
+
cacheKey?: string | false;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### RouterPluginOptions
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export interface RouterPluginOptions<Meta, Extra> {
|
|
179
|
+
routes: StaticRouteConfig<Meta, Extra>[];
|
|
180
|
+
mode?: "browser" | "hash" | "memory";
|
|
181
|
+
basename?: string;
|
|
182
|
+
future?: CreateMemoryRouterOptions["future"];
|
|
183
|
+
hydrationData?: CreateMemoryRouterOptions["hydrationData"];
|
|
184
|
+
getContext?: CreateMemoryRouterOptions["getContext"];
|
|
185
|
+
dataStrategy?: CreateMemoryRouterOptions["dataStrategy"];
|
|
186
|
+
initialEntries?: CreateMemoryRouterOptions["initialEntries"];
|
|
187
|
+
initialIndex?: CreateMemoryRouterOptions["initialIndex"];
|
|
188
|
+
window?: Window;
|
|
189
|
+
dynamic?: DynamicRoutingOptions<Meta, Extra>;
|
|
190
|
+
authorization?: RouteAuthorizationOptions<Meta, Extra>;
|
|
191
|
+
onError?: RouterErrorHandler<Meta, Extra>;
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 4. 内部架构设计
|
|
196
|
+
|
|
197
|
+
目录按职责拆分,不把 normalize、patch、runtime 混在一个文件:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
src
|
|
201
|
+
├── adapters
|
|
202
|
+
│ ├── create-app-router.ts
|
|
203
|
+
│ └── index.ts
|
|
204
|
+
├── core
|
|
205
|
+
│ ├── guards
|
|
206
|
+
│ │ └── route-shell.tsx
|
|
207
|
+
│ ├── normalize
|
|
208
|
+
│ │ ├── normalize-routes.ts
|
|
209
|
+
│ │ └── route-id.ts
|
|
210
|
+
│ ├── patcher
|
|
211
|
+
│ │ └── create-navigation-patcher.ts
|
|
212
|
+
│ ├── runtime
|
|
213
|
+
│ │ └── route-registry.ts
|
|
214
|
+
│ └── index.ts
|
|
215
|
+
├── runtime
|
|
216
|
+
│ ├── AppRouterProvider.tsx
|
|
217
|
+
│ └── index.ts
|
|
218
|
+
├── types
|
|
219
|
+
│ ├── index.ts
|
|
220
|
+
│ └── route.ts
|
|
221
|
+
└── index.ts
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 各层职责
|
|
225
|
+
|
|
226
|
+
- `adapters`
|
|
227
|
+
面向 `react-router-dom`,负责组装最终 router
|
|
228
|
+
|
|
229
|
+
- `core/normalize`
|
|
230
|
+
负责静态路由标准化、自动 `id` 生成、业务字段转换
|
|
231
|
+
|
|
232
|
+
- `core/guards`
|
|
233
|
+
把 `layout/auth/redirect` 包装成统一的 route shell
|
|
234
|
+
|
|
235
|
+
- `core/patcher`
|
|
236
|
+
封装导航期间动态注入逻辑,支持 `await`、缓存、错误处理和去重
|
|
237
|
+
|
|
238
|
+
- `core/runtime`
|
|
239
|
+
维护已注册路由集合,避免重复 patch
|
|
240
|
+
|
|
241
|
+
- `runtime`
|
|
242
|
+
提供 `AppRouterProvider`
|
|
243
|
+
|
|
244
|
+
- `types`
|
|
245
|
+
只放公开类型,不掺杂实现
|
|
246
|
+
|
|
247
|
+
## 5. 关键实现思路
|
|
248
|
+
|
|
249
|
+
### 静态路由输入
|
|
250
|
+
|
|
251
|
+
业务方只关心:
|
|
252
|
+
|
|
253
|
+
- `path`
|
|
254
|
+
- `component/lazy`
|
|
255
|
+
- `children`
|
|
256
|
+
- `meta`
|
|
257
|
+
- `layout`
|
|
258
|
+
- `auth`
|
|
259
|
+
- `redirect`
|
|
260
|
+
|
|
261
|
+
包内部负责:
|
|
262
|
+
|
|
263
|
+
- 自动生成稳定 `id`
|
|
264
|
+
- `component -> Component`
|
|
265
|
+
- `redirect -> <Navigate />`
|
|
266
|
+
- `layout -> RouteShell`
|
|
267
|
+
- `auth + authorization -> RouteShell`
|
|
268
|
+
- 补齐 `handle.meta/auth/source`
|
|
269
|
+
|
|
270
|
+
### 动态补丁
|
|
271
|
+
|
|
272
|
+
使用 `react-router-dom@7` 的 `patchRoutesOnNavigation`,内部封装:
|
|
273
|
+
|
|
274
|
+
- `loader(context)` 支持异步
|
|
275
|
+
- `await fetch(...)`
|
|
276
|
+
- 接口成功后返回路由数组或 patch 描述
|
|
277
|
+
- 自动 normalize 为 `RouteObject`
|
|
278
|
+
- 自动过滤已注入 `id`
|
|
279
|
+
- 按 `parentId` 打补丁
|
|
280
|
+
- 支持 `cacheKey`
|
|
281
|
+
|
|
282
|
+
### 错误处理机制
|
|
283
|
+
|
|
284
|
+
动态路由加载失败时:
|
|
285
|
+
|
|
286
|
+
- 默认走 `onError(context)` 回调
|
|
287
|
+
- `errorMode: "silent"` 时只降级,不中断导航
|
|
288
|
+
- `errorMode: "throw"` 时向上抛出,让业务自己接管
|
|
289
|
+
|
|
290
|
+
## 6. 最小可用示例
|
|
291
|
+
|
|
292
|
+
### 6.1 定义静态路由
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
import { Outlet } from "react-router-dom";
|
|
296
|
+
import { defineRoutes } from "@vlian/router";
|
|
297
|
+
|
|
298
|
+
function RootLayout({ children }: { children: React.ReactNode }) {
|
|
299
|
+
return (
|
|
300
|
+
<div className="app-shell">
|
|
301
|
+
<aside>menu</aside>
|
|
302
|
+
<main>{children}</main>
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function RootPage() {
|
|
308
|
+
return <Outlet />;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function LoginPage() {
|
|
312
|
+
return <div>login</div>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const routes = defineRoutes([
|
|
316
|
+
{
|
|
317
|
+
id: "root",
|
|
318
|
+
path: "/",
|
|
319
|
+
component: RootPage,
|
|
320
|
+
layout: RootLayout,
|
|
321
|
+
children: [
|
|
322
|
+
{
|
|
323
|
+
index: true,
|
|
324
|
+
redirect: "/dashboard",
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
id: "login",
|
|
330
|
+
path: "/login",
|
|
331
|
+
component: LoginPage,
|
|
332
|
+
meta: { title: "登录" },
|
|
333
|
+
},
|
|
334
|
+
]);
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 6.2 定义动态路由加载方法
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import type { DynamicRouteLoader } from "@vlian/router";
|
|
341
|
+
|
|
342
|
+
type AppMeta = {
|
|
343
|
+
title?: string;
|
|
344
|
+
permissionCode?: string;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
export const loadDynamicRoutes: DynamicRouteLoader<AppMeta> = async ({
|
|
348
|
+
path,
|
|
349
|
+
signal,
|
|
350
|
+
}) => {
|
|
351
|
+
if (!path.startsWith("/system")) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const response = await fetch("/api/auth/routes", { signal });
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new Error(`route api failed: ${response.status}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const data = await response.json();
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
parentId: "root",
|
|
364
|
+
cacheKey: `permission:${data.authzVersion}`,
|
|
365
|
+
routes: data.routes.map((item: any) => ({
|
|
366
|
+
id: item.code,
|
|
367
|
+
path: item.path.replace(/^\//, ""),
|
|
368
|
+
meta: {
|
|
369
|
+
title: item.title,
|
|
370
|
+
permissionCode: item.code,
|
|
371
|
+
},
|
|
372
|
+
auth: {
|
|
373
|
+
required: true,
|
|
374
|
+
permissionCode: item.code,
|
|
375
|
+
},
|
|
376
|
+
lazy: async () => {
|
|
377
|
+
const mod = await import(`./pages/${item.component}.tsx`);
|
|
378
|
+
return {
|
|
379
|
+
Component: mod.default,
|
|
380
|
+
loader: mod.loader,
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
})),
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### 6.3 创建 router
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { createAppRouter } from "@vlian/router";
|
|
392
|
+
import { routes } from "./routes";
|
|
393
|
+
import { loadDynamicRoutes } from "./dynamic-routes";
|
|
394
|
+
import { authStore } from "./state/auth-store";
|
|
395
|
+
|
|
396
|
+
export const router = createAppRouter({
|
|
397
|
+
mode: "browser",
|
|
398
|
+
routes,
|
|
399
|
+
authorization: {
|
|
400
|
+
resolve: ({ auth }) => {
|
|
401
|
+
if (!auth || auth === false) {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const currentPermissions = authStore.getState().permissions;
|
|
406
|
+
const permissionCode =
|
|
407
|
+
typeof auth === "object" ? auth.permissionCode : undefined;
|
|
408
|
+
|
|
409
|
+
return !permissionCode || currentPermissions.includes(permissionCode);
|
|
410
|
+
},
|
|
411
|
+
redirectTo: "/login",
|
|
412
|
+
},
|
|
413
|
+
dynamic: {
|
|
414
|
+
defaultParentId: "root",
|
|
415
|
+
loader: loadDynamicRoutes,
|
|
416
|
+
getNavigationCacheKey: ({ path }) =>
|
|
417
|
+
path.startsWith("/system") ? path : false,
|
|
418
|
+
errorMode: "silent",
|
|
419
|
+
},
|
|
420
|
+
onError: ({ error, path }) => {
|
|
421
|
+
console.error("dynamic route load failed", path, error);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
这里的 `authStore` 可以是 Redux、Zustand、Pinia 风格的前端状态容器,关键点是:
|
|
427
|
+
|
|
428
|
+
- 路由层只消费当前权限状态
|
|
429
|
+
- 真正的权限来源由登录态接口或用户信息接口提供
|
|
430
|
+
- 前端权限只用于体验控制,不作为最终安全边界
|
|
431
|
+
|
|
432
|
+
例如可以先通过接口拉取当前用户权限,再写入 store:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
type AuthState = {
|
|
436
|
+
permissions: string[];
|
|
437
|
+
setPermissions: (permissions: string[]) => void;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
export const authStore = create<AuthState>((set) => ({
|
|
441
|
+
permissions: [],
|
|
442
|
+
setPermissions: (permissions) => set({ permissions }),
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
export const bootstrapAuth = async () => {
|
|
446
|
+
const response = await fetch("/api/auth/me");
|
|
447
|
+
if (!response.ok) {
|
|
448
|
+
throw new Error(`auth bootstrap failed: ${response.status}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const data = await response.json();
|
|
452
|
+
authStore.getState().setPermissions(data.permissions ?? []);
|
|
453
|
+
};
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
如果你的权限系统完全以后端接口为准,也可以在 `authorization.resolve` 里只做“前端展示控制”,而把真正的安全校验继续留在服务端接口。
|
|
457
|
+
|
|
458
|
+
### 6.4 在应用中使用
|
|
459
|
+
|
|
460
|
+
```tsx
|
|
461
|
+
import { RouterProvider } from "react-router-dom";
|
|
462
|
+
import { router } from "./router";
|
|
463
|
+
|
|
464
|
+
export function App() {
|
|
465
|
+
return <RouterProvider router={router} />;
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### 6.5 使用 AppRouterProvider
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
import { AppRouterProvider } from "@vlian/router/provider";
|
|
473
|
+
import { routes } from "./routes";
|
|
474
|
+
import { loadDynamicRoutes } from "./dynamic-routes";
|
|
475
|
+
|
|
476
|
+
export function App() {
|
|
477
|
+
return (
|
|
478
|
+
<AppRouterProvider
|
|
479
|
+
options={{
|
|
480
|
+
mode: "browser",
|
|
481
|
+
routes,
|
|
482
|
+
dynamic: {
|
|
483
|
+
defaultParentId: "root",
|
|
484
|
+
loader: loadDynamicRoutes,
|
|
485
|
+
},
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### 6.6 懒加载用法
|
|
493
|
+
|
|
494
|
+
当前版本已经原生支持懒加载,直接在 `StaticRouteConfig.lazy` 里返回 `react-router-dom` 的 lazy route module 即可。
|
|
495
|
+
|
|
496
|
+
推荐把 `import()` 提取成独立函数,这样后面做预加载时可以复用同一份模块加载逻辑。
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
import { defineRoutes } from "@vlian/router";
|
|
500
|
+
|
|
501
|
+
const loadDashboardModule = () => import("./pages/dashboard");
|
|
502
|
+
const loadUserListModule = () => import("./pages/system/users");
|
|
503
|
+
|
|
504
|
+
export const routes = defineRoutes([
|
|
505
|
+
{
|
|
506
|
+
id: "root",
|
|
507
|
+
path: "/",
|
|
508
|
+
children: [
|
|
509
|
+
{
|
|
510
|
+
path: "dashboard",
|
|
511
|
+
lazy: async () => {
|
|
512
|
+
const mod = await loadDashboardModule();
|
|
513
|
+
return {
|
|
514
|
+
Component: mod.default,
|
|
515
|
+
loader: mod.loader,
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
meta: {
|
|
519
|
+
title: "Dashboard",
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
path: "system/users",
|
|
524
|
+
lazy: async () => {
|
|
525
|
+
const mod = await loadUserListModule();
|
|
526
|
+
return {
|
|
527
|
+
Component: mod.default,
|
|
528
|
+
loader: mod.loader,
|
|
529
|
+
ErrorBoundary: mod.ErrorBoundary,
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
meta: {
|
|
533
|
+
title: "用户管理",
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
]);
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
适用场景:
|
|
542
|
+
|
|
543
|
+
- 页面体积较大,希望按需分包
|
|
544
|
+
- 页面 loader 和页面组件需要一起延迟加载
|
|
545
|
+
- 权限页、后台页、低频页不希望在首屏打包
|
|
546
|
+
|
|
547
|
+
### 6.7 预加载用法
|
|
548
|
+
|
|
549
|
+
当前版本没有单独暴露 `preloadRoute()` 这一类官方 API,因此推荐两种预加载方式:
|
|
550
|
+
|
|
551
|
+
- 对静态懒加载页面,手动提前执行同一个 `import()` 函数
|
|
552
|
+
- 对动态路由接口,手动预热接口请求缓存,让真正导航时直接复用结果
|
|
553
|
+
|
|
554
|
+
#### 6.7.1 预加载静态懒加载页面
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
const loadUserListModule = () => import("./pages/system/users");
|
|
558
|
+
|
|
559
|
+
export const routes = defineRoutes([
|
|
560
|
+
{
|
|
561
|
+
id: "root",
|
|
562
|
+
path: "/",
|
|
563
|
+
children: [
|
|
564
|
+
{
|
|
565
|
+
path: "system/users",
|
|
566
|
+
lazy: async () => {
|
|
567
|
+
const mod = await loadUserListModule();
|
|
568
|
+
return {
|
|
569
|
+
Component: mod.default,
|
|
570
|
+
loader: mod.loader,
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
export const preloadUserListPage = () => loadUserListModule();
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
在菜单 hover、按钮曝光、空闲时机里预热模块:
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
import { Link } from "react-router-dom";
|
|
585
|
+
import { preloadUserListPage } from "./routes";
|
|
586
|
+
|
|
587
|
+
export function UserMenuLink() {
|
|
588
|
+
return (
|
|
589
|
+
<Link
|
|
590
|
+
onFocus={() => {
|
|
591
|
+
void preloadUserListPage();
|
|
592
|
+
}}
|
|
593
|
+
onMouseEnter={() => {
|
|
594
|
+
void preloadUserListPage();
|
|
595
|
+
}}
|
|
596
|
+
to="/system/users"
|
|
597
|
+
>
|
|
598
|
+
用户管理
|
|
599
|
+
</Link>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### 6.7.2 预加载动态路由接口
|
|
605
|
+
|
|
606
|
+
对于动态路由,推荐把“请求路由清单”的逻辑提成可复用缓存函数。这样:
|
|
607
|
+
|
|
608
|
+
- 预加载阶段可以先请求一次
|
|
609
|
+
- 真正导航进入时,`dynamic.loader` 直接复用缓存结果
|
|
610
|
+
|
|
611
|
+
```ts
|
|
612
|
+
type DynamicRouteResponse = {
|
|
613
|
+
authzVersion: number;
|
|
614
|
+
routes: Array<{
|
|
615
|
+
code: string;
|
|
616
|
+
path: string;
|
|
617
|
+
title: string;
|
|
618
|
+
component: string;
|
|
619
|
+
}>;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
let routeManifestPromise: Promise<DynamicRouteResponse> | null = null;
|
|
623
|
+
|
|
624
|
+
const fetchRouteManifest = async (
|
|
625
|
+
signal?: AbortSignal,
|
|
626
|
+
): Promise<DynamicRouteResponse> => {
|
|
627
|
+
if (!routeManifestPromise) {
|
|
628
|
+
routeManifestPromise = fetch("/api/auth/routes", { signal }).then(
|
|
629
|
+
async (response) => {
|
|
630
|
+
if (!response.ok) {
|
|
631
|
+
routeManifestPromise = null;
|
|
632
|
+
throw new Error(`route api failed: ${response.status}`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return response.json();
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return routeManifestPromise;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
export const preloadDynamicRouteManifest = () => fetchRouteManifest();
|
|
644
|
+
|
|
645
|
+
export const loadDynamicRoutes: DynamicRouteLoader = async ({
|
|
646
|
+
path,
|
|
647
|
+
signal,
|
|
648
|
+
}) => {
|
|
649
|
+
if (!path.startsWith("/system")) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const data = await fetchRouteManifest(signal);
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
parentId: "root",
|
|
657
|
+
cacheKey: `permission:${data.authzVersion}`,
|
|
658
|
+
routes: data.routes.map((item) => ({
|
|
659
|
+
id: item.code,
|
|
660
|
+
path: item.path.replace(/^\//, ""),
|
|
661
|
+
lazy: async () => {
|
|
662
|
+
const mod = await import(`./pages/${item.component}.tsx`);
|
|
663
|
+
return {
|
|
664
|
+
Component: mod.default,
|
|
665
|
+
loader: mod.loader,
|
|
666
|
+
};
|
|
667
|
+
},
|
|
668
|
+
meta: {
|
|
669
|
+
title: item.title,
|
|
670
|
+
},
|
|
671
|
+
})),
|
|
672
|
+
};
|
|
673
|
+
};
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
在用户即将进入系统管理区之前,先预热动态路由接口:
|
|
677
|
+
|
|
678
|
+
```tsx
|
|
679
|
+
import { preloadDynamicRouteManifest } from "./dynamic-routes";
|
|
680
|
+
|
|
681
|
+
export function SystemEntryButton() {
|
|
682
|
+
return (
|
|
683
|
+
<button
|
|
684
|
+
onFocus={() => {
|
|
685
|
+
void preloadDynamicRouteManifest();
|
|
686
|
+
}}
|
|
687
|
+
onMouseEnter={() => {
|
|
688
|
+
void preloadDynamicRouteManifest();
|
|
689
|
+
}}
|
|
690
|
+
type="button"
|
|
691
|
+
>
|
|
692
|
+
进入系统管理
|
|
693
|
+
</button>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
#### 6.7.3 预加载建议
|
|
699
|
+
|
|
700
|
+
- 预加载触发点优先放在 `onMouseEnter`、`onFocus`、首屏空闲时机
|
|
701
|
+
- 不要对全站所有路由无差别预加载,否则会抵消懒加载收益
|
|
702
|
+
- 动态路由预加载建议只预热“路由清单接口”,页面模块仍由实际访问时再 lazy import
|
|
703
|
+
- 如果后续需要统一 API,可以在此包上继续增加 `preloadRoute` / `prefetchDynamicRoutes` 官方封装
|
|
704
|
+
|
|
705
|
+
## 7. 与参考源码的关系
|
|
706
|
+
|
|
707
|
+
参考目录里的思路本质上是:
|
|
708
|
+
|
|
709
|
+
- 静态路由先创建
|
|
710
|
+
- `patchRoutesOnNavigation` 在导航时补权限路由
|
|
711
|
+
- 动态请求和路由去重放在一个收口函数内
|
|
712
|
+
|
|
713
|
+
这个包保留了那套思想,但做了三点抽象:
|
|
714
|
+
|
|
715
|
+
1. 业务不再直接操作 `RouteObject`
|
|
716
|
+
2. 业务不再自己写 patch 去重逻辑
|
|
717
|
+
3. 业务可以直接返回“路由数组”或“patch 描述”
|
|
718
|
+
|
|
719
|
+
## 8. 方案权衡
|
|
720
|
+
|
|
721
|
+
### 易用性
|
|
722
|
+
|
|
723
|
+
- 优先让业务写声明式配置
|
|
724
|
+
- 把 `patchRoutesOnNavigation`、注册去重、导航缓存隐藏在包内
|
|
725
|
+
- 保留 `component` 这种业务更容易理解的字段
|
|
726
|
+
|
|
727
|
+
### 可扩展性
|
|
728
|
+
|
|
729
|
+
- `meta` 泛型可扩展
|
|
730
|
+
- `Extra` 泛型可扩展业务自定义字段
|
|
731
|
+
- `dynamic.loader` 支持多种动态来源
|
|
732
|
+
- `authorization` 可替换成任何业务权限判定逻辑
|
|
733
|
+
|
|
734
|
+
### 可维护性
|
|
735
|
+
|
|
736
|
+
- normalize、patcher、registry、runtime 分文件
|
|
737
|
+
- 不把适配器逻辑和业务字段转换揉成一个文件
|
|
738
|
+
- 后续接菜单、埋点、面包屑时只需要在 normalize 层或 handle 层扩展
|
|
739
|
+
|
|
740
|
+
### 性能
|
|
741
|
+
|
|
742
|
+
- 只在需要的导航上触发动态加载
|
|
743
|
+
- 同一路径支持缓存
|
|
744
|
+
- 路由 `id` 去重避免重复 patch
|
|
745
|
+
- `lazy` 仍然由 `react-router-dom` 原生消费
|
|
746
|
+
|
|
747
|
+
### 动态注入时机
|
|
748
|
+
|
|
749
|
+
- 放在导航期,而不是应用启动期
|
|
750
|
+
- 更适合权限路由、租户能力路由、插件路由
|
|
751
|
+
- 可以在拿到服务端返回后再决定注入哪些页面
|
|
752
|
+
|
|
753
|
+
### 与权限系统结合
|
|
754
|
+
|
|
755
|
+
- 简单鉴权可放 `authorization.resolve`
|
|
756
|
+
- 异步权限拉取建议放 `dynamic.loader`
|
|
757
|
+
- 页面数据级权限建议继续放页面 loader 或业务请求层
|
|
758
|
+
|
|
759
|
+
## 9. npm 发布与交付建议
|
|
760
|
+
|
|
761
|
+
### package.json 关键字段
|
|
762
|
+
|
|
763
|
+
当前包已采用:
|
|
764
|
+
|
|
765
|
+
- `main: ./dist/index.cjs`
|
|
766
|
+
- `module: ./dist/index.js`
|
|
767
|
+
- `types: ./dist/index.d.ts`
|
|
768
|
+
- `exports` 同时暴露根入口、`./core`、`./runtime`、`./types`
|
|
769
|
+
- `peerDependencies`
|
|
770
|
+
- `react`
|
|
771
|
+
- `react-dom`
|
|
772
|
+
- `react-router-dom`
|
|
773
|
+
- `files`
|
|
774
|
+
- `dist`
|
|
775
|
+
- `README.md`
|
|
776
|
+
|
|
777
|
+
### 构建产物策略
|
|
778
|
+
|
|
779
|
+
- `tsc --emitDeclarationOnly`
|
|
780
|
+
生成类型声明
|
|
781
|
+
- `swc` 生成 ESM
|
|
782
|
+
- `swc + rename-cjs.js`
|
|
783
|
+
生成 CJS
|
|
784
|
+
|
|
785
|
+
### ESM / CJS / d.ts 策略
|
|
786
|
+
|
|
787
|
+
- ESM 产物:`dist/**/*.js`
|
|
788
|
+
- CJS 产物:`dist/**/*.cjs`
|
|
789
|
+
- 类型声明:`dist/**/*.d.ts`
|
|
790
|
+
|
|
791
|
+
### README 最小内容
|
|
792
|
+
|
|
793
|
+
至少包含:
|
|
794
|
+
|
|
795
|
+
- 安装方式
|
|
796
|
+
- 快速开始
|
|
797
|
+
- `StaticRouteConfig` 字段说明
|
|
798
|
+
- `dynamic.loader` 示例
|
|
799
|
+
- 错误处理方式
|
|
800
|
+
- 版本与 peer dependency 说明
|
|
801
|
+
|
|
802
|
+
### 用户安装方式
|
|
803
|
+
|
|
804
|
+
```bash
|
|
805
|
+
pnpm add @vlian/router react-router-dom
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### React + Vite 最小接入示例
|
|
809
|
+
|
|
810
|
+
下面给出一个可以直接放进 React + Vite 项目的最小示例,目录大致如下:
|
|
811
|
+
|
|
812
|
+
```text
|
|
813
|
+
src
|
|
814
|
+
├── main.tsx
|
|
815
|
+
├── router.ts
|
|
816
|
+
├── routes.ts
|
|
817
|
+
├── dynamic-routes.ts
|
|
818
|
+
├── state
|
|
819
|
+
│ └── auth-store.ts
|
|
820
|
+
└── pages
|
|
821
|
+
├── home.tsx
|
|
822
|
+
├── login.tsx
|
|
823
|
+
└── system
|
|
824
|
+
└── users.tsx
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
#### 1. 安装依赖
|
|
828
|
+
|
|
829
|
+
```bash
|
|
830
|
+
pnpm add react-router-dom @vlian/router zustand
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
#### 2. 定义权限 store
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
// src/state/auth-store.ts
|
|
837
|
+
import { create } from "zustand";
|
|
838
|
+
|
|
839
|
+
type AuthState = {
|
|
840
|
+
permissions: string[];
|
|
841
|
+
setPermissions: (permissions: string[]) => void;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
export const authStore = create<AuthState>((set) => ({
|
|
845
|
+
permissions: [],
|
|
846
|
+
setPermissions: (permissions) => set({ permissions }),
|
|
847
|
+
}));
|
|
848
|
+
|
|
849
|
+
export const bootstrapAuth = async () => {
|
|
850
|
+
const response = await fetch("/api/auth/me");
|
|
851
|
+
if (!response.ok) {
|
|
852
|
+
throw new Error(`auth bootstrap failed: ${response.status}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const data = await response.json();
|
|
856
|
+
authStore.getState().setPermissions(data.permissions ?? []);
|
|
857
|
+
};
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
#### 3. 定义静态路由
|
|
861
|
+
|
|
862
|
+
```tsx
|
|
863
|
+
// src/routes.ts
|
|
864
|
+
import { Outlet } from "react-router-dom";
|
|
865
|
+
import { defineRoutes } from "@vlian/router";
|
|
866
|
+
|
|
867
|
+
function RootLayout({ children }: { children?: React.ReactNode }) {
|
|
868
|
+
return (
|
|
869
|
+
<div>
|
|
870
|
+
<header>Demo App</header>
|
|
871
|
+
<main>{children}</main>
|
|
872
|
+
</div>
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const loadHomeModule = () => import("./pages/home");
|
|
877
|
+
const loadLoginModule = () => import("./pages/login");
|
|
878
|
+
|
|
879
|
+
export const routes = defineRoutes([
|
|
880
|
+
{
|
|
881
|
+
id: "root",
|
|
882
|
+
path: "/",
|
|
883
|
+
component: Outlet,
|
|
884
|
+
layout: RootLayout,
|
|
885
|
+
children: [
|
|
886
|
+
{
|
|
887
|
+
index: true,
|
|
888
|
+
lazy: async () => {
|
|
889
|
+
const mod = await loadHomeModule();
|
|
890
|
+
return {
|
|
891
|
+
Component: mod.default,
|
|
892
|
+
};
|
|
893
|
+
},
|
|
894
|
+
meta: {
|
|
895
|
+
title: "首页",
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
id: "login",
|
|
902
|
+
path: "/login",
|
|
903
|
+
lazy: async () => {
|
|
904
|
+
const mod = await loadLoginModule();
|
|
905
|
+
return {
|
|
906
|
+
Component: mod.default,
|
|
907
|
+
};
|
|
908
|
+
},
|
|
909
|
+
meta: {
|
|
910
|
+
title: "登录",
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
]);
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
#### 4. 定义动态路由加载器
|
|
917
|
+
|
|
918
|
+
```ts
|
|
919
|
+
// src/dynamic-routes.ts
|
|
920
|
+
import type { DynamicRouteLoader } from "@vlian/router";
|
|
921
|
+
|
|
922
|
+
type AppMeta = {
|
|
923
|
+
title?: string;
|
|
924
|
+
permissionCode?: string;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const pageModuleLoaders: Record<string, () => Promise<any>> = {
|
|
928
|
+
"system/users": () => import("./pages/system/users"),
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
export const loadDynamicRoutes: DynamicRouteLoader<AppMeta> = async ({
|
|
932
|
+
path,
|
|
933
|
+
signal,
|
|
934
|
+
}) => {
|
|
935
|
+
if (!path.startsWith("/system")) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const response = await fetch("/api/auth/routes", { signal });
|
|
940
|
+
if (!response.ok) {
|
|
941
|
+
throw new Error(`route api failed: ${response.status}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const data = await response.json();
|
|
945
|
+
|
|
946
|
+
return {
|
|
947
|
+
parentId: "root",
|
|
948
|
+
cacheKey: `permission:${data.authzVersion}`,
|
|
949
|
+
routes: data.routes.map((item: any) => ({
|
|
950
|
+
id: item.code,
|
|
951
|
+
path: item.path.replace(/^\//, ""),
|
|
952
|
+
auth: {
|
|
953
|
+
required: true,
|
|
954
|
+
permissionCode: item.code,
|
|
955
|
+
},
|
|
956
|
+
meta: {
|
|
957
|
+
title: item.title,
|
|
958
|
+
permissionCode: item.code,
|
|
959
|
+
},
|
|
960
|
+
lazy: async () => {
|
|
961
|
+
const loadModule = pageModuleLoaders[item.component];
|
|
962
|
+
if (!loadModule) {
|
|
963
|
+
throw new Error(`unknown page module: ${item.component}`);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const mod = await loadModule();
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
Component: mod.default,
|
|
970
|
+
loader: mod.loader,
|
|
971
|
+
};
|
|
972
|
+
},
|
|
973
|
+
})),
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
#### 5. 创建 router
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
// src/router.ts
|
|
982
|
+
import { createAppRouter } from "@vlian/router";
|
|
983
|
+
import { routes } from "./routes";
|
|
984
|
+
import { loadDynamicRoutes } from "./dynamic-routes";
|
|
985
|
+
import { authStore } from "./state/auth-store";
|
|
986
|
+
|
|
987
|
+
export const router = createAppRouter({
|
|
988
|
+
mode: "browser",
|
|
989
|
+
routes,
|
|
990
|
+
authorization: {
|
|
991
|
+
resolve: ({ auth }) => {
|
|
992
|
+
if (!auth || auth === false) {
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const permissionCode =
|
|
997
|
+
typeof auth === "object" ? auth.permissionCode : undefined;
|
|
998
|
+
|
|
999
|
+
return (
|
|
1000
|
+
!permissionCode ||
|
|
1001
|
+
authStore.getState().permissions.includes(String(permissionCode))
|
|
1002
|
+
);
|
|
1003
|
+
},
|
|
1004
|
+
redirectTo: "/login",
|
|
1005
|
+
},
|
|
1006
|
+
dynamic: {
|
|
1007
|
+
defaultParentId: "root",
|
|
1008
|
+
loader: loadDynamicRoutes,
|
|
1009
|
+
getNavigationCacheKey: ({ path }) =>
|
|
1010
|
+
path.startsWith("/system") ? path : false,
|
|
1011
|
+
errorMode: "silent",
|
|
1012
|
+
},
|
|
1013
|
+
onError: ({ error, path }) => {
|
|
1014
|
+
console.error("dynamic route load failed", path, error);
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
#### 6. 在 Vite 入口挂载 RouterProvider
|
|
1020
|
+
|
|
1021
|
+
```tsx
|
|
1022
|
+
// src/main.tsx
|
|
1023
|
+
import React from "react";
|
|
1024
|
+
import ReactDOM from "react-dom/client";
|
|
1025
|
+
import { RouterProvider } from "react-router-dom";
|
|
1026
|
+
import { router } from "./router";
|
|
1027
|
+
import { bootstrapAuth } from "./state/auth-store";
|
|
1028
|
+
|
|
1029
|
+
const start = async () => {
|
|
1030
|
+
await bootstrapAuth();
|
|
1031
|
+
|
|
1032
|
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
1033
|
+
<React.StrictMode>
|
|
1034
|
+
<RouterProvider router={router} />
|
|
1035
|
+
</React.StrictMode>,
|
|
1036
|
+
);
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
void start();
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
#### 7. 页面文件示例
|
|
1043
|
+
|
|
1044
|
+
```tsx
|
|
1045
|
+
// src/pages/home.tsx
|
|
1046
|
+
export default function HomePage() {
|
|
1047
|
+
return <div>home page</div>;
|
|
1048
|
+
}
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
```tsx
|
|
1052
|
+
// src/pages/login.tsx
|
|
1053
|
+
export default function LoginPage() {
|
|
1054
|
+
return <div>login page</div>;
|
|
1055
|
+
}
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
```tsx
|
|
1059
|
+
// src/pages/system/users.tsx
|
|
1060
|
+
export async function loader() {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
export default function UsersPage() {
|
|
1065
|
+
return <div>users page</div>;
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
这个示例的运行过程是:
|
|
1070
|
+
|
|
1071
|
+
- 启动时先调用 `bootstrapAuth()` 初始化权限到 store
|
|
1072
|
+
- 创建 browser router,并注册静态路由
|
|
1073
|
+
- 当用户导航到 `/system/**` 时,`dynamic.loader` 发起接口请求
|
|
1074
|
+
- 接口返回后按 `parentId: "root"` 把动态路由补丁进当前路由树
|
|
1075
|
+
- 路由命中后再执行页面级 `lazy import`
|
|
1076
|
+
|
|
1077
|
+
### 用户引入方式
|
|
1078
|
+
|
|
1079
|
+
```ts
|
|
1080
|
+
import { createAppRouter, defineRoutes } from "@vlian/router";
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
### 初始化方式
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
const router = createAppRouter({
|
|
1087
|
+
mode: "browser",
|
|
1088
|
+
routes,
|
|
1089
|
+
});
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
### 产出可供下载使用的构建物
|
|
1093
|
+
|
|
1094
|
+
```bash
|
|
1095
|
+
pnpm --filter @vlian/router build
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
打包后发布:
|
|
1099
|
+
|
|
1100
|
+
```bash
|
|
1101
|
+
pnpm --filter @vlian/router publish --access public
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
## 10. 推荐后续增强项
|
|
1105
|
+
|
|
1106
|
+
- 增加 `beforePatch` / `afterPatch` 生命周期钩子
|
|
1107
|
+
- 支持多路动态 loader 组合
|
|
1108
|
+
- 把菜单树和路由树的映射收口成官方 helper
|
|
1109
|
+
- 增加 `prefetchDynamicRoutes(paths[])`
|
|
1110
|
+
- 增加测试覆盖:
|
|
1111
|
+
- normalize
|
|
1112
|
+
- dynamic patch 去重
|
|
1113
|
+
- cacheKey
|
|
1114
|
+
- redirect/layout/auth 包装
|
|
1115
|
+
|
|
1116
|
+
## 11. 当前落地状态
|
|
1117
|
+
|
|
1118
|
+
`packages/router` 已包含:
|
|
1119
|
+
|
|
1120
|
+
- 独立 npm 包骨架
|
|
1121
|
+
- 类型定义
|
|
1122
|
+
- 主工厂 `createAppRouter`
|
|
1123
|
+
- 静态路由 normalize
|
|
1124
|
+
- 动态路由 patcher
|
|
1125
|
+
- route registry
|
|
1126
|
+
- `AppRouterProvider` 子路径导出
|
|
1127
|
+
- npm 发布和构建配置
|
|
1128
|
+
|
|
1129
|
+
如果要继续落到生产可用版本,下一步建议补:
|
|
1130
|
+
|
|
1131
|
+
- 单元测试
|
|
1132
|
+
- 真实业务示例
|
|
1133
|
+
- changelog
|
|
1134
|
+
- 发布流水线
|