从零搭建一个React管理系统
# 前言
最近新接到一个需求,要新写一个海外的后台管理系统。于是我决定这次采用
React
相关生态的技术架构去搭建这个系统,正好也熟悉一下React
的开发。
# 技术选型
提到React
的管理系统,那就不得不提umi
了。他是一个基于React
+webpack
而做的脚手架,内置了非常非常多的常见功能。包括路由,状态管理,国际化等等,但是距离快速开发还是差了点什么。于是我又把目光转移到了AntdPro
,他是基于umi
再一次进行了业务封装,搞了个demo
后发现,虽然他真的封装了很多高级组件但是用起来确实不太好用。
总结一下:
- umi:
- 版本迭代很快,文档很多,可能写一行代码翻文档要翻半天,我个人不是太喜欢这种开发节奏,所以被我pass了。
- AntdPro:
- 基于umi脚手架的后台管理系统模板,进行了很多业务上的封装。这样使得他的自由度变得更低了,比较适用于自定义需求没那么强的管理系统。像外包类的,主打一个效率高。由于我之前并不是React选手,对umi了解也不多。做了一个demo后发现我要一遍在umi上查文档一遍在AntdPro上查文档,妈呀。查阅资料时间比开发时间还久,所以果断也被我pass了。
- Next.js:
- 最近非常火的一个React开发框架,最初是作为React的SSR解决方案问世。现在的他已经无所不能了,是的你没听过,他无所不能了。yyds,无论是SSR,SSG,SCR。他都能做,but我才疏学浅,感觉使用起来学习成本有点高怕耽误项目进度,所以也给我pass了。
好了,主流的框架都因为各自的弊端被我pass了(是的我就是不太愿意学QAQ...),项目还得做。那就只能自己从零搭一个吧。重复造轮子才是一个合格的程序猿的日常工作!
# 搭建项目
根据我多年vue
的开发经验来看,本次的项目我需要准备以下几样东西。
cli
:一个快速生成React
项目的脚手架router
:前端路由处理UI库
:那必然是用全西湖最好的ui库啦css
:React
的css
解决方案五花八门,我当然要尝尝鲜用当下火热的tailWind
国际化
:i18n
的React
相关库react-i18next
,i18next
状态管理
:我钟爱的zustand
,redux
什么的麻烦靠边
罗列出以上开发必须品之后,就可以开始着手搭建我的第一个React
项目啦
# 通过脚手架创建项目
首先通过pnpm
去创建一个vite
的React
,ts
工程
pnpm create vite my-app
安装组件库
pnpm install antd --save
# 安装路由
接下来安装一下路由,印象中react-router-dom
炒鸡炒鸡麻烦,一点都不如vue-router
来的好用。不过万幸的是,这次我赶上react-router
新版本发布,他已经支持配置式
路由了,使用起来和vue
的几乎无差别。
pnpm install react-router-dom
通过createHashRouter
创建一个路由配置,并导出,后续渲染路由的时候需要用到他
创建一个路由文件,并写入以下内容
import { createHashRouter, Outlet } from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { lazy } from "react";
const lazyComponent = (importFn) => {
const Component = lazy(importFn);
return <Component />;
};
/**
*
* 多层级路由给父路由的element设置为 <Outlet /> 作为子路由的渲染出口
* handle 中配置路由的详细信息
* type:配置为layout则会被作为布局使用,不会处理当前路由会转而处理子路由
* hidden:配置为true则不会出现在左侧导航中
* icon:导航图标
*/
const routerConfig: RouteObject[] = [
{
path: "/",
element: lazyComponent(() => import("@/components/AuthRoot")),
handle: {
type: "layout",
hidden: true,
},
children: [
{
path: "/",
element: lazyComponent(() => import("@/views/data-profile")),
handle: {
label: "page_data_profile",
icon: "BarChartOutlined",
},
},
{
path: "/order",
element: lazyComponent(() => import("@/views/order")),
handle: {
label: "page_order_label",
icon: "FileDoneOutlined",
},
},
{
path: "/balance-management",
element: lazyComponent(() => import("@/views/balance-management")),
handle: {
label: "page_balance_label",
icon: "PayCircleOutlined",
},
},
],
},
{
path: "/login",
element: lazyComponent(() => import("@/views/login")),
handle: {
hidden: true,
},
},
];
export default createHashRouter(routerConfig);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
因为项目已经写完了,所以这里贴的都是完整代码了。不是已开始创建项目时写的代码。一些坑已经被踩过了,下面我会一一提点出来。
关于配置项的每个字段我就不多说,可以看文档,这里需要注意。如果你采用lazy
去懒加载组件的话,必须要返回一个组件不能直接给element
赋值lazy
函数。比如下面这样
{
path: "/login",
element: lazy(() => import("@/views/login")),
handle: {
hidden: true,
},
},
2
3
4
5
6
7
这样写会报错,所以就有了上文中那个工具函数。用来统一返回组件
const lazyComponent = (importFn) => {
const Component = lazy(importFn);
return <Component />;
};
2
3
4
每个路由对象都有一个handle
对象,你可以把他理解成vue-router
的meta
对象。也就是可以在这里面存储一些对当前路由的配置信息,包括权限
,icon
,以及其他一些自定义的内容。
因为这里我需要根据这份路由表去生成菜单,所以我在里面配置了一些我自己需要的东西。这里的内容按需去配就行。
注意一下,这里我把布局组件放在了第一级
,其他需要布局组件的路由都会作为他的子路由去渲染,并且第一个页面也就是首页的路径和布局组件的相同,这样就会默认渲染
第一个子路由。而那些不需要布局的页面就直接写在布局组件平级
就行了
下面我们去渲染路由组件,进入我们的App.tsx
import { RouterProvider } from "react-router-dom";
import routers from "@/routers";
import { Suspense} from "react";
import Loading from "@/components/Loading";
function App() {
return (
<>
<Suspense fallback={<Loading />}>
<RouterProvider router={routers} />
</Suspense>
</>
);
}
export default App;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RouterProvider
组件会作为一级路由的渲染出口,把createHashRouter
创建的路由对象传给他就行。注意这里因为是lazy
加载的组件,所以会出现白屏情况,我们需要一个缓冲组件
,也就是Suspense
。
他会在组件还没被渲染出来的时候先去渲染fallback
里的内容,我们配置一个loading
上去就可以了。
写到这里,已经可以根据不同的路径切换路由了。
现在一级路由能渲染了,那二级路由该怎么渲染呢。别急,react-router
还提供了一个outlet
组件用来作为子路由的渲染出口。
我们的布局组件AuthRoot
里就是用来渲染二级路由的,并且一些路由守卫的功能都将在这里完成
// AuthRoot
import Layout from "@/components/Layout";
import { useCookieState } from "ahooks";
import { Navigate } from "react-router-dom";
const AuthRoot: React.FC = () => {
const [token] = useCookieState("belife-app-token");
return (
<>
{token ? <Layout /> : <Navigate to="/login" replace />}
{/*<Layout />*/}
</>
);
};
export default AuthRoot;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Layout
组件里就是布局组件,并且里面会通过outlet
去渲染二级路由。详细的代码就不贴了,东西有点多。
到此,路由已经处理完成,一个基本的可用的系统也就搭建的差不多了。下面来说一下国际化
# 国际化
首先我们安装一下两个库
pnpm install i18next
pnpm install react-i18next
2
创建一个locales
文件夹用来放置国际化相关的文件
// locales/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enUsTrans from "./language/en-us.json";
import zhCnTrans from "./language/zh-cn.json";
i18n.use(initReactI18next).init(
{
resources: {
en: {
translation: enUsTrans, // 引入json文件
},
zh: {
translation: zhCnTrans,
},
},
lng: "zh", // 默认语言
fallbackLng: "zh",
interpolation: {
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
},
},
() => {},
);
export default i18n;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
准备两个翻译文件
// locales/language/zh-us.json
{
"test words": "测试文字",
}
// locales/language/en-us.json
{
"test words": "test words",
}
2
3
4
5
6
7
8
接着在项目的入口文件里引入这个文件
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "@/locales/i18n";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
2
3
4
5
6
7
8
9
10
11
12
然后我们就可以在需要使用的地方通过i18n
提供的api
去渲染内容了
import { useTranslation } from "react-i18next";
const { t,i18n:{language} } = useTranslation();
// 渲染文字
<div>{t('test words')}</div>
// i18n里提供了一些当前语言的信息
2
3
4
5
6
7
8
这里需要同步一下antd
组件的国际化,所以App.ts
里要这样写
import { RouterProvider } from "react-router-dom";
import routers from "@/routers";
import { Suspense, useEffect, useState } from "react";
import { ConfigProvider } from "antd";
import { useTranslation } from "react-i18next";
import zhCN from "antd/es/locale/zh_CN";
import enUS from "antd/es/locale/en_US";
import Loading from "@/components/Loading";
function App() {
const { i18n } = useTranslation();
const [locale, setLocale] = useState(i18n.language === "zh" ? zhCN : enUS);
useEffect(() => {
setLocale(i18n.language === "zh" ? zhCN : enUS);
}, [i18n.language]);
return (
<>
<ConfigProvider locale={locale}>
<Suspense fallback={<Loading />}>
<RouterProvider router={routers} />
</Suspense>
</ConfigProvider>
</>
);
}
export default App;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
获取到当前i18n
的语言然后同步设置antd
的国际化语言。接下来我们再去实现一些语言的切换,国际化就完成了。
i18n.changeLanguage
用于切换语言,传参就是你在i18n
里配置的集中语言的key
。这里我们配合缓存去做切换
function HeadContent(props: {
collapsed: boolean;
setCollapsed: (val: boolean) => void;
handleReloadMenu: () => void;
}) {
const { i18n, t } = useTranslation();
const { collapsed, setCollapsed, handleReloadMenu } = props;
const items = [{ key: "loginOut", label: <Button>{t("log_out")}</Button> }];
const navigate = useNavigate();
const [, setToken] = useCookieState("belife-app-token");
const handleClickItem = () => {
setToken("");
navigate("/login");
};
// 获取本地存储的语言类型
const [language, setLanguage] = useLocalStorageState("language");
// 切换语言,更新本地存储的值
const handleClickLanguage = () => {
setLanguage(language === "中文" || !language ? "English" : "中文");
};
// 监听本地存储的语言值,有变动就同步切换i18n的语言类型
useEffect(() => {
i18n.changeLanguage(language === "中文" || !language ? "zh" : "en");
handleReloadMenu();
}, [language]);
return (
<div className={Styles.app_head}>
<Button
type="text"
icon={
collapsed ? (
<IconFont icon="MenuUnfoldOutlined" />
) : (
<IconFont icon="MenuFoldOutlined" />
)
}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: "16px",
width: 64,
height: 64,
}}
/>
<div className={Styles.app_head_right}>
<Button style={{ marginRight: "20px" }} onClick={handleClickLanguage}>
{language || "中文"}
</Button>
<Dropdown menu={{ items, onClick: handleClickItem }}>
<Avatar icon={<IconFont icon="UserOutlined" />} />
</Dropdown>
</div>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
到这里,国际化我们也完成了。
# tailwindcss
处理完上面那些,我们再来安装一下tailwind
以及postcss
pnpm install tailwindcss
pnpm install postcss
2
在根目录下新增两个配置文件
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,ts,tsx,jsx,js}"],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
preflight: false, // 禁用tailwind默认样式
},
};
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
然后再index.css
里面引入tailwind
的样式
@tailwind base;
@tailwind components;
@tailwind utilities;
2
3
由于tailwindcss
自带了一些默认样式,和组件库的样式会有冲突,所以这里选择禁用默认样式,也就是上文tailwind.config.js
中配置的那个属性。当然你也可以选择不引入@tailwind base
,不过好像会出现很多意想不到的问题。
状态管理目前还没用上,暂时没加。想了解的小伙伴可以去搜一搜zustand
的文档,使用非常的简单也很轻便。
到这里,我们的管理系统就基本上搭建完成了。下面来贴几个比较实用的工具。
# Loading组件
众所周知,Antd
的loading
组件并没有提供Api式
的调用(也可能是我没找到嘿嘿),每次使用都需要引入组件。这样的loading
显然是不太方便的,所以我们要对他进行一个小小的改造
import { Spin } from "antd";
import { memo } from "react";
import type { FC, MemoExoticComponent } from "react";
import ReactDom from "react-dom/client";
import type { Root } from "react-dom/client";
interface LoadingType extends MemoExoticComponent<FC> {
show: (props?: any) => void;
hidden: () => void;
oWrapper?: Element;
loadingCount?: number;
wrapperRoot?: Root;
}
const Loading: LoadingType = memo(() => {
return (
<div
style={{ background: "rgba(255,255,255,.7)" }}
className={`w-[100vw] h-[100vh] flex justify-center items-center`}
>
<Spin />
</div>
);
}) as LoadingType;
/**
* 遮罩的样式
*/
const MaskStyles = {
position: "fixed",
top: "0",
left: "0",
right: "0",
bottom: "0",
zIndex: "9999",
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "rgba(255,255,255,.7)",
};
Loading.show = function (props) {
if (this.loadingCount) {
this.loadingCount++;
return;
}
this.oWrapper = document.createElement("div");
this.oWrapper.setAttribute("id", "customer-loading");
document.body.appendChild(this.oWrapper);
this.wrapperRoot = ReactDom.createRoot(this.oWrapper);
this.wrapperRoot.render(
<div style={{ ...MaskStyles }}>
<Spin {...props}></Spin>
</div>,
);
this.loadingCount = 1;
};
Loading.hidden = function () {
this.loadingCount--;
if (this.loadingCount === 0) {
this.wrapperRoot.unmount();
document.body.removeChild(this.oWrapper);
}
};
export default Loading;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
我在loading
组件的原型上维护了一些列函数和几个状态,并通过动态挂载的方式去渲染loading
。这样一来loading
组件就不仅可以作为标签引入,还可以通过api
的形式去调用。无论是把他用作缓冲,还是用作接口的loading
都可以。在他自身还维护了一个记数状态,
用来防止重复调用的问题。外部使用也不需要去考虑这种情况了,统一在组件内部做好了处理。
# getList Hooks
对于管理系统来说,最常用的就是表格数据的查询。通常来说里面糅合了分页,搜索等等,而这些代码的相似度一般都偏高。所以这里我把他抽出来封装成了一个hooks
,由hooks
内部来提供数据,让外部组件来调用这个hooks
里提供的api
以及读取它里面的数据。
import { useEffect, useState } from "react";
interface UseGetListProps {
params?: Record<string, any>;
request: (params?: any) => Promise<any>;
page?: number;
pageSize?: number;
}
export const useGetList = (props: UseGetListProps) => {
const [page, setPage] = useState(props.page || 1);
const [pageSize, setPageSize] = useState(props.pageSize || 10);
const [list, setList] = useState([]);
const [total, setTotal] = useState(0);
const [data, setData] = useState({});
async function getList() {
const data = await props.request({
...(props.params || {}),
pageNum: page,
pageSize: pageSize,
});
setData(data);
setList(data?.data || []);
if (page === 1) {
setTotal(data.totalCount);
}
}
function handleSetPageSize(val: number) {
setPageSize(val);
setPage(1);
}
function handleSetPageCount(val: number) {
setPage(val);
}
useEffect(() => {
getList();
}, [page, pageSize, props.params]);
return {
page,
pageSize,
list,
total,
handleSetPageCount,
getList,
handleSetPageSize,
data,
};
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 结语
以上就是本文的全部内容了,从技术选型开始,然后逐步剖析从零搭建一个项目工程需要考虑哪些东西。有脚手架帮我们做了很多工程化的基建,剩下的其实只需要根据业务需要去找到对应的类库就行了。 总体来说还是挺有收获的,这也是我的第一个
React
项目。从零开始比直接使用成熟的第三方库能更直观的去学习React
相关生态,还是非常建议自己去搭一套的,毕竟不能做一个离了第三方库就啥也干不了的Api
工程师。