继续上一讲的路由知识,这一集我们构建一个类似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}

其它的页面自行查看代码,它们的作用如下

  • 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
    
            
                
footer

自动路由

接下来修改上一集的路由组件,以保证可以通过配置的方式来自动生成路由

配置接口

  • 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递归处理路由的路径以及跳转路由的fromto.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嵌套
  • 空路由: 如果没有redirectpage字段,那么如果有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};
};

路由配置

修改原来页面中的LinkRouteLink,并为需要编程式导航的按钮添加上通过useRouteHistory()获取的historypush,最后添加测试配置

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',
        },
    ],
};

发表评论

您的电子邮箱地址不会被公开。