统一路由、菜单、面包屑和权限配置

Featured Image

我最近做的一个新项目是一个典型的中后台项目,采用的是 React + React Router + Antd 方案。正常情况下我们需要定义路由配置,在页面中定义面包屑的数据,页面写完之后需要在左侧菜单中增加页面的路由。写多了之后,我会觉得同一个路由的相关信息在不同的地方重复声明,实在是有点麻烦,为什么我们不统一在一个地方定义,然后各个使用的地方动态获取呢?

单独配置

首先我们看看每个功能单独定义是如何配置的,之后我们再总结规律整理成一份通用的配置。

路由和权限

路由我们使用了 react-router-config 进行了声明化的配置。

// router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: [
      {
        path: '/goods',
        exact: true,
        title: '商品列表',
        component: GoodsList,
      },
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem,
      }
    ],
  },
];

//app.tsx
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { routes } from './router';

export default function App() {
  return <Router>{renderRoutes(routes)}</Router>;
};

菜单

左侧导航菜单我们使用的是 <Menu /> 组件,大概的方式如下:

//./layouts/default
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Layout, Menu } from 'antd';

export default function({route}) {
  return (
    <Layout>
      <Layout.Header>
        Header
      </Layout.Header>
      <Layout>
        <Layout.Sider>
          <Menu mode="inline">
            <Menu.SubMenu title="商品管理">
              <Menu.Item key="/goods">商品列表</Menu.Item>
            </Menu.SubMenu>
          </Menu>
        </Layout.Sider>
        <Layout.Content>
          {renderRoutes(route.routes)}
        </Layout.Content>
      </Layout>
    </Layout>
  );
}

权限

这里的权限主要指的是页面的权限。我们会请求一个服务端的权限列表接口,每个页面和功能都对应一个权限点,后台配置后告知我们该用户对应的权限列表。所以我们只需要记录每个页面对应的权限点,并在进入页面的时候判断下对应的权限点在不在返回的权限列表数据中即可。

而页面权限与页面是如此相关,所以我们惯性的会将页面的权限点与页面路由配置在一块,再在页面统一的父组件中进行权限点的判断。

// router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: [
      {
        path: '/goods',
        exact: true,
        title: '商品列表',
        component: GoodsList,
        permission: 'goods',
      },
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem,
        permission: 'goods-item',
      }
    ],
  },
];

// ./layouts/default
import React, { useEffect, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { matchRoutes } from 'react-router-config';

export default function({route}) {
  const history = useHistory();
  const location = useLocation();
  const page = useMemo(() => matchRoutes(route.routes, location.pathname)?.[0]?.route, [
    location.pathname,
    route.routes,
  ]);

  useEffect(() => {
    getPermissionList().then(permissions => {
      if(page.permission && !permissions.includes(page.permission)) {
        history.push('/no-permission');
      }
    })
  }, []);
}

面包屑

面包屑则比较简单了,直接使用 <Breadcrumb /> 即可

//./pages/goods-item.tsx
import React from 'react';
import { Link } from 'react-router-dom';
import { Breadcrumb } from 'antd';

export default function() {
  return (
    <Breadcrumb>
      <Breadcrumb.Item>
        <Link to="/goods">商品列表</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>商品详情</Breadcrumb.Item>
    </Breadcrumb>
  );
}

合并配置

通过上面的整理我们可以看到,所有的功能都是和配置相关,所有的配置都是对应路由的映射。虽然路由本身是平级的,但由于菜单和面包屑属于多级路由关系,所有我们的最终配置最好是多级嵌套,这样可以记录层级关系,生成菜单和面包屑比较方便。

最终我们定义的配置结构如下:

//router-config.ts
import type { RouterConfig } from 'react-router-config';
import GoodsList from './pages/goods-list';
import GoodsItem from './pages/goods-item';

export interface PathConfig extends RouterConfig {
  menu?: boolean;
  permission?: string;
  children?: PathConfig[];
}

export const routers = [
  {
    path: '/goods',
    exact: true,
    title: '商品列表',
    component: GoodsList,
    children: [
      {
        path: '/goods/:id',
        exact: true,
        title: '商品详情',
        component: GoodsItem
      }
    ]
  }
];

路由

基于上面的嵌套配置,我们需要定义一个 flatRouters() 方法将其进行打平,替换原来的配置即可。

//router.ts
import { RouteConfig } from 'react-router-config';
import DefaultLayout from './layouts/default';
import { routers, PathConfig } from './router-config';

function flatRouters(routers: PathConfig[]): PathConfig[] {
  const results = [];
  for (let i = 0; i < routers.length; i++) {
    const { children, ...router } = routers[i];
    results.push(router);
    if (Array.isArray(children)) {
      results.push(...routeFlat(children));
    }
  }
  return results;
}

export const routes: RouteConfig[] = [
  {
    component: DefaultLayout,
    routes: flatRouters(routers),
  },
];

菜单

菜单本身也是嵌套配置,将其正常渲染出来即可。

//./layouts/default
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Layout, Menu } from 'antd';

const NavMenu: React.FC<{}> = () => (
  <Menu mode="inline">
    {routers.filter(({ menu }) => menu).map(({ title, path, children }) => (
      Array.isArray(children) && children?.filter(({ menu }) => menu).length ? (
        <Menu.SubMenu key={path} title={title} icon={icon}>
          {children.filter(({ menu }) => menu).map(({ title, path }) => (
            <NavMenuItem key={path} title={title} path={path} />
          ))}
        </Menu.SubMenu>
      ) : (
        <NavMenuItem key={path} title={title} path={path} />
      )
    ))}
  </Menu>
);

const NavMenuItem: React.FC<{path: string, title: string}> = ({path, title}) => (
  <Menu.Item>
    {/^https?:\/\//.test(path) ? (
      <a href={path} target="_blank" rel="noreferrer noopener">{title}</a>
    ) : (
      <Link to={path}>{title}</Link>
    )}
  </Menu.Item>
);

export default function({route}) {
  return (
    <Layout>
      <Layout.Header>
        Header
      </Layout.Header>
      <Layout>
        <Layout.Sider>
          <NavMenu />
        </Layout.Sider>
        <Layout.Content>
          {renderRoutes(route.routes)}
        </Layout.Content>
      </Layout>
    </Layout>
  );
};

面包屑

面包屑的难点在于我们需要根据当前页面路由,不仅找到当前路由,还需要找到它的各种父级路由。

除了定义一个 findCrumb() 方法来查找路由之外,为了方便查找,还在配置上做了一些约定。

如果两个路由是父子关系,那么他们的路由路径也需要是包含关系。例如商品列表和商品详情是父子路由关系,商品列表的路径是 /goods,那么商品详情的路由则应该为 /goods/:id

这样在递进匹配查找的过程中,只需要判断当前页面路由是否包含该路径即可,减小了查找的难度。

另外还有一个问题大家可能会注意到,商品详情的路由路径是 /goods/:id,由于带有命名参数,当前路由去做字符串匹配的话肯定是没办法匹配到的。所以需要对命名参数进行正则通配符化,方便做路径的匹配。

命名参数除了影响路径查找之外,还会影响面包屑的链接生成。

由于带有命名参数,我们不能在面包屑中直接使用该路径作为跳转路由。为此我们还需要写一个 stringify() 方法,通过当前路由获取到所有的参数列表,并对路径中的命名参数进行替换。

这也是为什么之前我们需要将父子路由的路径定义成包含关系。子路由在该条件下肯定会包含父级路径中所需要的参数,极大的方便我们父级路由的生成。

//src/components/breadcrumb.tsx
import React, { useMemo } from 'react';
import { Breadcrumb as OBreadcrumb, BreadcrumbProps } from 'antd';
import { useHistory, useLocation, useParams } from 'react-router';
import Routers, { PathConfig } from '../router-config';

function findCrumb(routers: PathConfig[], pathname: string): PathConfig[] {
  const ret: PathConfig[] = [];
  const router = routers.filter(({ path }) => path !== '/').find(({ path }) =>
    new RegExp(`^${path.replace(/\:[a-zA-Z]+/g, '.+?').replace(/\//g, '\\/')}`, 'i').test(pathname)
  );
  if (!router) { return ret; }

  ret.push(router);
  if (Array.isArray(router.children)) {
    ret.push(...findCrumb(router.children, pathname));
  }
  return ret;
}

function stringify(path: string, params: Record<string, string>) {
  return path.replace(/\:([a-zA-Z]+)/g, (placeholder, key) => params[key] || placeholder);
}

const Breadcrumb = React.memo<BreadcrumbProps>(props => {
  const history = useHistory();
  const params = useParams();
  const location = useLocation();

  const routers: PathConfig[] = useMemo<PathConfig[]>(
    () => findCrumb(Routers, location.pathname).slice(1), 
    [location.pathname]
  );

  if (!routers.length || routers.length < 2) {
    return null;
  }

  const data = props.data ? props.data : routers.map(({ title: name, path }, idx) => ({
    name,
    onClick: idx !== routers.length - 1 ? () => history.push(stringify(path, params)) : undefined,
  }));
  return (
    <OBreadcrumb {...props}>
      {data.map(({name, onClick}) => (
        <Breadcrumb.Item key={name}>
          <span onClick={onClick}>{name}</span>
        </Breadcrumb.Item>
      ))}
    </OBreadcrumb>
  );
});

export default Breadcrumb;

后记

至此我们的统一配置基本上就屡清楚了,我们发现只是简单的增加了几个属性,就让所有的配置统一到了一起。甚至我们可以更上一层楼,把 component 这个配置进行声明化,最终的配置如下:

//router-config.json
[
  {
    path: "/goods",
    exact: true,
    title: "商品列表",
    component: "goods-list",
    children: [
      {
        path: "/goods/:id",
        exact: true,
        title: "商品详情",
        component: "goods-item"
      }
    ]
  }
]

//router-config.tsx
import React from 'react';
import type { RouterConfig } from 'react-router-config';
import routerConfig from './router-config.json';

export interface PathConfig extends RouterConfig {
  menu?: boolean;
  permission?: string;
  children?: PathConfig[];
}

export interface PathConfigRaw extends PathConfig {
  component?: string;
  children?: PathConfigRaw[];
}

function Component(router: PathConfigRaw[]): PathConfig[] {
  return router.map(route => {
    if(route.component) {
      const LazyComponent = React.lazy(() => import(`./pages/${route.component}`));
      route.component = (
        <React.Suspense fallback="loading...">
          <LazyComponent />
        </React.Suspense>
      );
    }

    if(Array.isArray(route.children)) {
      route.children = Component(route.children);
    }

    return route;
  });
}

export const routers = Component(routerConfig);

将这些配置声明化,最大的好处是我们可以将其存储在后台配置中,通过后台菜单管理之类的功能对其进行各种管理配置。

当然这种统一配置也不一定适合所有的场景,大家还是要具体问题具体分析。比如有同事和我反馈说微前端的场景里可能就不是特别合适,不管怎么统一配置,主应用和子应用中可能都需要分别存在一些配置。主应用需要菜单,子应用需要路由,这种时候可能稍微拆分一下反而更倒是合适的。

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

0 评论