继续上一讲的路由知识,这一集我们构建一个类似vue-router,umi那样的配置式路由组件.仿照一个angular文档中的英雄之旅编写一个类似的应用
手动路由
建立一些演示数据和页面
├── data
│ ├── heros.ts
│ ├── index.ts
│ └── types.ts
├── pages
│ ├── auth
│ │ └── login.tsx
│ ├── errors
│ │ └── 404.tsx
│ └── heros
│ ├── components
│ ├── dashboard.tsx
│ ├── detail.tsx
│ ├── layout.tsx
│ ├── list.tsx
│ └── style.css
其中layout.tsx
为布局页面,<Nav />
组件用于导航的连接
// src/pages/heros/components/Nav.tsx
const Nav: FC = () => {
return (
);
};
// src/pages/heros/layout.tsx
const MasterLayout: FC = ({ children }) => {
const {
config: { title },
} = useConfig();
return (
<>
{title}
{children}
>
);
};
其它的页面自行查看代码,它们的作用如下
dashboard
: 首页,用于展示一部分"英雄名称"list
: 英雄列表,用于展示所有英雄detail
: 英雄资料,用于显示单个英雄的详细资料
数据暂时直接采用内部读取的硬数据
// src/data
const heroes: Hero[] = [
...
];
export const getHeros: () => Hero[] = () => heroes;
export const getHero: (id: number) => Hero | undefined = (id) => heroes.find((h) => h.id === id);
先采用上一集的手动方式来配置路由
// src/App.tsx
自动路由
接下来修改上一集的路由组件,以保证可以通过配置的方式来自动生成路由
配置接口
RouteConfig
类型的配置由用户传入- 通过
generatePaths
函数处理后生成RouterContextProps
类型的准确路径的路由配置
// generatePaths处理后的准确路由
export type IRoute = Record> = Omit<
BaseRouterProps,
'children' | 'component' | 'render' | 'path'
> & {
// 页面可以是一个组件或者字符串,如果是字符串则异步加载pages目录下的页面
page?: FC | string;
...
};
// 跳转路由配置项
export interface RedirectOption extends LocationDescriptorObject {
from?: string;
}
// 路由配置项
export type RouteOption
= Record> = Omit<
IRoute,
'path' | 'redirect' | 'children'
> & {
path?: string;
redirect?: string | RedirectOption;
children?: RouteOption
[];
};
// 路由配置
export interface RouteConfig {
basePath?: string; // 基础url路径
hash?: boolean; // 是否hash
routes?: RouteOptions[]; // 路由列表
}
// generatePaths处理后的最终路由配置
export interface RouterContextProps {
basePath: string;
routes: IRoute[];
}
函数解析
formatPath
用于格式化路径,实现了以下规则
- 一个页面的路由路径等于其{父路由路径}/{当前路由路径},比如布局下的子路由
- 如果没有父路由则为配置中的{basePath}/{当前路由路径}
- 如果路由的路径以"*"开头并且是顶级路由,比如404页面,则直接返回当前路径
generatePaths
使用formatPath
递归处理路由的路径以及跳转路由的from
和to.pathname
,最终生成IRoute
类型的路由数组
无论配置中传入的
redirect
配置是字符串还是对象,最终会生成一个带from
的用于Redirect
组件的RedirectOption
类型对象
function generatePaths(routes: RouteOption[], basePath: string, parentPath?: string): IRoute[] {
return (routes.filter((route) => route.path !== undefined) as IRoute[]).map((route) => {
...
return item;
});
}
getLayoutPaths
在生成路由组件时,把布局路由(有children选项的路由)下的子路由的路径提取出来合并到布局路由中
通过
includes
函数自动剔除已包含的路由
比如
{
path: '/heros',
page: 'heros/layout',
children: [
{
exact: true,
path: '',
page: 'heros/dashboard',
},
{
path: 'list',
page: 'heros/list',
},
{
path: 'detail/:id',
page: 'heros/detail',
},
],
}
会生成['/heros','/heros/list','/heros/detail/:id']
作为/heros/layout
的路由路径
原因在于动态配置的异步加载页面是无法预知其内部的路由的,所以把所有访问其子组件的URL先定位到父路由然后才能定位到其子路由
getRoute
生成页面路由和跳转路由组件
- 跳转路由: 用于生成
Redirect
组件路由 - 页面路由: 页面路由如果
page
是字符串则使用AsyncPage
加载异步页面,如果有子路由则使用getRoutes
嵌套 - 空路由: 如果没有
redirect
与page
字段,那么如果有children
,且children
不是空数组则此路由只是用于路径拼接,直接提取其下的子路由列表,否则就是空路由
function getRoute(route: IRoute) {
const { page: Page, redirect, loading, children, params, ...rest } = route;
const isLayout = (children ?? []).length > 0;
if (redirect) {...}
if (Page) {
return (
typeof Page === 'string' ? (
{getRoutes(children ?? [])}
) : (
{getRoutes(children ?? [])}
)
}
/>
);
}
return isLayout ? getRoutes(children ?? [], false) : null;
}
getRoutes
循环递归路由列表,并通过getRoute
生成最终路由组件列表
Hooks
useRouteHistory
为默认的useHistory
生成的history
对象的push
,replace
,createHref
方法的pathname
参数添加上basePath
export const useRouteHistory = () => {
const { basePath } = useRouter();
const history = useHistory();
const push = useCallback(
(location: LocationDescriptor, state?: S) =>
history.push(getHistoryOption(basePath, location, state)),
[basePath, history],
);
...
return { ...history, push, replace, createHref };
};
useRouter
获取generatePaths
处理后的路由配置
路由组件
RouteLink与RouteNavLink
对to
参数使用formatPath
函数处理,为to.pathname
添加上basePath
前缀
Router
根据传入的路由配置生成最终的路由组件列表
AsyncPage
现在的路由组件列表已经是动态生成,所以原来useMemo
中的url
依赖需要去除才能保证页面不会被重复渲染
export const AsyncPage = ({ page, loading, children, ...rest }: PageProps) => {
const fallback = useMemo(() => loading ?? , []);
const View = useMemo(
() =>
loadable(() => timeout(pMinDelay(pages[page](), 500), 12000), {
cacheKey: () => page.replaceAll('/', '-'),
fallback,
}),
[],
);
return {children} ;
};
路由配置
修改原来页面中的Link
为RouteLink
,并为需要编程式导航的按钮添加上通过useRouteHistory()
获取的history
的push
,最后添加测试配置
import { RouteConfig } from '../components/Router';
import List from '../pages/heros/list';
export const router: RouteConfig = {
routes: [
{
path: '/',
exact: true,
page: 'home',
},
{
path: '/heros',
page: 'heros/layout',
children: [
{
exact: true,
path: '',
page: 'heros/dashboard',
},
{
path: 'list',
// 测试组件路由
page: List,
},
{
path: 'detail/:id',
page: 'heros/detail',
},
],
},
{
path: '/redirect',
redirect: '/',
},
{
path: '/login',
page: 'auth/login',
},
{
path: '*',
page: 'errors/404',
},
],
};