@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.
Files changed (41) hide show
  1. package/README.md +1134 -0
  2. package/dist/adapters/create-app-router.cjs +1 -0
  3. package/dist/adapters/create-app-router.d.ts +4 -0
  4. package/dist/adapters/create-app-router.js +1 -0
  5. package/dist/adapters/index.cjs +1 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/core/guards/route-shell.cjs +1 -0
  9. package/dist/core/guards/route-shell.d.ts +13 -0
  10. package/dist/core/guards/route-shell.js +1 -0
  11. package/dist/core/index.cjs +1 -0
  12. package/dist/core/index.d.ts +3 -0
  13. package/dist/core/index.js +1 -0
  14. package/dist/core/normalize/normalize-routes.cjs +1 -0
  15. package/dist/core/normalize/normalize-routes.d.ts +8 -0
  16. package/dist/core/normalize/normalize-routes.js +1 -0
  17. package/dist/core/normalize/route-id.cjs +1 -0
  18. package/dist/core/normalize/route-id.d.ts +2 -0
  19. package/dist/core/normalize/route-id.js +1 -0
  20. package/dist/core/patcher/create-navigation-patcher.cjs +1 -0
  21. package/dist/core/patcher/create-navigation-patcher.d.ts +12 -0
  22. package/dist/core/patcher/create-navigation-patcher.js +1 -0
  23. package/dist/core/runtime/route-registry.cjs +1 -0
  24. package/dist/core/runtime/route-registry.d.ts +9 -0
  25. package/dist/core/runtime/route-registry.js +1 -0
  26. package/dist/index.cjs +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +1 -0
  29. package/dist/runtime/AppRouterProvider.cjs +1 -0
  30. package/dist/runtime/AppRouterProvider.d.ts +6 -0
  31. package/dist/runtime/AppRouterProvider.js +1 -0
  32. package/dist/runtime/index.cjs +1 -0
  33. package/dist/runtime/index.d.ts +2 -0
  34. package/dist/runtime/index.js +1 -0
  35. package/dist/types/index.cjs +1 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/index.js +1 -0
  38. package/dist/types/route.cjs +1 -0
  39. package/dist/types/route.d.ts +116 -0
  40. package/dist/types/route.js +1 -0
  41. 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
+ - 发布流水线