继续上一讲的路由知识,这一集我们构建一个类似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 (
        <nav className="text-center flex-none">
            <Link to="/">Dashboard</Link>
            <Link to="/list">Heroes</Link>
        </nav>
    );
};
// src/pages/heros/layout.tsx
const MasterLayout: FC = ({ children }) => {
    const {
        config: { title },
    } = useConfig();
    return (
        <>
            <h1 className="text-center">{title}</h1>
            <Nav />
            <div className="w-2/3 mx-auto">{children}</div>
        </>
    );
};

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

  • 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
    <Configure config={{ title: '英雄联盟' }}>
            <Router history={historyCreator}>
                <div className="flex-auto">
                    <Switch>
                        <AsyncRoute page="heros/layout" path={['/', '/list', '/detail/:id']} exact>
                            <Switch>
                                <AsyncRoute path="/" page="heros/dashboard" exact />
                                <AsyncRoute path="/list" page="heros/list" />
                                <AsyncRoute path="/detail/:id" page="heros/detail" />
                            </Switch>
                        </AsyncRoute>
                        <AsyncRoute page="auth/login" path="/login" />
                        <AsyncRoute page="errors/404" path="*" />
                    </Switch>
                </div>
                <footer className="flex-none">footer</footer>
            </Router>
        </Configure>

自动路由

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

配置接口

  • RouteConfig类型的配置由用户传入
  • 通过generatePaths函数处理后生成RouterContextProps类型的准确路径的路由配置
// generatePaths处理后的准确路由
export type IRoute<P extends Record<string, any> = Record<string, any>> = Omit<
    BaseRouterProps,
    'children' | 'component' | 'render' | 'path'
> & {
    // 页面可以是一个组件或者字符串,如果是字符串则异步加载pages目录下的页面
    page?: FC<P> | string;
    ...
};
// 跳转路由配置项
export interface RedirectOption extends LocationDescriptorObject {
    from?: string;
}
// 路由配置项
export type RouteOption<P extends Record<string, any> = Record<string, any>> = Omit<
    IRoute<P>,
    'path' | 'redirect' | 'children'
> & {
    path?: string;
    redirect?: string | RedirectOption;
    children?: RouteOption<P>[];
};
// 路由配置
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 (
            <Route
                {...rest}
                key={uuidv4()}
                path={isLayout ? getLayoutPaths(route) : route.path}
                render={() =>
                    typeof Page === 'string' ? (
                        <AsyncPage page={Page} {...params}>
                            {getRoutes(children ?? [])}
                        </AsyncPage>
                    ) : (
                        <Page {...params}>{getRoutes(children ?? [])}</Page>
                    )
                }
            />
        );
    }
    return isLayout ? getRoutes(children ?? [], false) : null;
}

getRoutes

循环递归路由列表,并通过getRoute生成最终路由组件列表

Hooks

useRouteHistory

为默认的useHistory生成的history对象的push,replace,createHref方法的pathname参数添加上basePath

export const useRouteHistory = <S = LocationState>() => {
    const { basePath } = useRouter();
    const history = useHistory<S>();
    const push = useCallback(
        (location: LocationDescriptor<S>, 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 ?? <Loading />, []);
    const View = useMemo(
        () =>
            loadable(() => timeout(pMinDelay(pages[page](), 500), 12000), {
                cacheKey: () => page.replaceAll('/', '-'),
                fallback,
            }),
        [],
    );
    return <View {...rest}>{children}</View>;
};

路由配置

修改原来页面中的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',
        },
    ],
};