干货!我是如何在React-Router 6.10最新版本实现约定式路由的
1 前言
大家好,我是心锁,23届准毕业生。
时间逐渐流淌,我也差不多要摘掉准字了,在这段过渡期,我在尝试进行更全面的学习。
最近在学习react-router v6.10+,由于新项目又要配置路由,长期配置路由这种重复性工作真是非常xx。而我有幸曾接触过小程序开发和和NextJS,一向对于这种约定式路由非常向往,所以寻思了一下,能否自己手搓一个?
又因为,对于网页操作中常见的页面跳转回退操作,需要让手头的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官网。
3.1 理念差别
从v5升级到v6后,我能明显感觉到某些地方完全违背了我的想法,这是因为我常站在v5的角度思考,参照着v5的方式去构建路由。一部分原因是react-router v5是平铺的,绝对的,而react-router v6是相对的,嵌套性更强的。
在v6中,没有Switch
,取而代之的是Routes
,但是实际上由于Route
的功能变化,让Routes的存在感直线降低。其中比较直观的一点是,我们无法再为Route
的子元素传递除了和<></>之外的元素,也就是用来嵌套一组路由的Routes
实际上应该放置在element中。在这个前提下,与其使用Routes
不如直接使用Route嵌套,效果更佳。
我们大致理解一下这张图片,我们后边的路由转换会基于这种结构进行。
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进行页面跳转,那么我们需要使用createHashRouter
或createBrowserRouter
创建路由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,我们就可以实现自动化导入。
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()
获取路径名。
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.lazy的异步组件。理论上,我们可以调用import(”pages/index.tsx”)
,但是,import要求不能传递一个变量进去。
我查阅了相关文档,发现虽然import不能传递变量,但是可以传递多个参数,我们只需要先传递固定的前缀,之后再传递变量即可:
const a = "index.tsx";
import("pages/", a);
此时会发现,导入可以正常运行,导入成功:
那么此时,我们已经集齐了三要素中的两个,现在我们可以基于router的类型进行约定外的配置约束。
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则是可能有用的一些变量,目前我们加入了crumb
和title
,前者用于在面包屑中加载,后者用于渲染进页面title
。
那么此时,我们已经集齐了三要素,可以尝试搭建路由。
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}`,
};
});
按照上述代码操作后,我们得到了一份基础路由列表,现在我们可以根据这份列表进行树状转换了。
下边这份代码和上边的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中。
我们先准备好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变量。
我们可以通过下边的代码使用:
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文件中使用我们拓展后的约定。
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}
/>
);
};
- 正常面包屑
- 返回面包屑
如此一来,面包屑完全可以自适应路由,我们在开发时可以更加得心应手。
7 总结
本文介绍了如何使用约定式路由架构和keep-alive最佳实践来搭建React-router v6.10的自动化路由系统。首先,我们介绍了如何使用一个平铺路由列表进行遍历,然后根据route.path
对其进行分割,从而得到子路由的path。接着,我们介绍了如何遍历整个store并将source的数据和约定的路由数据加入store中。我们还介绍了如何通过配置简化常用操作,如何使用第三方库react-activation来实现keep-alive,并提供了一个自动化路由面包屑的实现方式。
在React-router v6.10的自动化路由系统中,我们可以使用约定式路由架构和keep-alive最佳实践来搭建一个高效、自动化的路由系统。通过这种方式,我们可以更快地开发应用程序,并且提高其性能和可维护性。
转载自:https://juejin.cn/post/7222931700438679609