likes
comments
collection
share

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

作者站长头像
站长
· 阅读数 17

1 前言

大家好,我是心锁,23届准毕业生。

时间逐渐流淌,我也差不多要摘掉准字了,在这段过渡期,我在尝试进行更全面的学习。

最近在学习react-router v6.10+,由于新项目又要配置路由,长期配置路由这种重复性工作真是非常xx。而我有幸曾接触过小程序开发和和NextJS,一向对于这种约定式路由非常向往,所以寻思了一下,能否自己手搓一个?

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

又因为,对于网页操作中常见的页面跳转回退操作,需要让手头的React 18项目支持持久化keep-alive,不得不想些办法让组件状态能得以保存。

那么,本文就出来了。

2 学习内容概述

阅读本文,你可以学习到包括但不限于以下内容:

  • 深入理解react router v5到v6的设计理念差别,进而理解如何使用react router v6。
  • 如何基于webpack或vite进行约定式路由搭建。
  • 完成一款实用的基于react-router v6+antd5的路由面包屑。
  • 如何在react中实现keep-alive(基于react-activation,无需使用babel),并结合约定式路由使用。

3 ReactRouter v5 vs v6

ReactRouter v5和v6在设计理念上有着显著的不同。其中最关键的一点,来自于v6的树形、更深刻的嵌套思维。我们这里并不具体去描述过多v5 和 v6的区别,只针对我踩的坑,因为我认为官网的文章已经非常具体生动了。

——这里是react router v6官网

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

3.1 理念差别

从v5升级到v6后,我能明显感觉到某些地方完全违背了我的想法,这是因为我常站在v5的角度思考,参照着v5的方式去构建路由。一部分原因是react-router v5是平铺的,绝对的,而react-router v6是相对的,嵌套性更强的。

在v6中,没有Switch ,取而代之的是Routes ,但是实际上由于Route 的功能变化,让Routes的存在感直线降低。其中比较直观的一点是,我们无法再为Route 的子元素传递除了和<></>之外的元素,也就是用来嵌套一组路由的Routes 实际上应该放置在element中。在这个前提下,与其使用Routes不如直接使用Route嵌套,效果更佳。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

我们大致理解一下这张图片,我们后边的路由转换会基于这种结构进行。

3.2 一些我踩了坑的使用差别

上文我们说过,从v5到v6,差别非常之大。官方文档虽然有升级指南,还是不得不踩坑。

3.2.1 history完全废除

在过去的v5中,如果我们需要进行命令式跳转行为,需要使用到useHistory 。而如果不在函数式组件内跳转,则需要从history 库中导入createHashHistory或createBrowerHistory使用:

import { createHashHistory } from 'history'

const history = globalThis.document ? createHashHistory() : null
export default history

在新版本中,我们应该使用useNavigate 。navigate是v6版本对于跳转行为的重要设计,在v6中如果需要不使用hook进行页面跳转,那么我们需要使用createHashRoutercreateBrowserRouter 创建路由router,通过router.navigate 实现路由跳转,需要注意的是:可能是bug,目前v6.10通过router.navigate 进行跳转不会自动带上basename,而通过useNavigate 或者Link则会自动带上basename。

可以通过下边代码简单兼容一下:

const { navigate } = router;
router.navigate = (to: any, ...rest: any[]) => {
  if (typeof to === "string") {
    return navigate(`/juejin${to}`, ...rest);
  }
  return navigate(to, ...rest);
};

3.2.2 消失的Redirect

在v5时代,有个非常受欢迎的API,Redirect 。然而,在v6,它非常突兀地消失了——换了个名叫Navigate,此坑注意。

// v5
<Switch>
  <Redirect from="about" to="about-us" />
</Switch>

//v6
<Routes>
  <Route path="about" element={<Navigate to="about-us"/>} />
</Routes>

4 路由搭建

约定式路由,是我接触的又一个软件设计范式。本质是说,开发人员仅需规定应用中不符约定的部分。对于路由来说,这真是一种棒极了的范式。

而结合react-router实现约定式路由的具体实现,我们需要一些要素,集齐了这些要素,咩都搞得定:

  • 我们需要知道文件路径。
  • 我们需要能依据文件路径导入文件,得到我们需要的信息。
  • 我们需要约定好,如何规定不符约定的部分,比如是否keep-alive、页面标题等内容。

总结来说,只要上述的信息,能够转换成router,我们就可以实现自动化导入。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

4.1 自动导入文件

可以通过Webpack或者Vite等打包工具中的require.context实现自动化加载。

在Webpack中,可以通过require.context 进行自动化导入,我们可以这样配置:

const requireContext = require.context(
  // 组件目录的相对路径
  '../pages',
  // 是否查询子目录
  true,
  // 组件文件名的正则表达式
  // 只会包括以 `.tsx` 结尾的文件
  /\.tsx$/
)

在Vite中,使用import.meta.glob ,不过我还没有在vite中实践过,只做了简单测试。

const requireContext = import.meta.glob('../pages/*.tsx')

请注意,后续的代码均是在webpack环境下工作。

我们通过自动导入获取到的requireContext 变量是用于导入文件的函数,我们需要通过requireContext.keys()获取路径名。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

requireContext.keys() 的返回值是一个包含符合匹配路径的字符串数组,其中包括了绝对路径和相对路径:

["pages/index.tsx","./index.tsx","pages/user/index.tsx","./user/index.tsx"]

我们可以试着调用:requireContext("pages/index.tsx")。Soga,我们可以通过requireContext("pages/index.tsx").default得到我们文件的导出。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

那么如果我们想做的是动态加载呢?即支持React.lazy的异步组件。理论上,我们可以调用import(”pages/index.tsx”) ,但是,import要求不能传递一个变量进去。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

我查阅了相关文档,发现虽然import不能传递变量,但是可以传递多个参数,我们只需要先传递固定的前缀,之后再传递变量即可:

const a = "index.tsx";
import("pages/", a);

此时会发现,导入可以正常运行,导入成功:

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

那么此时,我们已经集齐了三要素中的两个,现在我们可以基于router的类型进行约定外的配置约束。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

4.2 source.tsx

在小程序的约定式路由中,以文件夹下的xxx.json 文件作为约定外配置,在nextjs中更夸张些,可以在文件名中直接定义[id] 表示动态参数。

我希望结合当前目录架构和上述的经验形成更个性化的配置方式,所以有了source.tsx 文件。

其实一开始还有两个方案,但是都因为存在缺陷被我毙掉了:

  • 在文件夹用同名的xxx.json 表示配置,但是使用json格式则我们无法使用jsx语法,该方案毙掉。
  • 在页面文件夹通过export {source} 导出source变量,在变量中定义个性化信息。理论上该方案可以基于ESM可以做静态优化,不会导致页面组件代码被二次导入,但是这种方式将内容耦合进了页面中,我不喜欢。

那么我们定下来通过在页面文件夹下的source.tsx 文件定义约定外配置来完善架构。

也就是说,我们的目录结构应该类似这样:

pages
|——user/
    |————index.tsx
    |____source.tsx
|——index.tsx
|__source.tsx

出于项目规范考虑,我们还应该充分利用Ts进行类型注释,所以我们借鉴了vite的defineConfig

import type { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";

export type PageSource = Omit<RouteObject, "handle"> & {
  redirect?: string;
  handle?: {
    crumb: ReactNode | (() => ReactNode);
    title: ReactNode | (() => ReactNode);
  };
};

export const defineConfig = (config: PageSource) => config;

当前阶段,我们的source对象暂定为上述的PageSource ,其中提供redirect是出于可以进行方便滴重定向,handle则是可能有用的一些变量,目前我们加入了crumbtitle ,前者用于在面包屑中加载,后者用于渲染进页面title

那么此时,我们已经集齐了三要素,可以尝试搭建路由。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

4.3 搭建

正式开始搭建,首先,我们进行自动导入。目前的规则是,导入pages文件夹下所有不包含component或者hook的文件,同时,去除所有的相对路径导入。注意,结合我的项目都具体情况,页面的定义是某个文件夹下的index.tsx文件。也就是所有的页面必须是index.tsx文件。

const pageRequireContext = require.context(
  "./pages",
  true,
  /^(?!.*(component|hook)).*\.tsx$/
);

const filterRequirePath = (path: string) => {
  if (path.startsWith("./")) {
    return null;
  }
  return path;
};

const pageRequireContextKeys = pageRequireContext
  .keys()
  .map(filterRequirePath)
  .filter(Boolean) as string[];

由于我们通过pageRequireContext.keys() 获取到的内容都是平铺的,而我们的路由树是嵌套的。我们需要将转换成一个对象。同时,在这个处理过程中,我们可以先忽略source文件,这里通过filter将其过滤。

export interface Route {
  path: string;
  element: React.ReactNode;
  redirect?: string;
  children?: Route[];
  _children?: Route[];
}

const flatRoutes: Omit<Route, "element">[] = pageRequireContextKeys
    .filter((path) => !path.includes("source.tsx"))
    .map((path) => {
      const route =
        path === "pages/index.tsx"
          ? ""
          : path.replace("/index.tsx", "").replace("pages/", "");
      return {
        path: `/${route}`,
      };
    });

按照上述代码操作后,我们得到了一份基础路由列表,现在我们可以根据这份列表进行树状转换了。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

下边这份代码和上边的flatRoutes 处于同一函数initStore内。在下边这份代码中,我们首先找到了头部元素,即path为”/”的路由,然后对整个平铺路由列表进行遍历,通过对route.path进行分割,可以得到子路由的path,我们过滤掉了根目录,并且初始router直接在store中声明,是因为flatRoutes的顺序并不是从根目录开始。

export interface RouterStore {
  route: Route;
  [key: string]: any;
}

const store: RouterStore = {
   route: flatRoutes.find((i) => i.path === "/") as Route,
};

for (const route of flatRoutes) {
  const pathArr = route.path.split("/");
  const path = pathArr[pathArr.length - 1];
  if (path === "") {
    continue;
  }
  let current = store;
  for (let i = 0; i < pathArr.length; i++) {
    if (i == 0) {
      continue;
    }
    const path = pathArr[i];
    if (!current[path]) {
      current[path] = {};
    }
    current = current[path];
    if (i === pathArr.length - 1) {
      current.route = route as Route;
    }
  }
}

那么此时树形转换完毕,我们得到了一个RouterStore。接下来我们需要遍历整个store,将source的数据和约定的路由数据加入store中。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

我们先准备好source数据,再遍历store。由于source.tsx文件和页面文件index.tsx同级,只有文件名差异,所以我们生成一个映射,方便后边直接通过文件名索引到source数据。

const pageSource = Object.fromEntries(
  pageRequireContextKeys
    .filter((path) => path.includes("source.tsx"))
    .map((path) => {
      const source = pageRequireContext(path).default;
      return [path.replace("source.tsx", "index.tsx"), source];
    })
);

在下边这份代码中,通过递归的方式遍历整个store,确保每一个route都能被放置到自己父级的children中。

const generateRouter = (storeItem: RouterStore) => {
  let route: Route | null = storeItem["route"];
  for (const key in storeItem) {
    if (key === "route") {
      continue;
    }
    const item = storeItem[key];
    if (!route) {
      route = {
        path: key,
      } as Route;
    }
    if (!route.children) {
      route.children = [];
    }
    route.children.push(generateRouter(item));
  }
  if (!storeItem["route"]) {
    return route as Route;
  }
  ...
};

在后半部分,首先仍是对于根目录的特殊判定,其次则是通过路径映射得到source数据。我们在处理source数据时,还应该对于children做特殊处理,一般来讲如果需要在source.tsx中额外声明children,初衷一般是将children插入到现有children后边而不是替换,所以这里做了额外处理,将其保存为_children准备进入下一流程。而source的其他数据,会直接合并进route中。

const generateRouter = (storeItem: RouterStore) => {
  ...
  const path =
    route.path === "/"
      ? "index.tsx"
      : route.path.replace("/", "") + "/index.tsx";

  const source = pageSource[`pages/${path}`] || {};
  const { children, ...rest } = source;
  const Module = lazy(() => import(`./pages/${path}`));

  const betterRoute: Route = {
    ...route,
    path: route.path === "/" ? "/" : route.path.split("/").at(-1),
    element: <Suspense fallback={<LoadingFallback />}><Module/></Suspense>,
    ...(children && {
      _children: children,
    }),
    ...rest,
  };
  return betterRoute;
};

那么我们运行该函数,可以得到baseRoutes ,这已经是一个满足createRouter使用的routes变量。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

我们可以通过下边的代码使用:

const createRouter =
  IS_DEV ? createHashRouter : createBrowserRouter;
export type MyRouter = ReturnType<typeof createRouter>;
export const router: MyRouter = createRouter([routes]);

const AppRouter = () => {
  return <RouterProvider router={router}></RouterProvider>;
};

5 补充约定

我们上边的内容,已经完成了约定式路由的基本搭建。但是我希望我们能够通过配置简化很多常用操作。所以加一些额外的处理。

5.1 transformRouter

这份代码,补充了一些额外约定,比如我们可以通过直接定义redirect=“/user/base”自动生成重定向需要的代码。同时,也将上边的_children利用上,将其加入children尾部。

const transformRoute = (route: Route) => {
  if (route.children) {
    route.children = route.children.map(transformRoute);
  }
  if (route.redirect) {
    if (!route.children) {
      route.children = [];
    }
    route.children.push({
      path: "",
      element: <Navigate to={route.redirect} replace />,
    });
  }
  if (route._children) {
    if (!route.children) {
      route.children = [];
    }
    route.children.push(...route._children);
  }

  return route;
};

5.2 keep-alive

众所周知,react一直缺乏官方的keep-alive,官方的还不知道啥时候能出来。所以我们需要借助第三方库,我这里选用的就是**react-activation。**

来到generateRouter函数,我们适当改造一下generateRouter,在其中加入keep-alive。需要注意的是,由于我们目前在react18,并且是通过createRoot的形式使用,默认开启了concurrent,**react-activation在这方面的支持度相对较差。**主要体现在于存在异步组件可能会导致刷新页面后keep-alive在回退等场景白屏的情况,我们可以将keep-alive的页面通过default导入,就不会出现该情况。

import KeepAlive from "react-activation";

const generateRouter = (storeItem: RouterStore) => {
  ...
  const path =
    route.path === "/"
      ? "index.tsx"
      : route.path.replace("/", "") + "/index.tsx";

  const source = pageSource[`pages/${path}`] || {};
  const { children, keepAlive, ...rest } = source;
	const ele = (() => {
    if (keepAlive) {
      // BUGFIX:由于react-activation的bug,这里不能使用异步组件,需要手动引入页面
      const PageDefault = pageRequireContext(`pages/${path}`).default;
      return (
        <KeepAlive
          cacheKey={route.path}
          autoFreeze={false}
          name={route.path}
          {...rest}
        >
          <PageDefault />
        </KeepAlive>
      );
    }
    const Module = lazy(() => import(`./pages/${path}`));
    return <Module />;
  })();

  const betterRoute: Route = {
    ...route,
    path: route.path === "/" ? "/" : route.path.split("/").at(-1),
    element: <Suspense fallback={<LoadingFallback />}>{ele}</Suspense>,
    ...(children && {
      _children: children,
    }),
    ...rest,
  };
  return betterRoute;
};

同时,我们需要为KeepAlive组件提供Provider,推荐以下边的形式添加。

import { AliveScope } from "react-activation";

routes.element = <AliveScope>{routes.element}</AliveScope>;
export const router: MyRouter = createRouter([routes]);

当然,别忘了。我们修改一下source.type ,补充完整的类型.

import type { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";
import type { KeepAliveProps } from "react-activation";

export type PageMeta = Omit<RouteObject, "handle"> & {
  redirect?: string;
  handle?: {
    crumb: ReactNode | ((d: any) => ReactNode);
    title: ReactNode | ((d: any) => ReactNode);
  };
  keepAlive?: Partial<KeepAliveProps>;
};

export const defineConfig = (config: PageSource) => config;

那么此时,我们可以在source.tsx文件中使用我们拓展后的约定。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

6 基于v6的自动路由面包屑

在这里就不得不吐槽一下Antd,因为我在官网找到的案例丑陋到爆炸,并且感觉并不可用。我们通过useMatches自己做一个,这个面包屑,会通过useMatches自动生成路由导航。同时,我们通过扩展了back 属性,让面包屑适应返回场景。

import React from "react";
import { useMatches } from "react-router";
import { Link } from "react-router-dom";
import { RollbackOutlined } from "@ant-design/icons";
import {
  Breadcrumb as AntdBreadcrumb,
  BreadcrumbProps as AntdBreadcrumbProps,
} from "antd";

const backTitle = (
  <Link className="cursor-pointer" to={"../"}>
    <RollbackOutlined className="mr-2" />
    <span>返回</span>
  </Link>
);
export type BreadCrumbProps = AntdBreadcrumbProps & {
  back?: boolean;
  className?: string;
};

const BreadCrumb: React.FC<BreadCrumbProps> = ({
  back = false,
  className = "",
  ...rest
}) => {
  const matches = useMatches() as { handle: PageSource["handle"]; data: any }[];
  const _crumbs = matches.filter((match) =>
    Boolean(match.handle?.crumb && match.handle?.title)
  );

	const crumbs = _crumbs.map((match, index) => {
    const routerHandle = match.handle as NonNullable<PageSource["handle"]>;
    const title =
      typeof routerHandle.name === "function"
        ? routerHandle.title(match.data)
        : routerHandle.title;
    const crumb =
      typeof routerHandle.crumb === "function"
        ? routerHandle.crumb(match.data)
        : routerHandle.crumb;
    return index === _crumbs.length - 1 ? title : crumb;
  });

  if (crumbs.length === 0) {
    return null;
  }

  const items = back
    ? [{ title: backTitle }, { title: crumbs[crumbs.length - 1] }]
    : crumbs.map((crumb) => ({
        title: crumb,
      }));

  return (
    <AntdBreadcrumb
      className={`w-full p-4 pt-2 ${className}`}
      items={items}
      {...rest}
    />
  );
};
  • 正常面包屑

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

  • 返回面包屑

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

如此一来,面包屑完全可以自适应路由,我们在开发时可以更加得心应手。

干货!我是如何在React-Router 6.10最新版本实现约定式路由的

7 总结

本文介绍了如何使用约定式路由架构和keep-alive最佳实践来搭建React-router v6.10的自动化路由系统。首先,我们介绍了如何使用一个平铺路由列表进行遍历,然后根据route.path对其进行分割,从而得到子路由的path。接着,我们介绍了如何遍历整个store并将source的数据和约定的路由数据加入store中。我们还介绍了如何通过配置简化常用操作,如何使用第三方库react-activation来实现keep-alive,并提供了一个自动化路由面包屑的实现方式。

在React-router v6.10的自动化路由系统中,我们可以使用约定式路由架构和keep-alive最佳实践来搭建一个高效、自动化的路由系统。通过这种方式,我们可以更快地开发应用程序,并且提高其性能和可维护性。


本文正在参加「金石计划」