@vlian/router 0.1.0 → 0.1.1
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/DESIGN.md
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
# @vlian/router 设计文档
|
|
2
|
+
|
|
3
|
+
本文档在 `README.md` 示例与快速上手之外,从**架构、三类路由(静态 / 动态 / 权限)、API 与类型**角度完整描述 `packages/router` 的实现契约,便于评审与二次封装。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 文档索引
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
| 你要做的事 | 优先阅读 |
|
|
11
|
+
| -------------------------------- | ------------------------------------------------------------------- |
|
|
12
|
+
| 判断 `@vlian/router` 是否适合当前项目 | [1. 目标与边界](#1-目标与边界)、[使用约束](#使用约束) |
|
|
13
|
+
| 接入静态路由、布局、重定向、元信息 | [3. 静态路由(详细)](#3-静态路由详细)、[9.1 仅静态路由](#91-仅静态路由含-redirectlayoutmeta) |
|
|
14
|
+
| 接入后端菜单、权限路由或按需模块 | [4. 动态路由(详细)](#4-动态路由详细)、[9.3 静态 + 动态 + 权限](#93-静态--动态--权限完整链路) |
|
|
15
|
+
| 统一登录态、权限码、无权限降级 | [5. 权限路由(详细)](#5-权限路由详细)、[9.2 静态路由 + 权限](#92-静态路由--权限) |
|
|
16
|
+
| 查找公开导入路径和导出符号 | [导出索引](#导出索引)、[6. 公开 API 一览](#6-公开-api-一览) |
|
|
17
|
+
| 了解内部 normalize、patch、registry 契约 | [2. 模块分层与依赖关系](#2-模块分层与依赖关系)、[7. 内部导出](#7-内部导出vlianroutercore) |
|
|
18
|
+
| 按工程规范拆分路由模块 | [接入规范](#接入规范)、[9.4 推荐目录拆分示例](#94-推荐目录拆分示例) |
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. 目标与边界
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
| 目标 | 说明 |
|
|
27
|
+
| -------------- | ----------------------------------------------------------------------------------------- |
|
|
28
|
+
| 统一输入模型 | 业务只维护 `StaticRouteConfig` 树与可选的 `dynamic.loader` |
|
|
29
|
+
| 对接 Data Router | 基于 `react-router-dom` 的 `createBrowserRouter` / `createHashRouter` / `createMemoryRouter` |
|
|
30
|
+
| 导航期动态注入 | 使用 `patchRoutesOnNavigation` 按需挂载动态路由,并与已注册 `id` 去重 |
|
|
31
|
+
| 业务字段收口 | `meta`、`auth`、`redirect`、`layout` 在 normalize 与 Route Shell 中统一处理 |
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
| 非目标 | 说明 |
|
|
36
|
+
| ------ | ---------------------------------------------------------------- |
|
|
37
|
+
| 替代权限中心 | `authorization` 只做前端展示/导航控制,安全边界仍在接口与后端 |
|
|
38
|
+
| 规定菜单协议 | 动态路由数据形态由业务接口决定,包只消费 `StaticRouteConfig[]` 或 `DynamicRoutePatch` |
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 使用约束
|
|
44
|
+
|
|
45
|
+
`@vlian/router` 是对 `react-router-dom` Data Router 的业务封装。接入方应遵守下列约束,否则容易出现动态补丁失效、重复挂载、权限判断时序不稳定等问题。
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
| 约束 | 要求 | 原因 |
|
|
49
|
+
| ----------- | -------------------------------------------------------------------------------- | ------------------------------------------------ |
|
|
50
|
+
| Router 底座 | 仅面向 `react-router-dom@7` 的 Data Router;不支持 `BrowserRouter`/`HashRouter` 组件式包裹模式 | 动态注入依赖 `patchRoutesOnNavigation` |
|
|
51
|
+
| Peer 依赖 | 消费端必须提供 `react`、`react-dom`、`react-router-dom`,包内不内置这些依赖 | 避免多 React 实例和路由上下文冲突 |
|
|
52
|
+
| Router 生命周期 | `createAppRouter(options)` 产物应在应用启动期创建;使用 `AppRouterProvider` 时必须稳定 `options` 引用 | `options` 变化会重建整个 router,导致注册表、缓存与导航状态重置 |
|
|
53
|
+
| 路由 id | 被动态补丁挂载、菜单定位、面包屑、测试引用的路由必须手写稳定 `id` | 自动 id 含同级位置,路由顺序变化会改变 id |
|
|
54
|
+
| 动态父节点 | `dynamic.defaultParentId` 或 `DynamicRoutePatch.parentId` 指向的父路由必须已存在且 id 稳定 | React Router 的 `patch(parentId, routes)` 需要可定位父级 |
|
|
55
|
+
| 动态路径 | 动态子路由挂到父路由下时,`path` 推荐使用相对片段,不要带前导 `/` | 便于和父路由组成层级路径,避免和根路径语义混淆 |
|
|
56
|
+
| 动态加载 | `dynamic.loader` 必须尊重 `signal`,网络请求应传入 `{ signal }`;被取消的请求不应继续写外部状态 | 导航可中断,避免过期 patch 或竞态 |
|
|
57
|
+
| 权限判断 | `authorization.resolve` 必须是同步、快速、无副作用判断;异步权限拉取放在应用启动、loader 或 `dynamic.loader` | Route Shell 渲染期不等待异步权限 |
|
|
58
|
+
| 安全边界 | 前端 `auth` 只做展示、导航与体验控制;接口访问控制仍必须由后端完成 | 前端路由不可作为安全边界 |
|
|
59
|
+
| 自定义字段 | 业务扩展字段可放入配置对象,但不要和保留键重名;读取时优先通过 `route.__extra` 或 `handle.meta` | normalize 会把保留键用于内部转换 |
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 接入规范
|
|
65
|
+
|
|
66
|
+
### 模块组织
|
|
67
|
+
|
|
68
|
+
应用侧不要把路由声明、动态加载、权限判断、store 读取、页面组件导入堆在同一个文件。推荐按职责拆分:
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
| 文件 | 职责 |
|
|
72
|
+
| -------------------------- | ------------------------------------------------------------ |
|
|
73
|
+
| `routes/types.ts` | 定义 `AppRouteMeta`、业务扩展字段、接口菜单类型 |
|
|
74
|
+
| `routes/static-routes.tsx` | 只声明基础静态路由树、布局、重定向和公共 meta |
|
|
75
|
+
| `routes/dynamic-loader.ts` | 只负责把后端菜单或本地模块映射成 `StaticRouteConfig[]`/`DynamicRoutePatch` |
|
|
76
|
+
| `routes/authorization.ts` | 只负责同步权限判断、无权限跳转和降级视图 |
|
|
77
|
+
| `routes/router.ts` | 只组装 `createAppRouter` 或导出 provider 使用的 `RouterPluginOptions` |
|
|
78
|
+
| `routes/index.ts` | 可选,聚合对应用入口暴露的 router/options |
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
### 导入规范
|
|
82
|
+
|
|
83
|
+
- 业务入口使用 `@vlian/router` 导入 `createAppRouter`、`defineRoutes` 和公开类型。
|
|
84
|
+
- React 渲染入口使用 `@vlian/router/provider` 或 `@vlian/router/runtime` 导入 `AppRouterProvider`。
|
|
85
|
+
- 测试、自定义适配器、调试工具才允许从 `@vlian/router/core` 导入 `normalizeRoutes`、`RouteRegistry`、`createNavigationPatcher`。
|
|
86
|
+
- 只使用类型时写 `import type { ... } from "@vlian/router"`,避免引入不必要运行时代码。
|
|
87
|
+
- 不从 `src/`** 或 `dist/**` 深路径导入,发布包只承诺 `exports` 中声明的入口稳定。
|
|
88
|
+
|
|
89
|
+
### 命名与数据规范
|
|
90
|
+
|
|
91
|
+
- 根布局推荐固定写 `id: "root"`;登录页写 `id: "login"`;动态业务页用后端稳定 code,例如 `system.user.list`。
|
|
92
|
+
- 同一个 router 实例内 `id` 必须全局唯一,静态和动态路由共享同一注册表。
|
|
93
|
+
- `meta` 放 UI 语义,例如 `title`、`icon`、`breadcrumb`、`hiddenInMenu`。
|
|
94
|
+
- `auth` 放权限语义,例如 `required`、`permissionCode`、`redirectTo`。
|
|
95
|
+
- 后端菜单原始字段不要直接散落到路由主文件;先在 `dynamic-loader.ts` 内转换为包消费的 `StaticRouteConfig`。
|
|
96
|
+
- `redirect` 路由不应同时承载业务页面逻辑;需要业务判断时使用 RR `loader` 或页面组件自行处理。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 2. 模块分层与依赖关系
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
adapters/create-app-router.ts
|
|
104
|
+
├── normalizeRoutes (静态 + 动态二次 normalize)
|
|
105
|
+
├── RouteRegistry
|
|
106
|
+
├── createNavigationPatcher (仅当 options.dynamic 存在)
|
|
107
|
+
└── createBrowserRouter | createHashRouter | createMemoryRouter
|
|
108
|
+
|
|
109
|
+
core/normalize/normalize-routes.ts
|
|
110
|
+
├── createRouteId
|
|
111
|
+
└── createRouteShellComponent (layout / auth / redirect / 纯 Outlet)
|
|
112
|
+
|
|
113
|
+
core/patcher/create-navigation-patcher.ts
|
|
114
|
+
└── 调用 dynamic.loader → normalize → registry.filterUnregistered → patch
|
|
115
|
+
|
|
116
|
+
runtime/AppRouterProvider.tsx
|
|
117
|
+
└── useMemo(createAppRouter) + RouterProvider
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**导入路径约定**
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
| 路径 | 用途 |
|
|
124
|
+
| -------------------------------------------------- | ------------------------------------------------------------------ |
|
|
125
|
+
| `@vlian/router` | `createAppRouter`、`defineRoutes` 及主入口类型 |
|
|
126
|
+
| `@vlian/router/runtime` 或 `@vlian/router/provider` | `AppRouterProvider` |
|
|
127
|
+
| `@vlian/router/core` | `normalizeRoutes`、`createNavigationPatcher`、`RouteRegistry`(高级/测试) |
|
|
128
|
+
| `@vlian/router/types` | 仅类型 |
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 导出索引
|
|
134
|
+
|
|
135
|
+
### 包入口索引
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
| 导入路径 | 导出内容 | 推荐场景 |
|
|
139
|
+
| ------------------------ | ------------------------------------------------------------------- | ----------------------- |
|
|
140
|
+
| `@vlian/router` | `createAppRouter`、`defineRoutes`、`AppRouterProviderProps` 类型、全部公开类型 | 应用侧默认入口 |
|
|
141
|
+
| `@vlian/router/provider` | `AppRouterProvider`、`AppRouterProviderProps` | React 应用入口直接渲染 provider |
|
|
142
|
+
| `@vlian/router/runtime` | 同 `provider` | 需要按运行时语义导入 provider 的场景 |
|
|
143
|
+
| `@vlian/router/core` | `normalizeRoutes`、`createNavigationPatcher`、`RouteRegistry` | 单元测试、自定义适配器、调试 |
|
|
144
|
+
| `@vlian/router/types` | 全部类型导出 | 类型集中导入或工具包复用 |
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
### 主入口类型索引
|
|
148
|
+
|
|
149
|
+
`@vlian/router` 主入口导出的类型按职责可分为:
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
| 分类 | 类型 |
|
|
153
|
+
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| Router 配置 | `RouterPluginOptions`、`AppRouterMode` |
|
|
155
|
+
| 静态路由 | `StaticRouteConfig`、`AppRouteObject`、`AppRouteHandle`、`DefaultRouteMeta`、`RouteExtraFields` |
|
|
156
|
+
| 动态路由 | `DynamicRoutingOptions`、`DynamicRouteLoader`、`DynamicRouteLoaderContext`、`DynamicRouteLoadResult`、`DynamicRoutePatch`、`AppRoutePatchFunction` |
|
|
157
|
+
| 权限 | `RouteAuthConfig`、`RouteAuthorizationOptions`、`RouteAuthorizationContext`、`RouteAuthorizationFallbackProps` |
|
|
158
|
+
| 布局 | `RouteLayout`、`RouteLayoutDescriptor`、`RouteLayoutProps` |
|
|
159
|
+
| 重定向与懒加载 | `RouteRedirect`、`RedirectRouteConfig`、`RouteLazyLoader` |
|
|
160
|
+
| 错误处理 | `RouterErrorHandler`、`RouterErrorHandlerContext` |
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 3. 静态路由(详细)
|
|
166
|
+
|
|
167
|
+
静态路由指在 `createAppRouter({ routes })` 时一次性传入的 `StaticRouteConfig` 树,经 `normalizeRoutes(..., { source: "static" })` 转为 `AppRouteObject[]`。
|
|
168
|
+
|
|
169
|
+
### 3.1 `StaticRouteConfig` 字段说明
|
|
170
|
+
|
|
171
|
+
#### 与 `react-router-dom` 对齐的字段
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
| 字段 | 类型 | 说明 |
|
|
175
|
+
| -------------------------------------------- | ----------------------- | ------------------------------------------------------- |
|
|
176
|
+
| `path` | `string` | 路径片段,规则同 RR |
|
|
177
|
+
| `index` | `boolean` | 索引路由 |
|
|
178
|
+
| `caseSensitive` | `boolean` | 是否大小写敏感 |
|
|
179
|
+
| `component` / `Component` | 组件 | 业务推荐写 `component`,normalize 会读 `Component ?? component` |
|
|
180
|
+
| `element` | `ReactNode` | 与 component 二选一或组合(见 Route Shell) |
|
|
181
|
+
| `lazy` | `RouteLazyLoader` | 懒加载路由模块 |
|
|
182
|
+
| `loader` | `RouteObject["loader"]` | Data API |
|
|
183
|
+
| `action` | `RouteObject["action"]` | 表单等 |
|
|
184
|
+
| `shouldRevalidate` | 同 RR | |
|
|
185
|
+
| `errorElement` / `ErrorBoundary` | | 错误边界 |
|
|
186
|
+
| `hydrateFallbackElement` / `HydrateFallback` | | |
|
|
187
|
+
| `handle` | `RouteObject["handle"]` | 会与内部合并进 `AppRouteHandle` |
|
|
188
|
+
| `children` | `StaticRouteConfig[]` | 子路由 |
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
#### 包扩展字段
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
| 字段 | 类型 | 说明 |
|
|
195
|
+
| ---------- | ----------------- | -------------------------------------------------------------- |
|
|
196
|
+
| `id` | `string` | 可选;**显式 id 优先**。未提供时由 `createRouteId` 根据父 id、path/index、兄弟序号生成 |
|
|
197
|
+
| `meta` | `Meta` | 写入 `handle.meta`,供面包屑、标题等使用 |
|
|
198
|
+
| `layout` | `RouteLayout` | 函数或 `{ component, props }`,在 Shell 内包裹页面内容 |
|
|
199
|
+
| `auth` | `RouteAuthConfig` | 是否参与鉴权及自定义字段(见第 5 节) |
|
|
200
|
+
| `redirect` | `RouteRedirect` | 字符串或 `{ to, replace?, state? }`;字符串默认 `replace: true` |
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#### `Extra` 扩展字段
|
|
204
|
+
|
|
205
|
+
除上表「保留键」外,配置对象上**其余键**会落入 `AppRouteObject.__extra`,便于业务挂自定义标记而无需改包类型。
|
|
206
|
+
|
|
207
|
+
保留键集合(不会进入 `__extra`):`id`、`path`、`index`、`caseSensitive`、`component`、`Component`、`element`、`lazy`、`loader`、`action`、`shouldRevalidate`、`errorElement`、`ErrorBoundary`、`hydrateFallbackElement`、`HydrateFallback`、`handle`、`meta`、`layout`、`auth`、`redirect`、`children`。
|
|
208
|
+
|
|
209
|
+
### 3.2 路由 id 生成规则(`createRouteId`)
|
|
210
|
+
|
|
211
|
+
1. 若 `route.id` 为非空字符串(trim 后),**直接使用**。
|
|
212
|
+
2. 否则:
|
|
213
|
+
- `index: true` → 段名为 `index`
|
|
214
|
+
- `path` 缺省或 `/` → 段名为 `root`
|
|
215
|
+
- 否则将 `path` 按 `/` 分段,每段规范化(小写、非字母数字转 `-`)后用 `.` 连接
|
|
216
|
+
3. 最终:`无父` → `${selfId}.${position}`;`有父` → `${parentId}.${selfId}.${position}`(`position` 为同级下标)。
|
|
217
|
+
|
|
218
|
+
**实践建议**:需要被 `dynamic.defaultParentId` 或 `DynamicRoutePatch.parentId` 引用的节点(如根布局)应**手写稳定 `id`**(如 `root`)。
|
|
219
|
+
|
|
220
|
+
### 3.3 `handle` 合并结果(`AppRouteHandle`)
|
|
221
|
+
|
|
222
|
+
normalize 后:
|
|
223
|
+
|
|
224
|
+
- `handle.meta` = 配置的 `meta`
|
|
225
|
+
- `handle.auth` = 配置的 `auth`
|
|
226
|
+
- `handle.source` = `"static"` 或 `"dynamic"`(动态补丁进来的子树为 `"dynamic"`)
|
|
227
|
+
|
|
228
|
+
原 `handle` 对象若为普通对象会展开合并。
|
|
229
|
+
|
|
230
|
+
### 3.4 何时包一层 Route Shell(`needsRouteShell`)
|
|
231
|
+
|
|
232
|
+
在存在 `redirect`,或 `layout`,或 **(`auth` 且提供了 `authorization`)**,或 **普通组件/元素路由** 等组合时,会用 `createRouteShellComponent` 包装。
|
|
233
|
+
|
|
234
|
+
- **仅 `lazy` 路由**:在 `lazy` 解析完成后若需要 Shell,会把解析出的 `Component`/`element` 与原始 `auth`/`layout` 等一并传入 Shell。
|
|
235
|
+
- **纯父级仅有 `children`** 且无 component/element/lazy 时,可能不包 Shell;有 `children` 的叶子在 Shell 内无内容时可渲染 `<Outlet />`(见 `renderRouteContent`)。
|
|
236
|
+
|
|
237
|
+
### 3.5 `redirect` 行为
|
|
238
|
+
|
|
239
|
+
- `redirect: "/login"` → 规范为 `{ to: "/login", replace: true }`
|
|
240
|
+
- 对象形式默认 `replace: true`,可被覆盖
|
|
241
|
+
- Shell 内优先渲染 `<Navigate />`,不渲染业务组件
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 4. 动态路由(详细)
|
|
246
|
+
|
|
247
|
+
动态路由**不是**单独 API,而是通过 `RouterPluginOptions.dynamic` 启用:内部注册 `patchRoutesOnNavigation`(`createNavigationPatcher`)。
|
|
248
|
+
|
|
249
|
+
### 4.1 触发时机与上下文
|
|
250
|
+
|
|
251
|
+
每次导航时,React Router 可能调用 patch 函数,传入 `{ path, signal, matches, patch }`。本包构造:
|
|
252
|
+
|
|
253
|
+
`DynamicRouteLoaderContext`
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
| 字段 | 说明 |
|
|
257
|
+
| --------------- | ----------------------------------------------- |
|
|
258
|
+
| `path` | 当前导航路径 |
|
|
259
|
+
| `signal` | `AbortSignal`,请求应传入以便取消 |
|
|
260
|
+
| `matches` | 当前已匹配路由(类型为带 `AppRouteObject` 的 match) |
|
|
261
|
+
| `router` | `DataRouter` 实例 |
|
|
262
|
+
| `knownRouteIds` | **当前已注册**的所有路由 id 快照(含静态 + 已补丁动态),用于业务侧判断是否重复拉取 |
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
### 4.2 `DynamicRoutingOptions`
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
| 字段 | 类型 | 说明 |
|
|
269
|
+
| ----------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
|
270
|
+
| `loader` | `DynamicRouteLoader` | **必填**,返回见下节 |
|
|
271
|
+
| `defaultParentId` | `string` 或 `null` | 当返回值未指定 `parentId` 时,作为 `patch(parentId, routes)` 的父节点 id |
|
|
272
|
+
| `getNavigationCacheKey` | `(context) => string` 或 `false` | 默认 `() => path`。返回的 key 用于**导航级去重**:同一 key 已处理则直接 return,不再调 `loader` |
|
|
273
|
+
| `errorMode` | `"silent"` 或 `"throw"` | 默认 `silent`:`loader` 抛错时调用 `onError`,不向上抛;`throw` 时在 `onError` 后仍抛出 |
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
### 4.3 `DynamicRouteLoader` 返回值(`DynamicRouteLoadResult`)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
| 返回值 | 行为 |
|
|
280
|
+
| --------------------- | ------------------------------------------------------ |
|
|
281
|
+
| `void` / `undefined` | 不注入任何路由 |
|
|
282
|
+
| `StaticRouteConfig[]` | 视为单个 patch:`{ parentId: defaultParentId, routes: 数组 }` |
|
|
283
|
+
| `DynamicRoutePatch` | 指定 `parentId`、`routes`,可选 `cacheKey` |
|
|
284
|
+
| `DynamicRoutePatch[]` | 多次 patch,依次处理 |
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
`DynamicRoutePatch`
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
| 字段 | 说明 |
|
|
291
|
+
| ---------- | -------------------------------------------------------------------------------- |
|
|
292
|
+
| `parentId` | 挂到哪个父路由的 `children`;缺省用 `defaultParentId` |
|
|
293
|
+
| `routes` | 一批静态配置(会再走 `normalizeRoutes`,`source: "dynamic"`) |
|
|
294
|
+
| `cacheKey` | 若为非 `false`,该 patch 处理成功后会把此 key 加入**已解析集合**,后续相同 key 可跳过重复注入逻辑(与导航 cache 不同,见下) |
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
### 4.4 去重与注册表(`RouteRegistry`)
|
|
298
|
+
|
|
299
|
+
1. `normalizeRoutes` 后为每条路由生成稳定 `id`。
|
|
300
|
+
2. `registry.filterUnregistered(routes)` **过滤掉 id 已存在的路由**,只把新 id 交给 `patch`。
|
|
301
|
+
3. `registry.register` 把新 id 记入集合。
|
|
302
|
+
|
|
303
|
+
因此同一动态路由**重复返回相同 id** 不会重复挂载。
|
|
304
|
+
|
|
305
|
+
### 4.5 并发与 in-flight
|
|
306
|
+
|
|
307
|
+
- 使用 `navigationCacheKey`(默认即 `path`)作为 key;若已有同 key 的加载任务在执行,后续导航会 **await 同一 Promise** 后返回,避免 thundering herd。
|
|
308
|
+
- `navigationCacheKey === false` 时使用 `pending:${path}:${Date.now()}` 作为 in-flight key(几乎不合并)。
|
|
309
|
+
|
|
310
|
+
### 4.6 错误处理(`onError`)
|
|
311
|
+
|
|
312
|
+
`RouterErrorHandlerContext`:`DynamicRouteLoaderContext` 加上 `error` 与 `phase: "dynamic-route-loading"`。
|
|
313
|
+
|
|
314
|
+
动态加载失败时:先 `await onError?.(context)`,再根据 `errorMode` 决定是否重新抛出。
|
|
315
|
+
|
|
316
|
+
### 4.7 静态 vs 动态在 `handle.source` 上的差异
|
|
317
|
+
|
|
318
|
+
- 初始 `routes` → `source: "static"`
|
|
319
|
+
- `patch` 注入的配置 → `source: "dynamic"`
|
|
320
|
+
|
|
321
|
+
若业务按 `handle.source` 区分埋点或调试,需知晓此约定。
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 5. 权限路由(详细)
|
|
326
|
+
|
|
327
|
+
权限由两部分协作:**路由上的 `auth`** + **创建 Router 时的 `authorization`**。
|
|
328
|
+
|
|
329
|
+
### 5.1 `RouteAuthConfig`
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
| 形态 | 含义 |
|
|
333
|
+
| --------------------------------------------------------------------- | ----------------------------------------------------- |
|
|
334
|
+
| `boolean` | `true`/`false` 简单开关(具体解释由 `authorization.resolve` 决定) |
|
|
335
|
+
| `{ required?: boolean; redirectTo?: string; [key: string]: unknown }` | 可在对象上挂 `permissionCode` 等自定义字段,供 `resolve` 读取 |
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
包**不**内置「必须登录」语义,一律通过 `authorization.resolve` 解释。
|
|
339
|
+
|
|
340
|
+
### 5.2 `RouteAuthorizationOptions`
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
| 字段 | 类型 | 说明 |
|
|
344
|
+
| -------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
|
|
345
|
+
| `resolve` | `(context: RouteAuthorizationContext) => boolean` | **必填**。`context` 含 `route`(`AppRouteObject`)与 `auth` |
|
|
346
|
+
| `redirectTo` | `string` | 全局默认:无权限时跳转路径(可被路由级 `auth.redirectTo` 覆盖) |
|
|
347
|
+
| `renderDenied` | `ReactNode` 或 `ComponentType<RouteAuthorizationFallbackProps>` | 无权限且不跳转时渲染 |
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
**判定顺序(`createRouteShellComponent`)**
|
|
351
|
+
|
|
352
|
+
1. 若 `config.auth && config.authorization` 同时存在:
|
|
353
|
+
- `resolve({ route, auth }) === false` 时:
|
|
354
|
+
- 优先:若 `auth` 为对象且含 `redirectTo` 字符串 → 跳转该路径;
|
|
355
|
+
- 否则:若 `authorization.redirectTo` 为字符串 → 跳转;
|
|
356
|
+
- 否则:渲染 `renderDenied`(组件则传入 `{ route, auth }`),二者皆无则 `null`。
|
|
357
|
+
2. 若**仅有** `auth` 而没有传入 `authorization`,Shell **不会**拦截(`auth` 仅写入 `handle` 供别处读取)。
|
|
358
|
+
|
|
359
|
+
因此:**要做路由级权限拦截,必须同时配置 `auth` 与 `authorization`。**
|
|
360
|
+
|
|
361
|
+
### 5.3 与动态路由的组合
|
|
362
|
+
|
|
363
|
+
常见做法:接口返回的菜单/路由带上 `permissionCode`,动态 `normalize` 后每条路由带 `auth`,全局同一个 `authorization.resolve` 读全局 store 或 token 解析结果。
|
|
364
|
+
|
|
365
|
+
异步拉取权限列表应在 `dynamic.loader` 或应用启动阶段完成;`resolve` 内建议只做同步判断。
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## 6. 公开 API 一览
|
|
370
|
+
|
|
371
|
+
### 6.1 `defineRoutes(routes)`
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
| 参数 | 说明 |
|
|
375
|
+
| -------- | ---------------------------------- |
|
|
376
|
+
| `routes` | `StaticRouteConfig<Meta, Extra>[]` |
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
**返回值**:原数组,类型收窄为 `StaticRouteConfig[]`。
|
|
380
|
+
|
|
381
|
+
**作用**:仅 TypeScript 辅助,运行时为恒等函数。
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
### 6.2 `createAppRouter(options)`
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
| 参数 `RouterPluginOptions` | 说明 |
|
|
389
|
+
| --------------------------------- | ----------------------------------- |
|
|
390
|
+
| `routes` | **必填**,静态路由树 |
|
|
391
|
+
| `mode` | `"browser"`(默认)、`"hash"`、`"memory"` |
|
|
392
|
+
| `basename` | 同 RR |
|
|
393
|
+
| `future` | Memory/Browser/Hash 的 future 选项 |
|
|
394
|
+
| `hydrationData` | 仅 memory/browser 等支持的 hydration |
|
|
395
|
+
| `getContext` | RR 7 `getContext` |
|
|
396
|
+
| `dataStrategy` | RR 7 data strategy |
|
|
397
|
+
| `initialEntries` / `initialIndex` | 仅 `memory` |
|
|
398
|
+
| `window` | 自定义 `window`(测试或 iframe) |
|
|
399
|
+
| `dynamic` | 启用动态路由,见第 4 节 |
|
|
400
|
+
| `authorization` | 权限,见第 5 节 |
|
|
401
|
+
| `onError` | 动态加载阶段错误,见第 4.6 节 |
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
**返回值**:`DataRouter`,可直接给 `<RouterProvider router={...} />`。
|
|
405
|
+
|
|
406
|
+
**实现要点**:先 `normalizeRoutes` 静态树并 `new RouteRegistry`;若存在 `dynamic`,则把 `createNavigationPatcher` 赋给 `patchRoutesOnNavigation`,并在 patcher 内持有 `getRouter()` 以拿到同一 `router` 实例。
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
### 6.3 `AppRouterProvider`(`@vlian/router/runtime`)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
| 属性 | 说明 |
|
|
414
|
+
| --------- | -------------------------------------------- |
|
|
415
|
+
| `options` | `RouterPluginOptions`,会传入 `createAppRouter` |
|
|
416
|
+
| 其余 | 透传 `RouterProvider` 的 props(**不含** `router`) |
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
内部 `useMemo(() => createAppRouter(options), [options])`:`**options` 引用变化会重建整个 router**,生产环境建议 `useMemo` 稳定化 options 对象。
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## 7. 内部导出(`@vlian/router/core`)
|
|
424
|
+
|
|
425
|
+
适用于测试、调试或自有适配器。
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
| 符号 | 说明 |
|
|
429
|
+
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
430
|
+
| `normalizeRoutes(routes, options)` | `options`: `{ authorization?, parentId?, source }`;`source` 为 `"static"` 或 `"dynamic"`;动态补丁时传入父 `parentId` 以生成正确子 id |
|
|
431
|
+
| `createNavigationPatcher({ dynamic, authorization, registry, getRouter, onError })` | 返回 RR 的 `PatchRoutesOnNavigationFunction` |
|
|
432
|
+
| `RouteRegistry` | `isRegistered`、`register`、`filterUnregistered`、`snapshot` |
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## 8. 类型别名速查
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
| 类型 | 用途 |
|
|
441
|
+
| --------------------------------- | --------------------------------------------------------- |
|
|
442
|
+
| `AppRouteObject` | 带必选 `id`、可选 `children`、`handle`、`__extra` 的 `RouteObject` |
|
|
443
|
+
| `AppRoutePatchFunction` | 等价 RR 的 `PatchRoutesOnNavigationFunction` |
|
|
444
|
+
| `RouteLayout` | 组件或 `{ component, props }` |
|
|
445
|
+
| `RouteAuthorizationContext` | `{ route, auth }` |
|
|
446
|
+
| `RouteAuthorizationFallbackProps` | 同 context,用于 `renderDenied` 组件 props |
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## 9. 综合示例
|
|
452
|
+
|
|
453
|
+
### 9.1 仅静态路由(含 redirect、layout、meta)
|
|
454
|
+
|
|
455
|
+
```tsx
|
|
456
|
+
import { Outlet } from "react-router-dom";
|
|
457
|
+
import { defineRoutes } from "@vlian/router";
|
|
458
|
+
|
|
459
|
+
function AppLayout(props: { children?: React.ReactNode; route: import("@vlian/router").AppRouteObject }) {
|
|
460
|
+
return (
|
|
461
|
+
<div>
|
|
462
|
+
<header>{String(props.route.handle?.meta?.title ?? "")}</header>
|
|
463
|
+
{props.children}
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export const routes = defineRoutes([
|
|
469
|
+
{
|
|
470
|
+
id: "root",
|
|
471
|
+
path: "/",
|
|
472
|
+
component: Outlet,
|
|
473
|
+
layout: AppLayout,
|
|
474
|
+
meta: { title: "根" },
|
|
475
|
+
children: [
|
|
476
|
+
{ index: true, redirect: "/home" },
|
|
477
|
+
{
|
|
478
|
+
path: "home",
|
|
479
|
+
id: "home",
|
|
480
|
+
component: () => <div>Home</div>,
|
|
481
|
+
meta: { title: "首页" },
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### 9.2 静态路由 + 权限
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { createAppRouter } from "@vlian/router";
|
|
492
|
+
import { routes } from "./routes";
|
|
493
|
+
|
|
494
|
+
export const router = createAppRouter({
|
|
495
|
+
routes,
|
|
496
|
+
authorization: {
|
|
497
|
+
resolve: ({ auth }) => {
|
|
498
|
+
if (auth === false || auth === undefined) return true;
|
|
499
|
+
if (auth === true) return Boolean(getToken());
|
|
500
|
+
if (typeof auth === "object" && auth.required === false) return true;
|
|
501
|
+
return hasPermission((auth as { code?: string }).code);
|
|
502
|
+
},
|
|
503
|
+
redirectTo: "/login",
|
|
504
|
+
renderDenied: () => <div>无权限</div>,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
路由上:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
{ path: "admin", component: AdminPage, auth: { required: true, code: "admin" } }
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### 9.3 静态 + 动态 + 权限(完整链路)
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import { createAppRouter } from "@vlian/router";
|
|
519
|
+
import type { DynamicRouteLoader } from "@vlian/router";
|
|
520
|
+
import { routes } from "./routes";
|
|
521
|
+
|
|
522
|
+
const dynamicLoader: DynamicRouteLoader = async ({ path, signal }) => {
|
|
523
|
+
if (!path.startsWith("/app")) return;
|
|
524
|
+
|
|
525
|
+
const res = await fetch("/api/menu-routes", { signal });
|
|
526
|
+
const data = await res.json();
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
parentId: "root",
|
|
530
|
+
cacheKey: `menu:${data.version}`,
|
|
531
|
+
routes: data.items.map((item: { id: string; path: string; perm: string }) => ({
|
|
532
|
+
id: item.id,
|
|
533
|
+
path: item.path.replace(/^\//, ""),
|
|
534
|
+
auth: { required: true, code: item.perm },
|
|
535
|
+
lazy: async () => {
|
|
536
|
+
const mod = await import(`../pages/${item.id}.tsx`);
|
|
537
|
+
return { Component: mod.default };
|
|
538
|
+
},
|
|
539
|
+
})),
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
export const router = createAppRouter({
|
|
544
|
+
routes,
|
|
545
|
+
mode: "browser",
|
|
546
|
+
authorization: {
|
|
547
|
+
resolve: ({ auth }) => {
|
|
548
|
+
if (!auth || auth === false) return true;
|
|
549
|
+
const code = typeof auth === "object" ? auth.code : undefined;
|
|
550
|
+
return !code || usePermissionStore.getState().has(code);
|
|
551
|
+
},
|
|
552
|
+
redirectTo: "/login",
|
|
553
|
+
},
|
|
554
|
+
dynamic: {
|
|
555
|
+
defaultParentId: "root",
|
|
556
|
+
loader: dynamicLoader,
|
|
557
|
+
getNavigationCacheKey: ({ path }) => (path.startsWith("/app") ? path : false),
|
|
558
|
+
errorMode: "silent",
|
|
559
|
+
},
|
|
560
|
+
onError: ({ error, path }) => console.error(path, error),
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### 9.4 推荐目录拆分示例
|
|
565
|
+
|
|
566
|
+
适用于中后台、低代码平台、权限菜单较多的应用。重点是让每个文件只承担一类职责。
|
|
567
|
+
|
|
568
|
+
```text
|
|
569
|
+
src/routes
|
|
570
|
+
├── authorization.tsx
|
|
571
|
+
├── dynamic-loader.ts
|
|
572
|
+
├── index.ts
|
|
573
|
+
├── router.ts
|
|
574
|
+
├── static-routes.tsx
|
|
575
|
+
└── types.ts
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`routes/types.ts`
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
import type { RouteExtraFields } from "@vlian/router";
|
|
582
|
+
|
|
583
|
+
export type AppRouteMeta = {
|
|
584
|
+
title?: string;
|
|
585
|
+
icon?: string;
|
|
586
|
+
hiddenInMenu?: boolean;
|
|
587
|
+
breadcrumb?: boolean;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
export type AppRouteExtra = RouteExtraFields & {
|
|
591
|
+
menuCode?: string;
|
|
592
|
+
preload?: boolean;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
export type MenuRouteItem = {
|
|
596
|
+
id: string;
|
|
597
|
+
path: string;
|
|
598
|
+
title: string;
|
|
599
|
+
component: string;
|
|
600
|
+
permissionCode?: string;
|
|
601
|
+
};
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`routes/static-routes.tsx`
|
|
605
|
+
|
|
606
|
+
```tsx
|
|
607
|
+
import { Outlet } from "react-router-dom";
|
|
608
|
+
import { defineRoutes } from "@vlian/router";
|
|
609
|
+
import type { AppRouteExtra, AppRouteMeta } from "./types";
|
|
610
|
+
import { AppLayout } from "../layouts/AppLayout";
|
|
611
|
+
import { LoginPage } from "../pages/login";
|
|
612
|
+
|
|
613
|
+
export const staticRoutes = defineRoutes<AppRouteMeta, AppRouteExtra>([
|
|
614
|
+
{
|
|
615
|
+
id: "root",
|
|
616
|
+
path: "/",
|
|
617
|
+
component: Outlet,
|
|
618
|
+
layout: AppLayout,
|
|
619
|
+
meta: { title: "应用" },
|
|
620
|
+
children: [
|
|
621
|
+
{
|
|
622
|
+
index: true,
|
|
623
|
+
redirect: "/workspace",
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
id: "workspace",
|
|
627
|
+
path: "workspace",
|
|
628
|
+
lazy: async () => {
|
|
629
|
+
const mod = await import("../pages/workspace");
|
|
630
|
+
return { Component: mod.default };
|
|
631
|
+
},
|
|
632
|
+
meta: { title: "工作台", icon: "dashboard" },
|
|
633
|
+
auth: true,
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: "login",
|
|
639
|
+
path: "/login",
|
|
640
|
+
component: LoginPage,
|
|
641
|
+
meta: { title: "登录", hiddenInMenu: true },
|
|
642
|
+
},
|
|
643
|
+
]);
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
`routes/dynamic-loader.ts`
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
import type { DynamicRouteLoader } from "@vlian/router";
|
|
650
|
+
import type { AppRouteExtra, AppRouteMeta, MenuRouteItem } from "./types";
|
|
651
|
+
|
|
652
|
+
const normalizeChildPath = (path: string) => path.replace(/^\/+/, "");
|
|
653
|
+
|
|
654
|
+
export const loadDynamicRoutes: DynamicRouteLoader<
|
|
655
|
+
AppRouteMeta,
|
|
656
|
+
AppRouteExtra
|
|
657
|
+
> = async ({ path, signal, knownRouteIds }) => {
|
|
658
|
+
if (!path.startsWith("/system")) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const response = await fetch("/api/current-user/routes", { signal });
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
throw new Error(`load dynamic routes failed: ${response.status}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const data: { version: string; routes: MenuRouteItem[] } =
|
|
668
|
+
await response.json();
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
parentId: "root",
|
|
672
|
+
cacheKey: `menu:${data.version}`,
|
|
673
|
+
routes: data.routes
|
|
674
|
+
.filter((item) => !knownRouteIds.has(item.id))
|
|
675
|
+
.map((item) => ({
|
|
676
|
+
id: item.id,
|
|
677
|
+
path: normalizeChildPath(item.path),
|
|
678
|
+
meta: {
|
|
679
|
+
title: item.title,
|
|
680
|
+
icon: "menu",
|
|
681
|
+
},
|
|
682
|
+
auth: item.permissionCode
|
|
683
|
+
? { required: true, permissionCode: item.permissionCode }
|
|
684
|
+
: true,
|
|
685
|
+
menuCode: item.id,
|
|
686
|
+
lazy: async () => {
|
|
687
|
+
const mod = await import(`../pages/${item.component}.tsx`);
|
|
688
|
+
return { Component: mod.default, loader: mod.loader };
|
|
689
|
+
},
|
|
690
|
+
})),
|
|
691
|
+
};
|
|
692
|
+
};
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`routes/authorization.tsx`
|
|
696
|
+
|
|
697
|
+
```tsx
|
|
698
|
+
import type { RouteAuthorizationOptions } from "@vlian/router";
|
|
699
|
+
import type { AppRouteExtra, AppRouteMeta } from "./types";
|
|
700
|
+
import { authStore } from "../stores/auth-store";
|
|
701
|
+
|
|
702
|
+
export const authorization: RouteAuthorizationOptions<
|
|
703
|
+
AppRouteMeta,
|
|
704
|
+
AppRouteExtra
|
|
705
|
+
> = {
|
|
706
|
+
resolve: ({ auth }) => {
|
|
707
|
+
if (!auth || auth === false) {
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (auth === true) {
|
|
712
|
+
return authStore.getState().isLoggedIn;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (auth.required === false) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const permissionCode =
|
|
720
|
+
typeof auth.permissionCode === "string" ? auth.permissionCode : "";
|
|
721
|
+
return !permissionCode || authStore.getState().hasPermission(permissionCode);
|
|
722
|
+
},
|
|
723
|
+
redirectTo: "/login",
|
|
724
|
+
renderDenied: () => <div>无权限访问</div>,
|
|
725
|
+
};
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
`routes/router.ts`
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
import { createAppRouter } from "@vlian/router";
|
|
732
|
+
import { authorization } from "./authorization";
|
|
733
|
+
import { loadDynamicRoutes } from "./dynamic-loader";
|
|
734
|
+
import { staticRoutes } from "./static-routes";
|
|
735
|
+
|
|
736
|
+
export const router = createAppRouter({
|
|
737
|
+
mode: "browser",
|
|
738
|
+
routes: staticRoutes,
|
|
739
|
+
authorization,
|
|
740
|
+
dynamic: {
|
|
741
|
+
defaultParentId: "root",
|
|
742
|
+
loader: loadDynamicRoutes,
|
|
743
|
+
getNavigationCacheKey: ({ path }) =>
|
|
744
|
+
path.startsWith("/system") ? "system-menu" : false,
|
|
745
|
+
errorMode: "silent",
|
|
746
|
+
},
|
|
747
|
+
onError: ({ error, path }) => {
|
|
748
|
+
console.error("dynamic route load failed", path, error);
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
### 9.5 `AppRouterProvider` 稳定 options 示例
|
|
754
|
+
|
|
755
|
+
如果使用 `AppRouterProvider`,不要在 JSX 中每次 render 都创建新 `options` 对象。
|
|
756
|
+
|
|
757
|
+
```tsx
|
|
758
|
+
import { useMemo } from "react";
|
|
759
|
+
import { AppRouterProvider } from "@vlian/router/provider";
|
|
760
|
+
import type { RouterPluginOptions } from "@vlian/router";
|
|
761
|
+
import { authorization } from "./routes/authorization";
|
|
762
|
+
import { loadDynamicRoutes } from "./routes/dynamic-loader";
|
|
763
|
+
import { staticRoutes } from "./routes/static-routes";
|
|
764
|
+
import type { AppRouteExtra, AppRouteMeta } from "./routes/types";
|
|
765
|
+
|
|
766
|
+
export function App() {
|
|
767
|
+
const routerOptions = useMemo<
|
|
768
|
+
RouterPluginOptions<AppRouteMeta, AppRouteExtra>
|
|
769
|
+
>(
|
|
770
|
+
() => ({
|
|
771
|
+
mode: "browser",
|
|
772
|
+
routes: staticRoutes,
|
|
773
|
+
authorization,
|
|
774
|
+
dynamic: {
|
|
775
|
+
defaultParentId: "root",
|
|
776
|
+
loader: loadDynamicRoutes,
|
|
777
|
+
},
|
|
778
|
+
}),
|
|
779
|
+
[],
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
return <AppRouterProvider options={routerOptions} />;
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### 9.6 多父节点动态补丁示例
|
|
787
|
+
|
|
788
|
+
当后端一次返回多组菜单,需要分别挂到不同布局或不同模块父节点时,`dynamic.loader` 可以返回 `DynamicRoutePatch[]`。
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
import type { DynamicRouteLoader } from "@vlian/router";
|
|
792
|
+
|
|
793
|
+
export const loadModuleRoutes: DynamicRouteLoader = async ({ signal }) => {
|
|
794
|
+
const response = await fetch("/api/module-routes", { signal });
|
|
795
|
+
const data = await response.json();
|
|
796
|
+
|
|
797
|
+
return [
|
|
798
|
+
{
|
|
799
|
+
parentId: "root",
|
|
800
|
+
cacheKey: `workspace:${data.version}`,
|
|
801
|
+
routes: data.workspace.map((item: any) => ({
|
|
802
|
+
id: item.id,
|
|
803
|
+
path: item.path.replace(/^\/+/, ""),
|
|
804
|
+
meta: { title: item.title },
|
|
805
|
+
lazy: async () => {
|
|
806
|
+
const mod = await import(`../pages/${item.component}.tsx`);
|
|
807
|
+
return { Component: mod.default };
|
|
808
|
+
},
|
|
809
|
+
})),
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
parentId: "admin",
|
|
813
|
+
cacheKey: `admin:${data.version}`,
|
|
814
|
+
routes: data.admin.map((item: any) => ({
|
|
815
|
+
id: item.id,
|
|
816
|
+
path: item.path.replace(/^\/+/, ""),
|
|
817
|
+
auth: { required: true, permissionCode: item.permissionCode },
|
|
818
|
+
meta: { title: item.title },
|
|
819
|
+
lazy: async () => {
|
|
820
|
+
const mod = await import(`../pages/admin/${item.component}.tsx`);
|
|
821
|
+
return { Component: mod.default };
|
|
822
|
+
},
|
|
823
|
+
})),
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
};
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
## 10. 行为小结表
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
| 主题 | 要点 |
|
|
835
|
+
| ---- | -------------------------------------------------------------------------------------- |
|
|
836
|
+
| 静态路由 | `StaticRouteConfig` → `normalizeRoutes` → `id` / `handle` / 可选 Shell |
|
|
837
|
+
| 动态路由 | `dynamic.loader` → patch 结果 → 仅注册新 `id` → `handle.source === "dynamic"` |
|
|
838
|
+
| 权限路由 | 同时配置 `auth` + `authorization`;拒绝时 `auth.redirectTo` > 全局 `redirectTo` > `renderDenied` |
|
|
839
|
+
| 缓存 | 导航级:`getNavigationCacheKey`;patch 级:`DynamicRoutePatch.cacheKey` |
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
## 11. 与 README 的关系
|
|
845
|
+
|
|
846
|
+
- **README**:安装、快速开始、长示例、预加载与发布说明。
|
|
847
|
+
- **DESIGN.md**(本文):三类路由语义、全部选项与内部方法契约,便于架构评审与封装扩展。
|
|
848
|
+
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"__esModule",{value:true});Object.defineProperty(exports,"normalizeRoutes",{enumerable:true,get:function(){return normalizeRoutes}});const _routeshell=require("../guards/route-shell");const _routeid=require("./route-id");const RESERVED_ROUTE_KEYS=new Set(["id","path","index","caseSensitive","component","Component","element","lazy","loader","action","shouldRevalidate","errorElement","ErrorBoundary","hydrateFallbackElement","HydrateFallback","handle","meta","layout","auth","redirect","children"]);const isRecord=value=>typeof value==="object"&&value!==null&&!Array.isArray(value);const mergeHandle=(handle,meta,auth,source)=>{const baseHandle=isRecord(handle)?handle:{};return{...baseHandle,meta,auth,source}};const pickExtraFields=route=>{const extraEntries=Object.entries(route).filter(([key])=>!RESERVED_ROUTE_KEYS.has(key));return Object.fromEntries(extraEntries)};const normalizeRedirect=redirect=>{if(!redirect){return undefined}if(typeof redirect==="string"){return{to:redirect,replace:true}}return{replace:true,...redirect}};const needsRouteShell=(route,normalizedRedirect,authorization)=>Boolean(normalizedRedirect||route.layout||route.auth&&authorization||route.component||route.Component&&(route.layout||route.auth)||route.element!==undefined&&(route.layout||route.auth));const wrapLazyRoute=(lazy,routeRef,route,normalizedRedirect,authorization)=>{return async()=>{const resolved=await lazy();const component=resolved.Component??route.Component??route.component;const element=resolved.element??route.element;if(!needsRouteShell({...route,Component:component,element},normalizedRedirect,authorization)){return resolved}return{...resolved,Component:(0,_routeshell.createRouteShellComponent)({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element}),element:undefined}}};const normalizeRoute=(route,index,parentId,options)=>{const routeId=(0,_routeid.createRouteId)(route,parentId,index);const normalizedRedirect=normalizeRedirect(route.redirect);const handle=mergeHandle(route.handle,route.meta,route.auth,options.source);const normalizedRoute={id:routeId,path:route.path,index:route.index,caseSensitive:route.caseSensitive,loader:route.loader,action:route.action,shouldRevalidate:route.shouldRevalidate,errorElement:route.errorElement,ErrorBoundary:route.ErrorBoundary,hydrateFallbackElement:route.hydrateFallbackElement,HydrateFallback:route.HydrateFallback,handle,__extra:pickExtraFields(route)};if(route.children?.length){normalizedRoute.children=route.children.map((child,childIndex)=>normalizeRoute(child,childIndex,routeId,options))}if(route.lazy){normalizedRoute.lazy=wrapLazyRoute(route.lazy,normalizedRoute,route,normalizedRedirect,options.authorization);return normalizedRoute}const component=route.Component??route.component;const element=route.element;if(needsRouteShell(route,normalizedRedirect,options.authorization)){normalizedRoute.Component=(0,_routeshell.createRouteShellComponent)({route:normalizedRoute,redirect:normalizedRedirect,auth:route.auth,authorization:options.authorization,layout:route.layout,component,element});return normalizedRoute}if(component){normalizedRoute.Component=component}if(element!==undefined){normalizedRoute.element=element}return normalizedRoute};const normalizeRoutes=(routes,options)=>routes.map((route,index)=>normalizeRoute(route,index,options.parentId??null,options));
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});Object.defineProperty(exports,"normalizeRoutes",{enumerable:true,get:function(){return normalizeRoutes}});const _routeshell=require("../guards/route-shell");const _routeid=require("./route-id");const RESERVED_ROUTE_KEYS=new Set(["id","path","index","caseSensitive","component","Component","element","lazy","middleware","loader","action","shouldRevalidate","errorElement","ErrorBoundary","hydrateFallbackElement","HydrateFallback","handle","meta","layout","auth","redirect","children"]);const isRecord=value=>typeof value==="object"&&value!==null&&!Array.isArray(value);const mergeHandle=(handle,meta,auth,source)=>{const baseHandle=isRecord(handle)?handle:{};return{...baseHandle,meta,auth,source}};const pickExtraFields=route=>{const extraEntries=Object.entries(route).filter(([key])=>!RESERVED_ROUTE_KEYS.has(key));return Object.fromEntries(extraEntries)};const normalizeRedirect=redirect=>{if(!redirect){return undefined}if(typeof redirect==="string"){return{to:redirect,replace:true}}return{replace:true,...redirect}};const needsRouteShell=(route,normalizedRedirect,authorization)=>Boolean(normalizedRedirect||route.layout||route.auth&&authorization||route.component||route.Component&&(route.layout||route.auth)||route.element!==undefined&&(route.layout||route.auth));const wrapLazyRoute=(lazy,routeRef,route,normalizedRedirect,authorization)=>{if(typeof lazy!=="function"){if(!needsRouteShell(route,normalizedRedirect,authorization)){return lazy}const{Component:loadComponent,element:loadElement,...restLazy}=lazy;return{...restLazy,Component:async()=>{const component=loadComponent?await loadComponent():route.Component??route.component;const element=loadElement?await loadElement():route.element;return(0,_routeshell.createRouteShellComponent)({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element})}}}return async()=>{const resolved=await lazy();const component=resolved.Component??route.Component??route.component;const element=resolved.element??route.element;if(!needsRouteShell({...route,Component:component,element},normalizedRedirect,authorization)){return resolved}return{...resolved,Component:(0,_routeshell.createRouteShellComponent)({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element}),element:undefined}}};const normalizeRoute=(route,index,parentId,options)=>{const routeId=(0,_routeid.createRouteId)(route,parentId,index);const normalizedRedirect=normalizeRedirect(route.redirect);const handle=mergeHandle(route.handle,route.meta,route.auth,options.source);const normalizedRoute={id:routeId,path:route.path,index:route.index,caseSensitive:route.caseSensitive,middleware:route.middleware,loader:route.loader,action:route.action,shouldRevalidate:route.shouldRevalidate,errorElement:route.errorElement,ErrorBoundary:route.ErrorBoundary,hydrateFallbackElement:route.hydrateFallbackElement,HydrateFallback:route.HydrateFallback,handle,__extra:pickExtraFields(route)};if(route.children?.length){normalizedRoute.children=route.children.map((child,childIndex)=>normalizeRoute(child,childIndex,routeId,options))}if(route.lazy){normalizedRoute.lazy=wrapLazyRoute(route.lazy,normalizedRoute,route,normalizedRedirect,options.authorization);return normalizedRoute}const component=route.Component??route.component;const element=route.element;if(needsRouteShell(route,normalizedRedirect,options.authorization)){normalizedRoute.Component=(0,_routeshell.createRouteShellComponent)({route:normalizedRoute,redirect:normalizedRedirect,auth:route.auth,authorization:options.authorization,layout:route.layout,component,element});return normalizedRoute}if(component){normalizedRoute.Component=component}if(element!==undefined){normalizedRoute.element=element}return normalizedRoute};const normalizeRoutes=(routes,options)=>routes.map((route,index)=>normalizeRoute(route,index,options.parentId??null,options));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createRouteShellComponent}from"../guards/route-shell";import{createRouteId}from"./route-id";const RESERVED_ROUTE_KEYS=new Set(["id","path","index","caseSensitive","component","Component","element","lazy","loader","action","shouldRevalidate","errorElement","ErrorBoundary","hydrateFallbackElement","HydrateFallback","handle","meta","layout","auth","redirect","children"]);const isRecord=value=>typeof value==="object"&&value!==null&&!Array.isArray(value);const mergeHandle=(handle,meta,auth,source)=>{const baseHandle=isRecord(handle)?handle:{};return{...baseHandle,meta,auth,source}};const pickExtraFields=route=>{const extraEntries=Object.entries(route).filter(([key])=>!RESERVED_ROUTE_KEYS.has(key));return Object.fromEntries(extraEntries)};const normalizeRedirect=redirect=>{if(!redirect){return undefined}if(typeof redirect==="string"){return{to:redirect,replace:true}}return{replace:true,...redirect}};const needsRouteShell=(route,normalizedRedirect,authorization)=>Boolean(normalizedRedirect||route.layout||route.auth&&authorization||route.component||route.Component&&(route.layout||route.auth)||route.element!==undefined&&(route.layout||route.auth));const wrapLazyRoute=(lazy,routeRef,route,normalizedRedirect,authorization)=>{return async()=>{const resolved=await lazy();const component=resolved.Component??route.Component??route.component;const element=resolved.element??route.element;if(!needsRouteShell({...route,Component:component,element},normalizedRedirect,authorization)){return resolved}return{...resolved,Component:createRouteShellComponent({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element}),element:undefined}}};const normalizeRoute=(route,index,parentId,options)=>{const routeId=createRouteId(route,parentId,index);const normalizedRedirect=normalizeRedirect(route.redirect);const handle=mergeHandle(route.handle,route.meta,route.auth,options.source);const normalizedRoute={id:routeId,path:route.path,index:route.index,caseSensitive:route.caseSensitive,loader:route.loader,action:route.action,shouldRevalidate:route.shouldRevalidate,errorElement:route.errorElement,ErrorBoundary:route.ErrorBoundary,hydrateFallbackElement:route.hydrateFallbackElement,HydrateFallback:route.HydrateFallback,handle,__extra:pickExtraFields(route)};if(route.children?.length){normalizedRoute.children=route.children.map((child,childIndex)=>normalizeRoute(child,childIndex,routeId,options))}if(route.lazy){normalizedRoute.lazy=wrapLazyRoute(route.lazy,normalizedRoute,route,normalizedRedirect,options.authorization);return normalizedRoute}const component=route.Component??route.component;const element=route.element;if(needsRouteShell(route,normalizedRedirect,options.authorization)){normalizedRoute.Component=createRouteShellComponent({route:normalizedRoute,redirect:normalizedRedirect,auth:route.auth,authorization:options.authorization,layout:route.layout,component,element});return normalizedRoute}if(component){normalizedRoute.Component=component}if(element!==undefined){normalizedRoute.element=element}return normalizedRoute};export const normalizeRoutes=(routes,options)=>routes.map((route,index)=>normalizeRoute(route,index,options.parentId??null,options));
|
|
1
|
+
import{createRouteShellComponent}from"../guards/route-shell";import{createRouteId}from"./route-id";const RESERVED_ROUTE_KEYS=new Set(["id","path","index","caseSensitive","component","Component","element","lazy","middleware","loader","action","shouldRevalidate","errorElement","ErrorBoundary","hydrateFallbackElement","HydrateFallback","handle","meta","layout","auth","redirect","children"]);const isRecord=value=>typeof value==="object"&&value!==null&&!Array.isArray(value);const mergeHandle=(handle,meta,auth,source)=>{const baseHandle=isRecord(handle)?handle:{};return{...baseHandle,meta,auth,source}};const pickExtraFields=route=>{const extraEntries=Object.entries(route).filter(([key])=>!RESERVED_ROUTE_KEYS.has(key));return Object.fromEntries(extraEntries)};const normalizeRedirect=redirect=>{if(!redirect){return undefined}if(typeof redirect==="string"){return{to:redirect,replace:true}}return{replace:true,...redirect}};const needsRouteShell=(route,normalizedRedirect,authorization)=>Boolean(normalizedRedirect||route.layout||route.auth&&authorization||route.component||route.Component&&(route.layout||route.auth)||route.element!==undefined&&(route.layout||route.auth));const wrapLazyRoute=(lazy,routeRef,route,normalizedRedirect,authorization)=>{if(typeof lazy!=="function"){if(!needsRouteShell(route,normalizedRedirect,authorization)){return lazy}const{Component:loadComponent,element:loadElement,...restLazy}=lazy;return{...restLazy,Component:async()=>{const component=loadComponent?await loadComponent():route.Component??route.component;const element=loadElement?await loadElement():route.element;return createRouteShellComponent({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element})}}}return async()=>{const resolved=await lazy();const component=resolved.Component??route.Component??route.component;const element=resolved.element??route.element;if(!needsRouteShell({...route,Component:component,element},normalizedRedirect,authorization)){return resolved}return{...resolved,Component:createRouteShellComponent({route:routeRef,redirect:normalizedRedirect,auth:route.auth,authorization,layout:route.layout,component,element}),element:undefined}}};const normalizeRoute=(route,index,parentId,options)=>{const routeId=createRouteId(route,parentId,index);const normalizedRedirect=normalizeRedirect(route.redirect);const handle=mergeHandle(route.handle,route.meta,route.auth,options.source);const normalizedRoute={id:routeId,path:route.path,index:route.index,caseSensitive:route.caseSensitive,middleware:route.middleware,loader:route.loader,action:route.action,shouldRevalidate:route.shouldRevalidate,errorElement:route.errorElement,ErrorBoundary:route.ErrorBoundary,hydrateFallbackElement:route.hydrateFallbackElement,HydrateFallback:route.HydrateFallback,handle,__extra:pickExtraFields(route)};if(route.children?.length){normalizedRoute.children=route.children.map((child,childIndex)=>normalizeRoute(child,childIndex,routeId,options))}if(route.lazy){normalizedRoute.lazy=wrapLazyRoute(route.lazy,normalizedRoute,route,normalizedRedirect,options.authorization);return normalizedRoute}const component=route.Component??route.component;const element=route.element;if(needsRouteShell(route,normalizedRedirect,options.authorization)){normalizedRoute.Component=createRouteShellComponent({route:normalizedRoute,redirect:normalizedRedirect,auth:route.auth,authorization:options.authorization,layout:route.layout,component,element});return normalizedRoute}if(component){normalizedRoute.Component=component}if(element!==undefined){normalizedRoute.element=element}return normalizedRoute};export const normalizeRoutes=(routes,options)=>routes.map((route,index)=>normalizeRoute(route,index,options.parentId??null,options));
|
package/dist/types/route.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from "react";
|
|
2
|
-
import type { DataRouter,
|
|
2
|
+
import type { DataRouter, MemoryRouterOpts, PatchRoutesOnNavigationFunction, RouteMatch, RouteObject } from "react-router-dom";
|
|
3
3
|
export type MaybePromise<T> = T | Promise<T>;
|
|
4
4
|
export type AppRouterMode = "browser" | "hash" | "memory";
|
|
5
5
|
export type DefaultRouteMeta = Record<string, unknown>;
|
|
@@ -36,7 +36,7 @@ export interface RouteLayoutDescriptor<Meta = DefaultRouteMeta, Extra extends Ro
|
|
|
36
36
|
}
|
|
37
37
|
export type RouteLayout<Meta = DefaultRouteMeta, Extra extends RouteExtraFields = RouteExtraFields> = ComponentType<RouteLayoutProps<Meta, Extra>> | RouteLayoutDescriptor<Meta, Extra>;
|
|
38
38
|
export type RouteComponent = Exclude<RouteObject["Component"], undefined>;
|
|
39
|
-
export type RouteLazyLoader =
|
|
39
|
+
export type RouteLazyLoader = Exclude<RouteObject["lazy"], undefined>;
|
|
40
40
|
interface StaticRouteConfigBase<Meta = DefaultRouteMeta, Extra extends RouteExtraFields = RouteExtraFields> {
|
|
41
41
|
id?: string;
|
|
42
42
|
path?: string;
|
|
@@ -46,6 +46,7 @@ interface StaticRouteConfigBase<Meta = DefaultRouteMeta, Extra extends RouteExtr
|
|
|
46
46
|
Component?: RouteComponent;
|
|
47
47
|
element?: ReactNode;
|
|
48
48
|
lazy?: RouteLazyLoader;
|
|
49
|
+
middleware?: RouteObject["middleware"];
|
|
49
50
|
loader?: RouteObject["loader"];
|
|
50
51
|
action?: RouteObject["action"];
|
|
51
52
|
shouldRevalidate?: RouteObject["shouldRevalidate"];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vlian/router",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Business-friendly router wrapper built on react-router-dom",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Secra Framework Contributors",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
},
|
|
59
59
|
"files": [
|
|
60
60
|
"dist",
|
|
61
|
-
"README.md"
|
|
61
|
+
"README.md",
|
|
62
|
+
"DESIGN.md"
|
|
62
63
|
],
|
|
63
64
|
"scripts": {
|
|
64
65
|
"build": "npm run clean && npm run build:types && npm run build:esm && npm run build:cjs",
|
|
@@ -67,7 +68,8 @@
|
|
|
67
68
|
"build:cjs": "swc src -d dist-temp --strip-leading-paths --config-file .swcrc.cjs && node scripts/rename-cjs.js && rm -rf dist-temp",
|
|
68
69
|
"clean": "rm -rf dist dist-temp",
|
|
69
70
|
"typecheck": "tsc --noEmit",
|
|
70
|
-
"prepublishOnly": "npm run build"
|
|
71
|
+
"prepublishOnly": "npm run build && npm version patch --no-git-tag-version",
|
|
72
|
+
"publish:dry-run": "npm run prepublishOnly && npm publish --dry-run --access public"
|
|
71
73
|
},
|
|
72
74
|
"peerDependencies": {
|
|
73
75
|
"react": "^19.0.0",
|