@vlian/router
v0.1.2
Published
Business-friendly router wrapper built on react-router-dom
Readme
@vlian/router
基于 react-router-dom 的业务路由封装包。目标不是暴露更多路由细节,而是把静态路由标准化、动态补丁注入、导航阶段异步加载、基础鉴权包装这些复杂性收口到包内部,让业务方只维护两类输入:
- 静态路由配置
- 动态路由加载函数
1. 设计目标与边界
解决的问题
- 统一业务侧路由输入模型,业务方只写声明式静态路由
- 内部完成
StaticRouteConfig -> react-router-dom RouteObject的标准化 - 在导航发生时通过
patchRoutesOnNavigation按需注入动态路由 - 支持动态路由来源于本地模块、接口请求、权限码集合或混合来源
- 内置动态加载去重、导航级缓存、错误回调和基础降级
- 对
layout、auth、redirect、meta等业务字段做统一消费
不解决的问题
- 不替代业务权限系统本身
- 不负责服务端菜单接口协议设计
- 不强行规定业务的菜单模型、埋点模型、面包屑模型
- 不试图绕过
react-router-dom能力边界,仍以 data router 为底座 - 不内置复杂页面级权限请求流转,异步鉴权建议放在动态路由加载或 loader 中完成
2. 推荐 API 设计
主入口
import { createAppRouter, defineRoutes } from "@vlian/router";根入口只暴露核心构建 API,用于尽量降低消费端打包时被额外带入的运行时代码。
如果需要直接渲染 provider,请显式从子路径导入:
import { AppRouterProvider } from "@vlian/router/provider";核心 API
const routes = defineRoutes([
{
id: "root",
path: "/",
component: RootPage,
children: [
{
path: "dashboard",
component: DashboardPage,
meta: { title: "Dashboard" },
},
],
},
]);
const router = createAppRouter({
mode: "browser",
routes,
dynamic: {
defaultParentId: "root",
loader: async ({ path, signal }) => {
if (!path.startsWith("/system")) {
return;
}
const response = await fetch("/api/routes", { signal });
const payload = await response.json();
return payload.routes.map((item: any) => ({
id: item.code,
path: item.path,
lazy: async () => {
const mod = await import(`./pages/${item.component}.tsx`);
return { Component: mod.default };
},
meta: { title: item.title, code: item.code },
}));
},
},
});对外 API 说明
defineRoutes(routes)仅做类型收敛,帮助业务侧保留推断结果createAppRouter(options)包主工厂。内部完成:- 静态路由 normalize
- 动态 patch 创建
createBrowserRouter/createHashRouter/createMemoryRouter适配
AppRouterProvider针对“只想直接传 options 渲染”的场景,内部useMemo(createAppRouter)。 为避免默认入口增重,请从@vlian/router/provider或@vlian/router/runtime单独导入
3. TypeScript 类型设计
StaticRouteConfig
export interface StaticRouteConfig<Meta, Extra> extends Extra {
id?: string;
path?: string;
index?: boolean;
caseSensitive?: boolean;
component?: RouteComponent;
Component?: RouteComponent;
element?: ReactNode;
lazy?: RouteLazyLoader;
loader?: RouteObject["loader"];
action?: RouteObject["action"];
shouldRevalidate?: RouteObject["shouldRevalidate"];
errorElement?: ReactNode;
ErrorBoundary?: RouteObject["ErrorBoundary"];
hydrateFallbackElement?: ReactNode;
HydrateFallback?: RouteObject["HydrateFallback"];
handle?: RouteObject["handle"];
meta?: Meta;
layout?: RouteLayout<Meta, Extra>;
auth?: RouteAuthConfig;
redirect?: RouteRedirect;
children?: StaticRouteConfig<Meta, Extra>[];
}设计要点:
- 业务优先写
component,不必理解Component与element差异 - 保留
lazy、loader、action等 data router 原生能力 meta泛型可扩展Extra泛型用于承接业务扩展字段
DynamicRouteLoader
export type DynamicRouteLoader<Meta, Extra> = (
context: DynamicRouteLoaderContext<Meta, Extra>,
) => Promise<
| void
| StaticRouteConfig<Meta, Extra>[]
| DynamicRoutePatch<Meta, Extra>
| DynamicRoutePatch<Meta, Extra>[]
>;DynamicRouteLoaderContext
export interface DynamicRouteLoaderContext<Meta, Extra> {
path: string;
signal: AbortSignal;
matches: RouteMatch<string, AppRouteObject<Meta, Extra>>[];
router: DataRouter;
knownRouteIds: Set<string>;
}DynamicRoutePatch
export interface DynamicRoutePatch<Meta, Extra> {
parentId?: string | null;
routes: StaticRouteConfig<Meta, Extra>[];
cacheKey?: string | false;
}RouterPluginOptions
export interface RouterPluginOptions<Meta, Extra> {
routes: StaticRouteConfig<Meta, Extra>[];
mode?: "browser" | "hash" | "memory";
basename?: string;
future?: CreateMemoryRouterOptions["future"];
hydrationData?: CreateMemoryRouterOptions["hydrationData"];
getContext?: CreateMemoryRouterOptions["getContext"];
dataStrategy?: CreateMemoryRouterOptions["dataStrategy"];
initialEntries?: CreateMemoryRouterOptions["initialEntries"];
initialIndex?: CreateMemoryRouterOptions["initialIndex"];
window?: Window;
dynamic?: DynamicRoutingOptions<Meta, Extra>;
authorization?: RouteAuthorizationOptions<Meta, Extra>;
onError?: RouterErrorHandler<Meta, Extra>;
}4. 内部架构设计
目录按职责拆分,不把 normalize、patch、runtime 混在一个文件:
src
├── adapters
│ ├── create-app-router.ts
│ └── index.ts
├── core
│ ├── guards
│ │ └── route-shell.tsx
│ ├── normalize
│ │ ├── normalize-routes.ts
│ │ └── route-id.ts
│ ├── patcher
│ │ └── create-navigation-patcher.ts
│ ├── runtime
│ │ └── route-registry.ts
│ └── index.ts
├── runtime
│ ├── AppRouterProvider.tsx
│ └── index.ts
├── types
│ ├── index.ts
│ └── route.ts
└── index.ts各层职责
adapters面向react-router-dom,负责组装最终 routercore/normalize负责静态路由标准化、自动id生成、业务字段转换core/guards把layout/auth/redirect包装成统一的 route shellcore/patcher封装导航期间动态注入逻辑,支持await、缓存、错误处理和去重core/runtime维护已注册路由集合,避免重复 patchruntime提供AppRouterProvidertypes只放公开类型,不掺杂实现
5. 关键实现思路
静态路由输入
业务方只关心:
pathcomponent/lazychildrenmetalayoutauthredirect
包内部负责:
- 自动生成稳定
id component -> Componentredirect -> <Navigate />layout -> RouteShellauth + authorization -> RouteShell- 补齐
handle.meta/auth/source
动态补丁
使用 react-router-dom@7 的 patchRoutesOnNavigation,内部封装:
loader(context)支持异步await fetch(...)- 接口成功后返回路由数组或 patch 描述
- 自动 normalize 为
RouteObject - 自动过滤已注入
id - 按
parentId打补丁 - 支持
cacheKey
错误处理机制
动态路由加载失败时:
- 默认走
onError(context)回调 errorMode: "silent"时只降级,不中断导航errorMode: "throw"时向上抛出,让业务自己接管
6. 最小可用示例
6.1 定义静态路由
import { Outlet } from "react-router-dom";
import { defineRoutes } from "@vlian/router";
function RootLayout({ children }: { children: React.ReactNode }) {
return (
<div className="app-shell">
<aside>menu</aside>
<main>{children}</main>
</div>
);
}
function RootPage() {
return <Outlet />;
}
function LoginPage() {
return <div>login</div>;
}
export const routes = defineRoutes([
{
id: "root",
path: "/",
component: RootPage,
layout: RootLayout,
children: [
{
index: true,
redirect: "/dashboard",
},
],
},
{
id: "login",
path: "/login",
component: LoginPage,
meta: { title: "登录" },
},
]);6.2 定义动态路由加载方法
import type { DynamicRouteLoader } from "@vlian/router";
type AppMeta = {
title?: string;
permissionCode?: string;
};
export const loadDynamicRoutes: DynamicRouteLoader<AppMeta> = async ({
path,
signal,
}) => {
if (!path.startsWith("/system")) {
return;
}
const response = await fetch("/api/auth/routes", { signal });
if (!response.ok) {
throw new Error(`route api failed: ${response.status}`);
}
const data = await response.json();
return {
parentId: "root",
cacheKey: `permission:${data.authzVersion}`,
routes: data.routes.map((item: any) => ({
id: item.code,
path: item.path.replace(/^\//, ""),
meta: {
title: item.title,
permissionCode: item.code,
},
auth: {
required: true,
permissionCode: item.code,
},
lazy: async () => {
const mod = await import(`./pages/${item.component}.tsx`);
return {
Component: mod.default,
loader: mod.loader,
};
},
})),
};
};6.3 创建 router
import { createAppRouter } from "@vlian/router";
import { routes } from "./routes";
import { loadDynamicRoutes } from "./dynamic-routes";
import { authStore } from "./state/auth-store";
export const router = createAppRouter({
mode: "browser",
routes,
authorization: {
resolve: ({ auth }) => {
if (!auth || auth === false) {
return true;
}
const currentPermissions = authStore.getState().permissions;
const permissionCode =
typeof auth === "object" ? auth.permissionCode : undefined;
return !permissionCode || currentPermissions.includes(permissionCode);
},
redirectTo: "/login",
},
dynamic: {
defaultParentId: "root",
loader: loadDynamicRoutes,
getNavigationCacheKey: ({ path }) =>
path.startsWith("/system") ? path : false,
errorMode: "silent",
},
onError: ({ error, path }) => {
console.error("dynamic route load failed", path, error);
},
});这里的 authStore 可以是 Redux、Zustand、Pinia 风格的前端状态容器,关键点是:
- 路由层只消费当前权限状态
- 真正的权限来源由登录态接口或用户信息接口提供
- 前端权限只用于体验控制,不作为最终安全边界
例如可以先通过接口拉取当前用户权限,再写入 store:
type AuthState = {
permissions: string[];
setPermissions: (permissions: string[]) => void;
};
export const authStore = create<AuthState>((set) => ({
permissions: [],
setPermissions: (permissions) => set({ permissions }),
}));
export const bootstrapAuth = async () => {
const response = await fetch("/api/auth/me");
if (!response.ok) {
throw new Error(`auth bootstrap failed: ${response.status}`);
}
const data = await response.json();
authStore.getState().setPermissions(data.permissions ?? []);
};如果你的权限系统完全以后端接口为准,也可以在 authorization.resolve 里只做“前端展示控制”,而把真正的安全校验继续留在服务端接口。
6.4 在应用中使用
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
export function App() {
return <RouterProvider router={router} />;
}6.5 使用 AppRouterProvider
import { AppRouterProvider } from "@vlian/router/provider";
import { routes } from "./routes";
import { loadDynamicRoutes } from "./dynamic-routes";
export function App() {
return (
<AppRouterProvider
options={{
mode: "browser",
routes,
dynamic: {
defaultParentId: "root",
loader: loadDynamicRoutes,
},
}}
/>
);
}6.6 懒加载用法
当前版本已经原生支持懒加载,直接在 StaticRouteConfig.lazy 里返回 react-router-dom 的 lazy route module 即可。
推荐把 import() 提取成独立函数,这样后面做预加载时可以复用同一份模块加载逻辑。
import { defineRoutes } from "@vlian/router";
const loadDashboardModule = () => import("./pages/dashboard");
const loadUserListModule = () => import("./pages/system/users");
export const routes = defineRoutes([
{
id: "root",
path: "/",
children: [
{
path: "dashboard",
lazy: async () => {
const mod = await loadDashboardModule();
return {
Component: mod.default,
loader: mod.loader,
};
},
meta: {
title: "Dashboard",
},
},
{
path: "system/users",
lazy: async () => {
const mod = await loadUserListModule();
return {
Component: mod.default,
loader: mod.loader,
ErrorBoundary: mod.ErrorBoundary,
};
},
meta: {
title: "用户管理",
},
},
],
},
]);适用场景:
- 页面体积较大,希望按需分包
- 页面 loader 和页面组件需要一起延迟加载
- 权限页、后台页、低频页不希望在首屏打包
6.7 预加载用法
当前版本没有单独暴露 preloadRoute() 这一类官方 API,因此推荐两种预加载方式:
- 对静态懒加载页面,手动提前执行同一个
import()函数 - 对动态路由接口,手动预热接口请求缓存,让真正导航时直接复用结果
6.7.1 预加载静态懒加载页面
const loadUserListModule = () => import("./pages/system/users");
export const routes = defineRoutes([
{
id: "root",
path: "/",
children: [
{
path: "system/users",
lazy: async () => {
const mod = await loadUserListModule();
return {
Component: mod.default,
loader: mod.loader,
};
},
},
],
},
]);
export const preloadUserListPage = () => loadUserListModule();在菜单 hover、按钮曝光、空闲时机里预热模块:
import { Link } from "react-router-dom";
import { preloadUserListPage } from "./routes";
export function UserMenuLink() {
return (
<Link
onFocus={() => {
void preloadUserListPage();
}}
onMouseEnter={() => {
void preloadUserListPage();
}}
to="/system/users"
>
用户管理
</Link>
);
}6.7.2 预加载动态路由接口
对于动态路由,推荐把“请求路由清单”的逻辑提成可复用缓存函数。这样:
- 预加载阶段可以先请求一次
- 真正导航进入时,
dynamic.loader直接复用缓存结果
type DynamicRouteResponse = {
authzVersion: number;
routes: Array<{
code: string;
path: string;
title: string;
component: string;
}>;
};
let routeManifestPromise: Promise<DynamicRouteResponse> | null = null;
const fetchRouteManifest = async (
signal?: AbortSignal,
): Promise<DynamicRouteResponse> => {
if (!routeManifestPromise) {
routeManifestPromise = fetch("/api/auth/routes", { signal }).then(
async (response) => {
if (!response.ok) {
routeManifestPromise = null;
throw new Error(`route api failed: ${response.status}`);
}
return response.json();
},
);
}
return routeManifestPromise;
};
export const preloadDynamicRouteManifest = () => fetchRouteManifest();
export const loadDynamicRoutes: DynamicRouteLoader = async ({
path,
signal,
}) => {
if (!path.startsWith("/system")) {
return;
}
const data = await fetchRouteManifest(signal);
return {
parentId: "root",
cacheKey: `permission:${data.authzVersion}`,
routes: data.routes.map((item) => ({
id: item.code,
path: item.path.replace(/^\//, ""),
lazy: async () => {
const mod = await import(`./pages/${item.component}.tsx`);
return {
Component: mod.default,
loader: mod.loader,
};
},
meta: {
title: item.title,
},
})),
};
};在用户即将进入系统管理区之前,先预热动态路由接口:
import { preloadDynamicRouteManifest } from "./dynamic-routes";
export function SystemEntryButton() {
return (
<button
onFocus={() => {
void preloadDynamicRouteManifest();
}}
onMouseEnter={() => {
void preloadDynamicRouteManifest();
}}
type="button"
>
进入系统管理
</button>
);
}6.7.3 预加载建议
- 预加载触发点优先放在
onMouseEnter、onFocus、首屏空闲时机 - 不要对全站所有路由无差别预加载,否则会抵消懒加载收益
- 动态路由预加载建议只预热“路由清单接口”,页面模块仍由实际访问时再 lazy import
- 如果后续需要统一 API,可以在此包上继续增加
preloadRoute/prefetchDynamicRoutes官方封装
7. 与参考源码的关系
参考目录里的思路本质上是:
- 静态路由先创建
patchRoutesOnNavigation在导航时补权限路由- 动态请求和路由去重放在一个收口函数内
这个包保留了那套思想,但做了三点抽象:
- 业务不再直接操作
RouteObject - 业务不再自己写 patch 去重逻辑
- 业务可以直接返回“路由数组”或“patch 描述”
8. 方案权衡
易用性
- 优先让业务写声明式配置
- 把
patchRoutesOnNavigation、注册去重、导航缓存隐藏在包内 - 保留
component这种业务更容易理解的字段
可扩展性
meta泛型可扩展Extra泛型可扩展业务自定义字段dynamic.loader支持多种动态来源authorization可替换成任何业务权限判定逻辑
可维护性
- normalize、patcher、registry、runtime 分文件
- 不把适配器逻辑和业务字段转换揉成一个文件
- 后续接菜单、埋点、面包屑时只需要在 normalize 层或 handle 层扩展
性能
- 只在需要的导航上触发动态加载
- 同一路径支持缓存
- 路由
id去重避免重复 patch lazy仍然由react-router-dom原生消费
动态注入时机
- 放在导航期,而不是应用启动期
- 更适合权限路由、租户能力路由、插件路由
- 可以在拿到服务端返回后再决定注入哪些页面
与权限系统结合
- 简单鉴权可放
authorization.resolve - 异步权限拉取建议放
dynamic.loader - 页面数据级权限建议继续放页面 loader 或业务请求层
9. npm 发布与交付建议
package.json 关键字段
当前包已采用:
main: ./dist/index.cjsmodule: ./dist/index.jstypes: ./dist/index.d.tsexports同时暴露根入口、./core、./runtime、./typespeerDependenciesreactreact-domreact-router-dom
filesdistREADME.md
构建产物策略
tsc --emitDeclarationOnly生成类型声明swc生成 ESMswc + rename-cjs.js生成 CJS
ESM / CJS / d.ts 策略
- ESM 产物:
dist/**/*.js - CJS 产物:
dist/**/*.cjs - 类型声明:
dist/**/*.d.ts
README 最小内容
至少包含:
- 安装方式
- 快速开始
StaticRouteConfig字段说明dynamic.loader示例- 错误处理方式
- 版本与 peer dependency 说明
用户安装方式
pnpm add @vlian/router react-router-domReact + Vite 最小接入示例
下面给出一个可以直接放进 React + Vite 项目的最小示例,目录大致如下:
src
├── main.tsx
├── router.ts
├── routes.ts
├── dynamic-routes.ts
├── state
│ └── auth-store.ts
└── pages
├── home.tsx
├── login.tsx
└── system
└── users.tsx1. 安装依赖
pnpm add react-router-dom @vlian/router zustand2. 定义权限 store
// src/state/auth-store.ts
import { create } from "zustand";
type AuthState = {
permissions: string[];
setPermissions: (permissions: string[]) => void;
};
export const authStore = create<AuthState>((set) => ({
permissions: [],
setPermissions: (permissions) => set({ permissions }),
}));
export const bootstrapAuth = async () => {
const response = await fetch("/api/auth/me");
if (!response.ok) {
throw new Error(`auth bootstrap failed: ${response.status}`);
}
const data = await response.json();
authStore.getState().setPermissions(data.permissions ?? []);
};3. 定义静态路由
// src/routes.ts
import { Outlet } from "react-router-dom";
import { defineRoutes } from "@vlian/router";
function RootLayout({ children }: { children?: React.ReactNode }) {
return (
<div>
<header>Demo App</header>
<main>{children}</main>
</div>
);
}
const loadHomeModule = () => import("./pages/home");
const loadLoginModule = () => import("./pages/login");
export const routes = defineRoutes([
{
id: "root",
path: "/",
component: Outlet,
layout: RootLayout,
children: [
{
index: true,
lazy: async () => {
const mod = await loadHomeModule();
return {
Component: mod.default,
};
},
meta: {
title: "首页",
},
},
],
},
{
id: "login",
path: "/login",
lazy: async () => {
const mod = await loadLoginModule();
return {
Component: mod.default,
};
},
meta: {
title: "登录",
},
},
]);4. 定义动态路由加载器
// src/dynamic-routes.ts
import type { DynamicRouteLoader } from "@vlian/router";
type AppMeta = {
title?: string;
permissionCode?: string;
};
const pageModuleLoaders: Record<string, () => Promise<any>> = {
"system/users": () => import("./pages/system/users"),
};
export const loadDynamicRoutes: DynamicRouteLoader<AppMeta> = async ({
path,
signal,
}) => {
if (!path.startsWith("/system")) {
return;
}
const response = await fetch("/api/auth/routes", { signal });
if (!response.ok) {
throw new Error(`route api failed: ${response.status}`);
}
const data = await response.json();
return {
parentId: "root",
cacheKey: `permission:${data.authzVersion}`,
routes: data.routes.map((item: any) => ({
id: item.code,
path: item.path.replace(/^\//, ""),
auth: {
required: true,
permissionCode: item.code,
},
meta: {
title: item.title,
permissionCode: item.code,
},
lazy: async () => {
const loadModule = pageModuleLoaders[item.component];
if (!loadModule) {
throw new Error(`unknown page module: ${item.component}`);
}
const mod = await loadModule();
return {
Component: mod.default,
loader: mod.loader,
};
},
})),
};
};5. 创建 router
// src/router.ts
import { createAppRouter } from "@vlian/router";
import { routes } from "./routes";
import { loadDynamicRoutes } from "./dynamic-routes";
import { authStore } from "./state/auth-store";
export const router = createAppRouter({
mode: "browser",
routes,
authorization: {
resolve: ({ auth }) => {
if (!auth || auth === false) {
return true;
}
const permissionCode =
typeof auth === "object" ? auth.permissionCode : undefined;
return (
!permissionCode ||
authStore.getState().permissions.includes(String(permissionCode))
);
},
redirectTo: "/login",
},
dynamic: {
defaultParentId: "root",
loader: loadDynamicRoutes,
getNavigationCacheKey: ({ path }) =>
path.startsWith("/system") ? path : false,
errorMode: "silent",
},
onError: ({ error, path }) => {
console.error("dynamic route load failed", path, error);
},
});6. 在 Vite 入口挂载 RouterProvider
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { bootstrapAuth } from "./state/auth-store";
const start = async () => {
await bootstrapAuth();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);
};
void start();7. 页面文件示例
// src/pages/home.tsx
export default function HomePage() {
return <div>home page</div>;
}// src/pages/login.tsx
export default function LoginPage() {
return <div>login page</div>;
}// src/pages/system/users.tsx
export async function loader() {
return null;
}
export default function UsersPage() {
return <div>users page</div>;
}这个示例的运行过程是:
- 启动时先调用
bootstrapAuth()初始化权限到 store - 创建 browser router,并注册静态路由
- 当用户导航到
/system/**时,dynamic.loader发起接口请求 - 接口返回后按
parentId: "root"把动态路由补丁进当前路由树 - 路由命中后再执行页面级
lazy import
用户引入方式
import { createAppRouter, defineRoutes } from "@vlian/router";初始化方式
const router = createAppRouter({
mode: "browser",
routes,
});产出可供下载使用的构建物
pnpm --filter @vlian/router build打包后发布:
pnpm --filter @vlian/router publish --access public10. 推荐后续增强项
- 增加
beforePatch/afterPatch生命周期钩子 - 支持多路动态 loader 组合
- 把菜单树和路由树的映射收口成官方 helper
- 增加
prefetchDynamicRoutes(paths[]) - 增加测试覆盖:
- normalize
- dynamic patch 去重
- cacheKey
- redirect/layout/auth 包装
11. 当前落地状态
packages/router 已包含:
- 独立 npm 包骨架
- 类型定义
- 主工厂
createAppRouter - 静态路由 normalize
- 动态路由 patcher
- route registry
AppRouterProvider子路径导出- npm 发布和构建配置
如果要继续落到生产可用版本,下一步建议补:
- 单元测试
- 真实业务示例
- changelog
- 发布流水线
