React是近年来前端开发领域非常热门的技术框架,其背景是Facebook团队的技术支持,在全球的前端开发市场上占有率很高。结合React丰富的社区资源,可以让项目开发如虎添翼。虽然React的学习门槛要比Vue略高,但学习React对于大幅提高前端技能是非常有帮助的。本文基于Create-React-App(后简称CRA),详细梳理了从创建React工程、精简项目、配置调试环境,到集成路由、Ant Design、Redux等各种相关工具等内容,重在快速搭建标准React项目工程,对于React初学者来说,能够节省很多探索的时间。
在2022年2月,我发布了《2022新春版:超全面详细一条龙教程!从零搭建React项目全家桶》。不到一年的时间里,技术迭代了很多:React、react-router-dom、Redux、Ant Design等关键的开发利器都有重大的更新,用法也发生了较大变化,因此再更新一版。每期教程都会使用当前最新的技术版本,让你快速跟上前沿步伐。
先睹为快
先看下目录了解本教程都有哪些内容。
1 初始化项目
1.1 使用create-react-app新建项目
1.2 精简项目
2 Webpack配置
2.1 配置国内镜像源
2.2 暴露Webpack
2.3 支持Sass/Scss
2.4 支持Less
2.5 支持Stylus
2.6 设置路径别名
2.7 禁止build项目生成map文件
3 项目架构搭建
3.1 项目目录结构设计
3.2 关于样式命名规范
3.3 设置全局公用样式
4 引入Ant Design 5.x
4.1 安装Ant Design
4.2 设置Antd为中文语言
5 页面开发
5.1 构建Login页面
5.2 构建Home页面
5.3 构建Account页面
5.4 通过一级路由实现页面跳转
5.5 在React组件中实现页面路由跳转
5.6 在非React组件中实现页面路由跳转
6 组件开发
6.1 创建自定义SVG图标Icon组件
6.2 创建Header组件
6.3 引入Header组件
6.4 组件传参
7 二级路由配置
7.1 创建二级路由的框架页面
7.2 配置二级路由
7.3 获取当前路由地址
8 React Developer Tools浏览器插件
9 Redux及Redux Toolkit
9.1 安装Redux及Redux Toolkit
9.2 创建全局配置文件
9.3 创建用于主题换肤的store分库
9.4 创建store总库
9.5 引入store到项目
9.6 store的使用:实现亮色/暗色主题切换
9.7 非Ant Design组件的主题换肤
9.8 store的使用:实现主题色切换
9.8.1 创建主题色选择对话框组件
9.8.2 引入主题色选择对话框组件
9.8.3 将主题色配置应用于项目
9.9 安装Redux调试浏览器插件
10 基于axios封装公用API库
10.1 安装axios
10.2 封装公用API库
10.3 Mock.js安装与使用
10.4 发起API请求:实现登录功能
11 一些细节问题
11.1 解决Modal.method跟随主题换肤的问题
11.2 路由守卫
11.3 设置开发环境的反向代理请求
12 build项目
13 项目Git源码
结束语
本次分享Demo的主要依赖包版本:
Node.js 18.12.1
create-react-app 5.0.1
react 18.2.0
react-router-dom 6.4.5
antd 5.0.6
node-sass 8.0.0
sass-loader 12.3.0
less 4.1.3
less-loader 11.1.0
stylus 0.59.0
stylus-loader 7.1.0
axios 1.2.1
history 4.10.1
mockjs 1.1.0
react-redux 8.0.5
@reduxjs/toolkit 1.9.1
http-proxy-middleware 2.0.6
※注:
代码区域每行开头的:
“+” 表示新增
“-” 表示删除
“M” 表示修改
即便你是新手,跟着操作一遍,也可以快速上手React项目啦!下面请跟着新版教程一步步操作。
1 初始化项目
1.1 使用create-react-app新建项目
找个合适的目录,执行:
npx create-react-app react-app
命令最后的react-app是项目的名称,可以自行更改。
编写教程时,create-react-app已经发布了5.0.1,如果一直报错:
you are running create-react-app 4.0.3 which is behind the latest release (5.0.1)
说明你还在使用旧版本的create-react-app,需要先清除npx缓存,执行:
npx clear-npx-cache
然后再执行之前的命令创建项目:
npx create-react-app react-app
稍等片刻即可完成安装。安装完成后,可以使用npm或者yarn启动项目。
进入项目目录,并启动项目:
cd react-app
yarn start (或者使用npm start)
如果没有安装yarn,可执行以下命令全局安装:
npm install --global yarn
yarn中文网站:
https://yarn.bootcss.com/
启动后,可以通过以下地址访问项目:
http://localhost:3000/
1.2 精简项目
接下来,删除用不到的文件,最简化项目。
├─ /node_modules
├─ /public
| ├─ favicon.ico
| ├─ index.html
- | ├─ logo192.png
- | ├─ logo512.png
- | ├─ mainfest.json
- | └─ robots.txt
├─ /src
- | ├─ App.css
| ├─ App.js
- | ├─ App.test.js
- | ├─ index.css
| ├─ index.js
- | ├─ logo.svg
- | ├─ reportWebVitals.js
- | └─ setupTests.js
├─ .gitignore
├─ package-lock.json
├─ package.json
└─ README.md
现在目录结构如下,清爽许多:
├─ /node_modules
├─ /public
| ├─ favicon.ico
| └─ index.html
├─ /src
| ├─ App.js
| └─ index.js
├─ .gitignore
├─ package-lock.json
├─ package.json
└─ README.md
以上文件删除后,页面会报错。这是因为相应的文件引用已不存在。需要继续修改代码,先让项目正常运行起来。
逐个修改以下文件,最终精简代码依次如下:
src/App.js:
function App() {
return <div className="App">React-App</div>
}
export default App
src/index.js:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)
public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
运行效果如下:
2 Webpack配置
2.1 配置国内镜像源
npm和yarn默认是从国外源站拉取依赖包的,为提高下载速度和稳定性,建议配置为国内镜像源。
yarn registry国内镜像:
yarn config set registry https://registry.npmmirror.com
npm registry国内镜像:
npm config set registry https://registry.npmmirror.com
yarn node-sass国内镜像:
yarn config set SASS_BINARY_SITE https://npmmirror.com/mirrors/node-sass/
npm node-sass国内镜像:
npm config set SASS_BINARY_SITE https://npmmirror.com/mirrors/node-sass/
据淘宝官方声明,原先的 http://npm.taobao.org 和 http://registry.npm.taobao.org 域名于2022年5月31日零时起停止服务。新域名如下:
【Web 站点】https://npmmirror.com
【Registry Endpoint】https://registry.npmmirror.com
官方公告原文:《【望周知】淘宝 NPM 镜像站喊你切换新域名啦》(https://zhuanlan.zhihu.com/p/430580607)
如果不清楚本地当前yarn或者npm的配置,可以执行以下命令查看:
yarn查看方法:
yarn config list
npm查看方法:
npm config list
2.2 暴露Webpack
create-react-app默认情况下未暴露配置文件。如果要更灵活地配置项目,需要将配置文件暴露出来。
执行以下命令,暴露配置文件:
yarn eject
eject之前必须确保当前工程所有文件已提交git,否则会报以下错误:
Remove untracked files, stash or commit any changes, and try again.
需要先在项目根目录下执行提交git:
git add .
git commit -m "初始化项目(备注)"
然后再执行:
yarn eject
即可完成Webpack的暴露,这时项目里会多出来两个目录和若干个文件。具体变化如下:
+ ├─ /config
├─ /node_modules
├─ /public
+ ├─ /scripts
├─ /src
├─ .gitignore
M ├─ package-lock.json
M ├─ package.json
└─ README.md
2.3 支持Sass/Scss
eject后,虽然package.json以及webpack.config.js里有了sass相关代码,但是要正确使用Sass/Scss,还要再安装node-sass。
执行以下命令:
yarn add node-sass --dev
安装完成后,项目已支持Sass/Scss。
2.4 支持Less
支持Less稍微多一点步骤,首先安装less和less-loader:
yarn add less less-loader --dev
然后修改config/webpack.config.js:
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
+ const lessRegex = /\.less$/;
+ const lessModuleRegex = /\.module\.less$/;
...(略)
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 3,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
modules: {
mode: 'icss',
},
},
'sass-loader'
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 3,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
modules: {
mode: 'local',
getLocalIdent: getCSSModuleLocalIdent,
},
},
'sass-loader'
),
+ // 支持Less
+ {
+ test: lessRegex,
+ exclude: lessModuleRegex,
+ use: getStyleLoaders(
+ {
+ importLoaders: 3,
+ sourceMap: isEnvProduction
+ ? shouldUseSourceMap
+ : isEnvDevelopment,
+ modules: {
+ mode: 'icss',
+ },
+ },
+ 'less-loader'
+ ),
+ sideEffects: true,
+ },
+ {
+ test: lessModuleRegex,
+ use: getStyleLoaders(
+ {
+ importLoaders: 3,
+ sourceMap: isEnvProduction
+ ? shouldUseSourceMap
+ : isEnvDevelopment,
+ modules: {
+ mode: 'local',
+ getLocalIdent: getCSSModuleLocalIdent,
+ },
+ },
+ 'less-loader'
+ ),
+ },
其实就把上面sass配置代码复制一遍,改成less。按照以上操作后,项目已支持Less。
2.5 支持Stylus
支持Stylus跟Less完全一样,首先安装stylus和stylus-loader:
执行以下命令:
yarn add stylus stylus-loader --dev
安装完成后,按照上一小节介绍的支持Less的方法,修改config/webpack.config.js:
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
const lessRegex = /\.less$/;
const lessModuleRegex = /\.module\.less$/;
+ const stylusRegex = /\.styl$/;
+ const stylusModuleRegex = /\.module\.styl$/;
...(略)
+ // 支持stylus
+ {
+ test: stylusRegex,
+ exclude: stylusModuleRegex,
+ use: getStyleLoaders(
+ {
+ importLoaders: 3,
+ sourceMap: isEnvProduction
+ ? shouldUseSourceMap
+ : isEnvDevelopment,
+ modules: {
+ mode: 'icss',
+ },
+ },
+ 'stylus-loader'
+ ),
+ sideEffects: true,
+ },
+ {
+ test:stylusModuleRegex,
+ use: getStyleLoaders(
+ {
+ importLoaders: 3,
+ sourceMap: isEnvProduction
+ ? shouldUseSourceMap
+ : isEnvDevelopment,
+ modules: {
+ mode: 'local',
+ getLocalIdent: getCSSModuleLocalIdent,
+ },
+ },
+ 'stylus-loader'
+ ),
+ },
按照以上操作后,项目已支持Stylus。
2.6 设置路径别名
为了避免使用相对路径的麻烦,可以设置路径别名。
修改config/webpack.config.js:
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
'react-native': 'react-native-web',
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
'react-dom$': 'react-dom/profiling',
'scheduler/tracing': 'scheduler/tracing-profiling',
}),
...(modules.webpackAliases || {}),
+ '@': path.join(__dirname, '..', 'src'),
},
这样在js代码开头的import路径中,直接使用@表示“src根目录”,不用去自己去数有多少个"…/"了。
例如,src/app.js:
// 表示该文件当前路径下的app.styl(相对路径)
import './app.styl'
// 表示src/app.styl,等价于上面的文件地址(绝对路径)
import '@/app.styl'
2.7 禁止build项目生成map文件
map文件,即Javascript的source map文件,是为了解决被混淆压缩的js在调试的时候,能够快速定位到压缩前的源代码的辅助性文件。这个文件发布出去,会暴露源代码。因此,建议直接禁止build时生成map文件。
修改config/webpack.config.js,把shouldUseSourceMap的值改成false:
// Source maps are resource heavy and can cause out of memory issue for large source files.
- // const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
+ const shouldUseSourceMap =false;
3 项目架构搭建
3.1 项目目录结构设计
项目目录结构可根据项目实际灵活制定。这里分享下我常用的结构,主要分为公用模块目录、组件模块目录、页面模块目录、路由配置目录、Redux目录等几个部分,让项目结构更加清晰合理。
├─ /config <-- webpack配置目录
├─ /node_modules
├─ /public
| ├─ favicon.ico <-- 网页图标
| └─ index.html <-- HTML页模板
├─ /scripts <-- node编译脚本
├─ /src
| ├─ /api <-- api目录
| | └─ index.js <-- api库
| ├─ /common <-- 全局公用目录
| | ├─ /fonts <-- 字体文件目录
| | ├─ /images <-- 图片文件目录
| | ├─ /js <-- 公用js文件目录
| | └─ /styles <-- 公用样式文件目录
| | | ├─ frame.styl <-- 全部公用样式(import本目录其他全部styl)
| | | ├─ reset.styl <-- 清零样式
| | | └─ global.styl <-- 全局公用样式
| ├─ /components <-- 公共模块组件目录
| | ├─ /header <-- 头部导航模块
| | | ├─ index.js <-- header主文件
| | | └─ header.styl <-- header样式文件
| | └─ ... <-- 其他模块
| ├─ /pages <-- 页面组件目录
| | ├─ /home <-- home页目录
| | | ├─ index.js <-- home主文件
| | | └─ home.styl <-- home样式文件
| | ├─ /login <-- login页目录
| | | ├─ index.js <-- login主文件
| | | └─ login.styl <-- login样式文件
| | └─ ... <-- 其他页面
| ├─ /route <-- 路由配置目录
| ├─ /store <-- Redux配置目录
| ├─ globalConfig.js <-- 全局配置文件
| ├─ index.js <-- 项目入口文件
| ├─.gitignore
| ├─ package.json
| ├─ README.md
| └─ yarn.lock
注意以上项目结构,已经没有src/App.js了,现在先不用删除,随着后续章节的讲解再删除。
接下来,就按照上面的目录结构设计开始构建项目。
3.2 关于样式命名规范
以我多年来的开发经验来讲,合理的样式命名规范对项目开发有很大的帮助,主要体现在以下方面:
(1)避免因样式名重复导致的污染。
(2)从命名上可直观区分“组件样式”、“页面样式”(用于给在此页面的组件样式做定制调整)、“全局样式”。
(3)快速定位模块,便于查找问题。
分享一下本教程的样式命名规范:
G-xx: 表示全局样式,用来定义公用样式。
P-xx: 表示页面样式,用来设置页面的背景色、尺寸、定制化调整在此页面的组件样式。
M-xx: 表示组件样式,专注组件本身样式。
后续教程中,可以具体看到以上规范是如何应用的。
3.3 设置全局公用样式
我个人比较喜欢Stylus简洁的语法,因此本教程以Stylus作为css预处理语言。各位可以根据自己的习惯,自由选择Sass/Scss、Less、Stylus。
新建清零样式文件,src/common/styles/reset.styl。
由于reset.css代码较多,这里不再放出。非常推荐参考这个reset css,代码比较全面,更新也比较及时(截至本文写作时,是2022年8月7日更新的)。
具体代码详见:https://github.com/elad2412/the-new-css-reset/blob/main/css/reset.css
新建全局样式文件,src/common/styles/global.styl:
html, body, #root
height: 100%
/*清浮动*/
.clearfix:after
content: "."
display: block
height: 0
clear: both
visibility: hidden
.clearfix
display:block
全局样式将应用于项目的所有页面,可根据需要自行补充或调整。
新建全局样式总入口文件,src/common/styles/frame.styl:
@import './reset.styl';
@import './global.styl';
在frame.styl里引入其他公用样式,就方便一次性全部应用到项目中了。
然后在src/index.js里引入frame.styl:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+ // 全局样式
+ import '@/common/styles/frame.styl'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)
这样在所有页面里就可以直接使用全局样式了。
现在运行项目,可以发现reset、global中的样式已经生效。
4 引入Ant Design 5.x
Ant Design是一款非常优秀的UI库,在React项目开发中使用非常广泛。Ant Design发布5.x后,使用起来更加快捷,而且在主题换肤方面更加便捷。本次分享也特别说明下如何使用Ant Design(以下简称Antd)。
4.1 安装Ant Design
执行:
yarn add antd
然后修改src/App.js 来验证下Antd:
import { Button } from 'antd'
function App() {
return (
<div className="App">
<h1>React-App</h1>
<Button type="primary">Button</Button>
</div>
)
}
export default App
执行yarn start:
可以看到Antd的Button组件正常显示出来了。
※注:
Antd 5.x已经没有全局污染的reset样式了。因此不用再担心使用了Antd会影响页面样式。
4.2 设置Antd为中文语言
Antd默认语言是英文,需进行以下设置调整为中文。
修改src/index.js:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+ import { ConfigProvider } from 'antd'
+ // 引入Ant Design中文语言包
+ import zhCN from 'antd/locale/zh_CN'
// 全局样式
import '@/common/styles/frame.styl'
const root = ReactDOM.createRoot(document.getElementById('root'))
M root.render(
M <ConfigProvider locale={zhCN}>
M <App />
M </ConfigProvider>
M )
现在还没开始构建页面,因此关于Antd 5.x酷炫的主题换肤在后续章节再讲解,先别着急。
5 页面开发
本次教程包含Login、Home、Account三个业务页面和一个二级路由页面Entry。其中:
-
Login页面不换肤,不需要验证登录状态。
-
Home页面和Account页面,跟随换肤,并通过Entry进行登录状态验证及路由切换。
工程文件变动如下:
├─ /config
├─ /node_modules
├─ /public
├─ /scripts
├─ /src
| ├─ /api
| ├─ /common
| ├─ /components
+ | ├─ /pages
+ | | ├─ /account
+ | | | ├─ index.js
+ | | | └─ account.styl
+ | | ├─ /entry
+ | | | ├─ index.js
+ | | | └─ entry.styl
+ | | ├─ /home
+ | | | ├─ index.js
+ | | | └─ home.styl
+ | | ├─ /login
+ | | | ├─ index.js
+ | | | ├─ login.styl
+ | | | └─ logo.png
| ├─ App.js
| ├─ index.js
| ├─.gitignore
| ├─ package.json
| ├─ README.md
| └─ yarn.lock
5.1 构建Login页面
页面构建代码不再详述,都是很基础的内容了。
新建src/pages/login/index.js:
import { Button, Input } from 'antd'
import imgLogo from './logo.png'
import './login.styl'
function Login() {
return (
<div className="P-login">
<img src={imgLogo} alt="" className="logo" />
<div className="ipt-con">
<Input placeholder="账号" />
</div>
<div className="ipt-con">
<Input.Password placeholder="密码" />
</div>
<div className="ipt-con">
<Button type="primary" block={true}>
登录
</Button>
</div>
</div>
)
}
export default Login
新建src/pages/login/login.styl:
.P-login
position: absolute
top: 0
bottom: 0
width: 100%
background: #7adbcb
.logo
display: block
margin: 50px auto 20px
.ipt-con
margin: 0 auto 20px
width: 400px
text-align: center
别忘了还有一张图片:src/pages/login/logo.png。
暂时修改下入口文件代码,把原App页面换成Login页面,看看效果:
修改src/index.js:
- import App from './App'
+ import App from '@/pages/login'
5.2 构建Home页面
直接上代码。
新建src/pages/home/index.js:
import { Button } from 'antd'
import './home.styl'
function Home() {
return (
<div className="P-home">
<h1>Home Page</h1>
<div className="ipt-con">
<Button type="primary">返回登录</Button>
</div>
</div>
)
}
export default Home
新建src/pages/home/home.styl:
.P-home
position: absolute
top: 0
bottom: 0
width: 100%
background: linear-gradient(#f48c8d,#f4c58d)
h1
margin-top: 50px
text-align: center
color: #fff
font-size: 40px
.ipt-con
margin: 20px auto 0
text-align: center
暂时修改下入口文件代码,把初始页面换成Home页面。
修改src/index.js:
- import App from '@/pages/login'
+ import App from '@/pages/home'
看看效果:
5.3 构建Account页面
基本与Home页面一样,直接上代码。
新建src/pages/account/index.js:
import { Button } from 'antd'
import './account.styl'
function Account() {
return (
<div className="P-account">
<h1>Account Page</h1>
<div className="ipt-con">
<Button type="primary">返回登录</Button>
</div>
</div>
)
}
export default Account
新建src/pages/account/account.styl:
.P-account
position: absolute
top: 0
bottom: 0
width: 100%
background: linear-gradient(#f48c8d,#f4c58d)
h1
margin-top: 50px
text-align: center
color: #fff
font-size: 40px
.ipt-con
margin: 20px auto 0
text-align: center
同样,暂时修改下入口文件代码,把初始页面换成Account页面。
src/index.js:
- import App from '@/pages/home'
+ import App from '@/pages/account'
看看效果:
5.4 通过一级路由实现页面跳转
为了实现页面的跳转,需要安装react-router-dom。
执行:
yarn add react-router-dom
接下来进行路由配置,新建src/router/index.js:
import { createHashRouter, Navigate } from 'react-router-dom'
import Login from '@/pages/login'
import Home from '@/pages/home'
import Account from '@/pages/account'
// 全局路由
export const globalRouters = createHashRouter([
// 对精确匹配"/login",跳转Login页面
{
path: '/login',
element: <Login />,
},
// 精确匹配"/home",跳转Home页面
{
path: '/home',
element: <Home />,
},
// 精确匹配"/account",跳转Account页面
{
path: '/account',
element: <Account />,
},
// 如果URL没有"#路由",跳转Home页面
{
path: '/',
element: <Home />,
},
// 未匹配,,跳转Login页面
{
path: '*',
element: <Navigate to="/login" />,
},
])
为循序渐进讲解,暂时先将Login、Home、Account都当做一级页面,通过一级路由实现跳转。代码注释已写明跳转逻辑,不再赘述。
接下来应用以上路由配置,修改src/index.js:
import React from 'react'
import ReactDOM from 'react-dom/client'
+ import { RouterProvider } from 'react-router-dom'
+ import { globalRouters } from '@/router'
- import App from '@/pages/account'
import { ConfigProvider } from 'antd'
// 引入Ant Design中文语言包
import zhCN from 'antd/locale/zh_CN'
// 全局样式
import '@/common/styles/frame.styl'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<ConfigProvider locale={zhCN}>
- <App />
+ <RouterProvider router={globalRouters} />
</ConfigProvider>
)
这里使用了<RouterProvider>
实现路由跳转。同时,为了减少项目文件的依赖层级深度,也删除了<App>
,从此与App.js文件告别了。
记得删掉src/App.js
。
执行yarn start启动项目,输入对应的路由地址,可以正常显示对应的页面了。
Login页面:
http://localhost:3000/#/loginHome页面:
http://localhost:3000/#/homeAccount页面:
http://localhost:3000/#/account
5.5 在React组件中实现页面路由跳转
下面要实现的功能是,点击Login页面的“登录”按钮,跳转至Home页面。
修改src/pages/login/index.js:
+ import { useNavigate } from 'react-router-dom'
import { Button, Input } from 'antd'
import imgLogo from './logo.png'
import './login.styl'
function Login() {
+ // 创建路由钩子
+ const navigate = useNavigate()
return (
...(略)
<div className="ipt-con">
M <Button type="primary" block={true} onClick={()=>{navigate('/home')}}>登录</Button>
</div>
...(略)
同样的方法,再来实现点击Home页面的“返回登录”按钮,跳转至Login页面。
修改src/pages/home/index.js:
+ import { useNavigate } from 'react-router-dom'
import { Button } from 'antd'
import './home.styl'
function Home() {
+ // 创建路由钩子
+ const navigate = useNavigate()
return (
<div className="P-home">
<h1>Home Page</h1>
<div className="ipt-con">
M <Button type="primary" onClick={()=>{navigate('/login')}}>返回登录</Button>
</div>
</div>
)
}
export default Home
Account页面同理,不再赘述。现在,点击按钮进行页面路由跳转已经实现了。
至于Home与Account页面之间的互相跳转,大家可以使用navigate()举一反三自行实现。
5.6 在非React组件中实现页面路由跳转
在实际项目中,经常需要在非React组件中进行页面跳转。比如,当进行API请求的时候,如果发现登录认证已失效,就直接跳转至Login页面。
针对这种情况的统一处理,当然是封装成公用模块最合适。但往往这些纯功能性的模块都不是React组件,而是纯原生js。所以就没办法使用useNavigate()了。
下面介绍一下如何在非React组件中进行页面路由跳转。
需要安装额外的history依赖包。截至本文编写时,history最新版本为5.3.0,但history.push()只改变了页面地址栏的地址,却没有进行实际的跳转。在GitHub上也有很多人反馈,应该是最新版本的bug。目前的解决办法是安装4.10.1版本。
执行:
yarn add history@4.10.1
安装完成后,新建目录及文件,src/api/index.js:
import { createHashHistory } from 'history'
let history = createHashHistory()
export const goto = (path) => {
history.push(path)
}
在src/pages/home/index.js里调用goto方法:
import { useNavigate } from 'react-router-dom'
import { Button } from 'antd'
+ import { goto } from '@/api'
import './home.styl'
function Home() {
// 创建路由钩子
const navigate = useNavigate()
return (
<div className="P-home">
<h1>Home Page</h1>
+ <div className="ipt-con">
+ <Button onClick={()=>{goto('/login')}}>组件外跳转</Button>
+ </div>
<div className="ipt-con">
<Button type="primary" onClick={()=>{navigate('/login')}}>返回登录</Button>
</div>
</div>
)
}
export default Home
在home页点击“组件外跳转”按钮,可以正常跳转至login页面了,而实际执行跳转的代码是在src/api/index.js(非React组件)中,这样就非常适合封装统一的处理逻辑。
后续章节会讲述如何封装API接口,并通过组件外路由的方式实现API调用失败时的统一跳转。
6 组件开发
为了配合后续章节介绍二级路由和主题换肤,构建一个公用的头部组件。
6.1 创建自定义SVG图标Icon组件
Antd自带了很多Icon,非常方便直接使用。但在项目中遇到Antd没有的图标怎么办?当然,前提要求是自己构建的图标也能支持随时改变颜色和大小等样式。
例如针对切换亮色/暗色主题功能,Antd没有提供“太阳”“月亮”“主题色”的Icon。
第一个方法是在iconfont网站(https://www.iconfont.cn/)上制作自己的iconfont字体,然后以字体文件的方式应用到项目中。这种方式相信从事前端开发的同学都很熟悉了,不再赘述。这种方式相对来说比较麻烦,每次图标有变动时,都要重新生成一遍,而且遇到iconfont网站打不开等突发情况时,只能干着急。不是很推荐。
这里推荐第二个方法,就是基于Antd的Icon组件制作本地的自定义图标,而且用起来跟Antd自带的Icon是一样的,也不用额外考虑换肤的问题。虽然Antd官网介绍了制作方法,但讲解得不够具体。
Ant Design官方说明:
https://ant-design.antgroup.com/components/icon-cn#自定义-icon
下面具体分享一下这种高效的方案。
第一步:创建自定义图标库
新建src/components/extraIcons/index.js:
import Icon from '@ant-design/icons'
const SunSvg = () => (
// 这里粘贴“太阳”图标的SVG代码
)
const MoonSvg = () => (
// 这里粘贴“月亮”图标的SVG代码
)
const ThemeSvg = () => (
// 这里粘贴“主题色”图标的SVG代码
)
export const SunOutlined = (props) => <Icon component={SunSvg} {...props} />
export const MoonOutlined = (props) => <Icon component={MoonSvg} {...props} />
export const ThemeOutlined = (props) => <Icon component={ThemeSvg} {...props} />
第二步:在iconfont网站(https://www.iconfont.cn/)找到心仪的图片,然后点击按钮。
第三步:在弹出的图标详情弹层里,点击“复制SVG代码”。
第四步:将选好的SVG代码依次粘贴到src/components/extraIcons/index.js中对应的位置。
※注:一定要仔细坚持以下三方面。
检查svg代码中是否有class以及与颜色相关的fill、stroke等属性,如有,必须连带属性一起删除。
确保
确保
这里以“太阳”图标为例:
const SunSvg = () => (
// 这里粘贴“太阳”图标的SVG代码
<svg
t="1670490651290"
- class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1344"
- width="400"
+ width="1em"
- height="400"
+ height="1em"
+ fill="currentColor"
>
<path
d="...(略)"
p-id="1345"
></path>
</svg>
)
SVG代码太长了,这里就不全部贴出来了。
这样,自定义Icon就制作好了。使用方法在下一小节介绍。
6.2 创建Header组件
新建src/components/header/index.js:
import { Button, Card } from 'antd'
import { MoonOutlined, ThemeOutlined } from '@/components/extraIcons'
import './header.styl'
function Header() {
return (
<Card className="M-header">
<div className="header-wrapper">
<div className="logo-con">Header</div>
<div className="opt-con">
<Button icon={<MoonOutlined />} shape="circle"></Button>
<Button icon={<ThemeOutlined />} shape="circle"></Button>
</div>
</div>
</Card>
)
}
export default Header
新建src/components/header/header.styl:
.M-header
position: relative
z-index: 999
border-radius: 0
overflow hidden
.ant-card-body
padding: 16px 24px
height: 62px
line-height: 32px
.header-wrapper
display: flex
.logo-con
display: flex
font-size: 30px
font-weight: bold
.opt-con
display: flex
flex: 1
justify-content: flex-end
gap: 20px
简单说明一下:
- 使用Antd的
<Card>
组件,是为了跟随主题换肤,否则Header的背景色、边框色、文字色等元素的换肤都要单独实现。 <Card>
组件默认是圆角的,这里通过CSS将其还原成直角。当然你也可以使用Antd提供的SeedToken来对特定组件实现圆角,但不如CSS直接来得痛快。
6.3 引入Header组件
在Home页面里引入Header组件。
修改src/pages/home/index.js:
import { useNavigate } from 'react-router-dom'
import { Button } from 'antd'
+ import Header from '@/components/header'
import { goto } from '@/api'
import './home.styl'
function Home() {
// 创建路由钩子
const navigate = useNavigate()
return (
<div className="P-home">
+ <Header />
<h1>Home Page</h1>
...(略)
同样,在Account页面也引入Header组件。
修改src/pages/account/index.js:
import { Button } from 'antd'
+ import Header from '@/components/header'
import './account.styl'
function Account() {
return (
<div className="P-account">
+ <Header />
<h1>Account Page</h1>
...(略)
运行效果如下:
6.4 组件传参
使用过Vue的同学都知道,Vue组件有data和props。
data是组件内的数据;
props用来接收父组件传递来的数据。
在React中,如果使用的是Class方式定义的组件:
state是组件内的数据;
props用来接收父组件传递来的数据。
如果使用的是function方式定义的组件(也叫“无状态组件”或“函数式组件”):
使用useState()管理组件内的数据(hook);
使用props接收父组件传递来的数据。
Class组件有明确的声明周期管理,但是代码相对来说不如无状态组件简洁优雅。
无状态组件通过hook管理声明周期,效率更高。因此本教程全程使用无状态组件讲解。
下面简单演示下如何实现向子组件传递数据。
通过Home和Account分别向Header组件传递不同的值,并显示在Header组件中。
修改src/pages/home/index.js:
...(略)
M <Header title="home" info={()=>{console.log('info:home')}} />
...(略)
修改src/pages/account/index.js:
...(略)
M <Header title="account" info={()=>{console.log('info:account')}} />
...(略)
修改src/components/header/index.js:
...(略)
M function Header(props) {
+ // 接收来自父组件的数据
+ const { title, info } = props
+ // 如果info存在,则执行info()
+ info && info()
return (
<Card className="M-header">
<div className="header-wrapper">
M <div className="logo-con">Header:{title}</div>
...(略)
运行看下已经生效。
7 二级路由配置
在第6章节中,将Header组件分别导入到Home和Account页面,这显然是一种非常低效的方式。如果有N个页面,那要引入N多次。结合这个问题,下面来讲解如何通过二级路由来解决这个问题。
7.1 创建二级路由的框架页面
新建src/pages/entry/index.js:
import { Outlet } from 'react-router-dom'
import Header from '@/components/header'
import './entry.styl'
function Entry() {
return (
<div className="M-entry">
<Header />
<div className="main-container">
<Outlet />
</div>
</div>
)
}
export default Entry
新建src/pages/entry/entry.styl:
.M-entry
display: flex
flex-direction: column
height: 100%
.main-container
position: relative
flex: 1
这里的<Outlet>
就是为二级路由页面挖好的“坑”,Entry下的路由页面会放到<Outlet>
位置,而Header组件则是一次性引入,非常方便。
然后把Home和Account页面中的Header组件删掉。否则会与Entry里的Header组件重复出现。
修改src/pages/home.js:
import { useNavigate } from 'react-router-dom'
import { Button } from 'antd'
- import Header from '@/components/header'
import { goto } from '@/api'
import './home.styl'
function Home() {
// 创建路由钩子
const navigate = useNavigate()
return (
<div className="P-home">
- <Header title="home" info={()=>{console.log('info:home')}} />
<h1>Home Page</h1>
...(略)
同样,修改src/pages/account.js:
import { Button } from 'antd'
- import Header from '@/components/header'
import './account.styl'
function Account() {
return (
<div className="P-account">
+ <Header title="account" info={()=>{console.log('info:account')}} />
<h1>Account Page</h1>
7.2 配置二级路由
修改src/router/index.js:
import { createHashRouter, Navigate } from 'react-router-dom'
import Login from '@/pages/login'
import Home from '@/pages/home'
import Account from '@/pages/account'
// 引入Entry框架页面
import Entry from '@/pages/entry'
// 全局路由
export const globalRouters = createHashRouter([
// 对精确匹配"/login",跳转Login页面
{
path: '/login',
element: <Login />,
},
{
// 未匹配"/login",全部进入到entry路由
path: '/',
element: (
<Entry />
),
// 定义entry二级路由
children: [
{
// 精确匹配"/home",跳转Home页面
path: '/home',
element: <Home />,
},
{
// 精确匹配"/account",跳转Account页面
path: '/account',
element: <Account />,
},
{
// 如果URL没有"#路由",跳转Home页面
path: '/',
element: <Navigate to="/home" />,
},
{
// 未匹配,,跳转Login页面
path: '*',
element: <Navigate to="/login" />,
},
],
},
])
由于代码变动较多,这里就不采用代码对比的方式了,直接放出最终代码。新变化的地方就是引入了Entry页面,并且把除Login以外的页面,全都放到Entry的二级路由(children)里。也就是说,改造后,一级路由只有Login和Entry两个页面。
改造后,各页面的访问地址还是保持不变:
Login页面:
http://localhost:3000/#/loginHome页面:
http://localhost:3000/#/homeAccount页面:
http://localhost:3000/#/account
运行效果如下:
改造后,Header组件的传参不见了。这是因为把Header放到Entry页面后,需要根据当前路由来判断处于哪个页面,再传给Header。接下来就介绍下如何解决这个问题。
7.3 获取当前路由地址
使用react-router-dom提供的useLocation方法,可以很方便地获得当前路由地址。
修改src/pages/entry/index.js:
M import { Outlet, useLocation } from 'react-router-dom'
import Header from '@/components/header'
import './entry.styl'
function Entry() {
+ // 获得路由钩子
+ const location = useLocation()
return (
<div className="M-entry">
M <Header title={location.pathname} />
<div className="main-container">
<Outlet />
</div>
</div>
)
}
export default Entry
运行效果如下:
使用useLocation方法,可以很方便实现页面位置导航及当前页面状态显示等交互需求,非常适合与Antd的Menu导航菜单组件、Breadcrumb面包屑组件搭配使用。
8 React Developer Tools浏览器插件
为了更方便调试React项目,建议安装Chrome插件。
先科学上网,在Chrome网上应用店里搜索“React Developer Tools”并安装。
安装完成后,打开Chrome DevTools,点击Components按钮,可以清晰的看到React项目代码结构以及各种传参。
9 Redux及Redux Toolkit
Redux是用来做什么的?简单通俗的解释,Redux是用来管理项目级别的全局变量,而且是可以实时监听变化并改变DOM的。当多个模块都需要动态显示同一个数据,并且这些模块从属于不同的父组件,或者在不同的页面中,如果没有Redux,那实现起来就很麻烦了,问题追踪也很痛苦。Redux就是解决这个问题的。
做过Vue开发的同学都知道Vuex,React对应的工具就是Redux。在以前,在React中使用Redux还需要redux-thunk、immutable等插件,逻辑非常麻烦,也很难理解。现在官方推出了Redux Toolkit,一个开箱即用的高效的Redux开发工具集,不需要依赖第三方插件了,使用起来也很简洁。
9.1 安装Redux及Redux Toolkit
执行:
yarn add @reduxjs/toolkit react-redux
9.2 创建全局配置文件
新建src/globalConfig.js:
/**
* 全局配置
*/
export const globalConfig = {
// 初始主题(localStorage未设定的情况)
initTheme: {
// 初始为亮色主题
dark: false,
// 初始主题色
// 与customColorPrimarys数组中的某个值对应
// null表示默认使用Ant Design默认主题色或customColorPrimarys第一种主题色方案
colorPrimary: null,
},
// 供用户选择的主题色,如不提供该功能,则设为空数组
customColorPrimarys: [
'#1677ff',
'#f5222d',
'#fa8c16',
'#722ed1',
'#13c2c2',
'#52c41a',
],
// localStroge用户主题信息标识
SESSION_LOGIN_THEME: 'userTheme',
// localStroge用户登录信息标识
SESSION_LOGIN_INFO: 'userLoginInfo',
}
globalConfig其实与Redux没有太深入的关系,只是为了方便配置一些初始化默认值而已,以及定义localStorage的变量名,这么做就是为了把配置项都抽出来方便维护。
9.3 创建用于主题换肤的store分库
为了便于讲解,先创建分库。按照官方的概念,分库叫做slice。可以为不同的业务创建多个slice,便于独立维护。这里结合主题换肤功能,创建对应的分库。
新建store/slices/theme.js:
import { createSlice } from '@reduxjs/toolkit'
import { globalConfig } from '@/globalConfig'
// 先从localStorage里获取主题配置
const sessionTheme = JSON.parse(window.localStorage.getItem(globalConfig.SESSION_LOGIN_THEME))
// 如果localStorage里没有主题配置,则使用globalConfig里的初始化配置
const initTheme = sessionTheme?sessionTheme: globalConfig.initTheme
//该store分库的初始值
const initialState = {
dark: initTheme.dark,
colorPrimary: initTheme.colorPrimary
}
export const themeSlice = createSlice({
// store分库名称
name: 'theme',
// store分库初始值
initialState,
reducers: {
// redux方法:设置亮色/暗色主题
setDark: (state, action) => {
// 修改了store分库里dark的值(用于让全项目动态生效)
state.dark = action.payload
// 更新localStorage的主题配置(用于长久保存主题配置)
window.localStorage.setItem(globalConfig.SESSION_LOGIN_THEME, JSON.stringify(state))
},
// redux方法:设置主题色
setColorPrimary: (state, action) => {
// 修改了store分库里colorPrimary的值(用于让全项目动态生效)
state.colorPrimary = action.payload
// 更新localStorage的主题配置(用于长久保存主题配置)
window.localStorage.setItem(globalConfig.SESSION_LOGIN_THEME, JSON.stringify(state))
},
},
})
// 将setDark和setColorPrimary方法抛出
export const { setDark } = themeSlice.actions
export const { setColorPrimary } = themeSlice.actions
export default themeSlice.reducer
再啰嗦一下这部分的关键逻辑:
- 先从localStorage里获取主题配置,这么做是为了将用户的主题配置保存在浏览器中,用户在刷新或者重新打开该项目的时候,会直接应用之前设置的主题配置。
- 如果localStorage没有主题配置,则从globalConfig读取默认值,然后再写入localStorage。这种情况一般是用户使用当前浏览器第一次浏览该项目时会用到。
- setDark用来设置“亮色/暗色主题”,setColorPrimary用来设置“主题色”。每次设置后,除了变更store里的值(为了项目全局动态及时生效),还要同步写入localStorage(为了刷新或重新打开时及时生效)。
- “亮色/暗色主题”和“主题色”虽然都是颜色改变,但是完全不同的两个维度的换肤。“亮色/暗色主题”主要是对默认的文字、背景、边框等基础元素进行黑白切换,而“主题色”则是对带有“品牌色”的按钮等控件进行不同色系的颜色切换。
9.4 创建store总库
新建store/index.js:
import { configureStore } from '@reduxjs/toolkit'
// 引入主题换肤store分库
import themeReducer from '@/store/slices/theme'
export const store = configureStore({
reducer: {
// 主题换肤store分库
theme: themeReducer
// 可以根据需要在这里继续追加其他分库
},
})
原理就是创建总库,把各个分库都汇总起来。注释已写明,不再赘述。
9.5 引入store到项目
首先,将store引入到项目工程中。
修改src/index.js:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { globalRouters } from '@/router'
import { ConfigProvider } from 'antd'
+ import { store } from '@/store'
+ import { Provider } from 'react-redux'
// 引入Ant Design中文语言包
import zhCN from 'antd/locale/zh_CN'
// 全局样式
import '@/common/styles/frame.styl'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
+ <Provider store={store}>
<ConfigProvider locale={zhCN}>
<RouterProvider router={globalRouters} />
</ConfigProvider>
+ </Provider>
)
其实就是用react-redux提供的Provider带上store把项目包起来,这样整个项目就可以随时随地访问store了。
9.6 store的使用:实现亮色/暗色主题切换
由于主题换肤的交互操作位于Header组件,所以让Header组件对接store总库。
修改src/components/header/index.js:
import { Button, Card } from 'antd'
+ // 新加入“太阳”图标
M import { MoonOutlined, ThemeOutlined, SunOutlined } from '@/components/extraIcons'
+ // 引入Redux
+ import { useSelector, useDispatch } from 'react-redux'
+ // 从主题换肤store分库引入setDark方法
+ import { setDark } from '@/store/slices/theme'
import './header.styl'
function Header(props) {
+ // 获取redux派发钩子
+ const dispatch = useDispatch()
+ // 获取store中的主题配置
+ const theme = useSelector((state) => state.theme)
// 接收来自父组件的数据
const { title, info } = props
// 如果info存在,则执行info()
info && info()
return (
<Card className="M-header">
<div className="header-wrapper">
<div className="logo-con">Header:{title}</div>
<div className="opt-con">
- <Button icon={<MoonOutlined />} shape="circle"></Button>
+ {theme.dark ? (
+ <Button
+ icon={<SunOutlined />}
+ shape="circle"
+ onClick={() => {
+ dispatch(setDark(false))
+ }}
+ ></Button>
+ ) : (
+ <Button
+ icon={<MoonOutlined />}
+ shape="circle"
+ onClick={() => {
+ dispatch(setDark(true))
+ }}
+ ></Button>
+ )}
<Button icon={<ThemeOutlined />} shape="circle"></Button>
</div>
</div>
</Card>
)
}
export default Header
必要的注释已经写好了。useDispatch和useSelector可以通俗理解为:
- useDispatch用于写入store库,调用store里定义的方法。
- useSelector用于读取store库里的变量值。
以上代码中的theme就是从总库中获取的theme分库。theme.dark就是从theme分库中读取的dark值,从而判断当前是亮色还是暗色主题,进而确定是显示“月亮”按钮还是“太阳”按钮。
现在运行起来,点击Header里的“月亮/太阳”图标,可以进行切换了。但是并没有看到暗色主题效果?这是因为还没有把主题配置传递给Antd。
在本教程的需求中,Login页面不参与主题换肤,而其他页面参与主题换肤。因此,只需要在Entry页面通过useSelector将当前store里的主题配置读取出来,再应用给Antd即可。
修改src/entry/index.js:
import { Outlet, useLocation } from 'react-router-dom'
import Header from '@/components/header'
+ import { useSelector } from 'react-redux'
+ import { ConfigProvider, theme } from 'antd'
import './entry.styl'
+ // darkAlgorithm为暗色主题,defaultAlgorithm为亮色(默认)主题
+ // 注意这里的theme是来自于Ant Design的,而不是store
+ const { darkAlgorithm, defaultAlgorithm } = theme
function Entry() {
// 获得路由钩子
const location = useLocation()
+ // 获取store中的主题配置
+ const globalTheme = useSelector((state) => state.theme)
+ // Ant Design主题变量
+ let antdTheme = {
+ // 亮色/暗色配置
+ algorithm: globalTheme.dark ? darkAlgorithm : defaultAlgorithm,
+ }
return (
+ <ConfigProvider theme={antdTheme}>
<div className="M-entry">
<Header title={location.pathname} />
<div className="main-container">
<Outlet />
</div>
</div>
+ </ConfigProvider>
)
}
export default Entry
必要的注释已经写好了。主要逻辑就是从store里读取当前的主题配置,然后通过Antd提供的ConfigProvider带着antdTheme,把Entry页面包起来。
运行效果如下:
9.7 非Ant Design组件的主题换肤
细心的同学可能发现了,上一章节中的主题切换,页面中的“Home Page”始终是白色,并没有跟随换肤。这是因为它并没有包裹在Antd的组件中。而Header组件能够换肤是因为其外层用了Antd的<Card>
组件。所以在开发过程中,建议尽量使用Antd组件。当然,很可能会遇到自行开发的组件也要换肤。
接下来,就以“Home Page”换肤为目标,讲解下如何实现非Ant Design组件的主题换肤。
实现方式就是用Ant Design提供的useToken方法将当前主题的颜色赋值给非自定义组件。
修改src/pages/home/index.js:
import { useNavigate } from 'react-router-dom'
M import { Button, theme } from 'antd'
import { goto } from '@/api'
import './home.styl'
+ const { useToken } = theme
function Home() {
// 创建路由钩子
const navigate = useNavigate()
+ // 获取Design Token
+ const { token } = useToken()
return (
<div className="P-home">
+ <h1 style={{color: token.colorText}}>Home Page</h1>
<div className="ipt-con">
...(略)
运行效果如下:
这里将“Home Page”的文字色设为了token.colorText,即当前Antd文本色,因此会跟随主题进行换肤。同理,如果想让自定义组件的背景色换肤,可以使用token.colorBgContainer;边框色换肤,可以使用token.colorBorder;使用当前Antd主题色,可以使用token.colorPrimary。
以上这些token,就是Antd官网所介绍的SeedToken、MapToken、AliasToken,这些token涵盖了各种场景的颜色,大家参照官网列出的token说明挑选合适参数即可。
Ant Design 定制主题官方说明:
https://ant-design.antgroup.com/docs/react/customize-theme-cn#theme
9.8 store的使用:实现主题色切换
在src/globalConfig.js里的customColorPrimarys就是留给主题色换肤的。接下来讲解下具体实现方法。为了让交互体验稍微好一点,通过Antd的Modal组件来制作主题色选择功能。
9.8.1 创建主题色选择对话框组件
新建src/components/themeModal/index.js:
import { Modal } from 'antd'
import { useSelector, useDispatch } from 'react-redux'
import { CheckCircleFilled } from '@ant-design/icons'
import { setColorPrimary } from '@/store/slices/theme'
import { globalConfig } from '@/globalConfig'
import './themeModal.styl'
function ThemeModal({ onClose }) {
// 获取redux派发钩子
const dispatch = useDispatch()
// 获取store中的主题配置
const theme = useSelector((state) => state.theme)
return (
<Modal
className="M-themeModal"
open={true}
title="主题色"
onCancel={() => {
onClose()
}}
maskClosable={false}
footer={null}
>
<div className="colors-con">
{
// 遍历globalConfig配置的customColorPrimarys主题色
globalConfig.customColorPrimarys &&
globalConfig.customColorPrimarys.map((item, index) => {
return (
<div
className="theme-color"
style={{ backgroundColor: item }}
key={index}
onClick={() => {
dispatch(setColorPrimary(item))
}}
>
{
// 如果是当前主题色,则显示“对勾”图标
theme.colorPrimary === item && (
<CheckCircleFilled
style={{
fontSize: 28,
color: '#fff',
}}
/>
)
}
</div>
)
})
}
</div>
</Modal>
)
}
export default ThemeModal
补充相应的样式,新建src/components/themeModal/themeModal.styl:
.M-themeModal
.colors-con
margin-top: 20px
display: grid
grid-template-columns: repeat(6, 1fr)
row-gap: 10px
.theme-color
margin: 0 auto
width: 60px
height: 60px
line-height: 68px
border-radius: 6px
cursor: pointer
text-align: center
9.8.2 引入主题色选择对话框组件
修改src/components/header/index.js:
+ import { useState } from 'react'
import { Button, Card } from 'antd'
import { MoonOutlined, ThemeOutlined, SunOutlined } from '@/components/extraIcons'
import { useSelector, useDispatch } from 'react-redux'
import { setDark } from '@/store/slices/theme'
+ import ThemeModal from '@/components/themeModal'
+ import { globalConfig } from '@/globalConfig'
import './header.styl'
function Header(props) {
// 获取redux派发钩子
const dispatch = useDispatch()
// 获取store中的主题配置
const theme = useSelector((state) => state.theme)
// 接收来自父组件的数据
const { title, info } = props
+ // 是否显示主题色选择对话框
+ const [showThemeModal, setShowThemeModal] = useState(false)
// 如果info存在,则执行info()
info && info()
return (
<Card className="M-header">
<div className="header-wrapper">
<div className="logo-con">Header:{title}</div>
<div className="opt-con">
{theme.dark ? (
<Button
icon={<SunOutlined />}
shape="circle"
onClick={() => {
dispatch(setDark(false))
}}
></Button>
) : (
<Button
icon={<MoonOutlined />}
shape="circle"
onClick={() => {
dispatch(setDark(true))
}}
></Button>
)}
- <Button icon={<ThemeOutlined />} shape="circle"></Button>
+ {
+ // 当globalConfig配置了主题色,并且数量大于0时,才显示主题色换肤按钮
+ globalConfig.customColorPrimarys &&
+ globalConfig.customColorPrimarys.length > 0 && (
+ <Button
+ icon={<ThemeOutlined />}
+ shape="circle"
+ onClick={() => {
+ setShowThemeModal(true)
+ }}
+ ></Button>
+ )
+ }
</div>
</div>
+ {
+ // 显示主题色换肤对话框
+ showThemeModal && (
+ <ThemeModal
+ onClose={() => {
+ setShowThemeModal(false)
+ }}
+ />
+ )
+ }
</Card>
)
}
export default Header
运行项目,点击Header组件最右侧的主题色按钮,可以弹出主题色换肤对话框。
但现在点击颜色后还不能生效,这是因为还没有把主题色传递给Antd。
9.8.3 将主题色配置应用于项目
修改src/pages/entry/index.js:
...(略)
function Entry() {
...(略)
// Ant Design主题变量
let antdTheme = {
// 亮色/暗色配置
algorithm: globalTheme.dark ? darkAlgorithm : defaultAlgorithm,
}
+ // 应用自定义主题色
+ if (globalTheme.colorPrimary) {
+ antdTheme.token = {
+ colorPrimary: globalTheme.colorPrimary,
+ }
+ }
return (
...(略)
现在点击主题色对话框里的颜色就会立即生效了,刷新页面或者重新打开网页也会保留上次的主题色。
9.9 安装Redux调试浏览器插件
本章节讲解的Redux使用,每次对store的操作变化跟踪如果用console.log()显然很麻烦,也不及时。为了更方便地跟踪Redux状态,建议安装Chrome插件。这个插件可记录每次Redux的变化,非常便于跟踪调式。
先科学上网,在Chrome网上应用店里搜索“Redux DevTools”并安装。
具体使用方法很简单,大家可在网上查阅相关资料,不再赘述。
10 基于axios封装公用API库
为了方便API的维护,把各个API地址和相关方法集中管理是一个很不错的方案。
10.1 安装axios
axios是一款非常流行的API请求工具,先来安装一下。
执行:
yarn add axios
10.2 封装公用API库
直接上代码。
更新src/api/index.js:
import axios from 'axios'
import { createHashHistory } from 'history'
import { Modal } from 'antd'
import { globalConfig } from '@/globalConfig'
let history = createHashHistory()
// 配合教程演示组件外路由跳转使用,无实际意义
export const goto = (path) => {
history.push(path)
}
// 开发环境地址
let API_DOMAIN = '/api/'
if (process.env.NODE_ENV === 'production') {
// 正式环境地址
API_DOMAIN = 'http://xxxxx/api/'
}
// 用户登录信息在localStorage中存放的名称
export const SESSION_LOGIN_INFO = globalConfig.SESSION_LOGIN_INFO
// API请求正常,数据正常
export const API_CODE = {
// API请求正常
OK: 200,
// API请求正常,数据异常
ERR_DATA: 403,
// API请求正常,空数据
ERR_NO_DATA: 301,
// API请求正常,登录异常
ERR_LOGOUT: 401,
}
// API请求异常统一报错提示
export const API_FAILED = '网络连接异常,请稍后再试'
export const API_LOGOUT = '您的账号已在其他设备登录,请重新登录'
export const apiReqs = {
// 登录(成功后将登录信息存入localStorage)
signIn: (config) => {
axios
.post(API_DOMAIN + 'login', config.data)
.then((res) => {
let result = res.data
config.done && config.done(result)
if (result.code === API_CODE.OK) {
window.localStorage.setItem(
SESSION_LOGIN_INFO,
JSON.stringify({
uid: result.data.loginUid,
nickname: result.data.nickname,
token: result.data.token,
})
)
config.success && config.success(result)
} else {
config.fail && config.fail(result)
}
})
.catch(() => {
config.done && config.done()
config.fail &&
config.fail({
message: API_FAILED,
})
Modal.error({
title: '登录失败',
})
})
},
// 管登出(登出后将登录信息从localStorage删除)
signOut: () => {
const { uid, token } = getLocalLoginInfo()
let headers = {
loginUid: uid,
'access-token': token,
}
let axiosConfig = {
method: 'post',
url: API_DOMAIN + 'logout',
headers,
}
axios(axiosConfig)
.then((res) => {
logout()
})
.catch(() => {
logout()
})
},
// 获取用户列表(仅做示例)
getUserList: (config) => {
config.method = 'get'
config.url = API_DOMAIN + 'user/getUserList'
apiRequest(config)
},
// 修改用户信息(仅做示例)
modifyUser: (config) => {
config.url = API_DOMAIN + 'user/modify'
apiRequest(config)
},
}
// 从localStorage获取用户信息
export function getLocalLoginInfo() {
return JSON.parse(window.localStorage[SESSION_LOGIN_INFO])
}
// 退出登录
export function logout() {
// 清除localStorage中的登录信息
window.localStorage.removeItem(SESSION_LOGIN_INFO)
// 跳转至Login页面
history.push('/login')
}
/*
* API请求封装(带验证信息)
* config.history: [必填]用于页面跳转等逻辑
* config.method: [必须]请求method
* config.url: [必须]请求url
* config.data: 请求数据
* config.formData: 是否以formData格式提交(用于上传文件)
* config.success(res): 请求成功回调
* config.fail(err): 请求失败回调
* config.done(): 请求结束回调
*/
export function apiRequest(config) {
const loginInfo = JSON.parse(
window.localStorage.getItem(SESSION_LOGIN_INFO)
)
if (config.data === undefined) {
config.data = {}
}
config.method = config.method || 'post'
// 封装header信息
let headers = {
loginUid: loginInfo ? loginInfo.uid : null,
'access-token': loginInfo ? loginInfo.token : null,
}
let data = null
// 判断是否使用formData方式提交
if (config.formData) {
headers['Content-Type'] = 'multipart/form-data'
data = new FormData()
Object.keys(config.data).forEach(function (key) {
data.append(key, config.data[key])
})
} else {
data = config.data
}
// 组装axios数据
let axiosConfig = {
method: config.method,
url: config.url,
headers,
}
// 判断是get还是post,并加入发送的数据
if (config.method === 'get') {
axiosConfig.params = data
} else {
axiosConfig.data = data
}
// 发起请求
axios(axiosConfig)
.then((res) => {
let result = res.data
config.done && config.done()
if (result.code === API_CODE.ERR_LOGOUT) {
// 如果是登录信息失效,则弹出Antd的Modal对话框
Modal.error({
title: result.message,
// 点击OK按钮后,直接跳转至登录界面
onOk: () => {
logout()
},
})
} else {
// 如果登录信息正常,则执行success的回调
config.success && config.success(result)
}
})
.catch((err) => {
// 如果接口不通或出现错误,则弹出Antd的Modal对话框
Modal.error({
title: API_FAILED,
})
// 执行fail的回调
config.fail && config.fail()
// 执行done的回调
config.done && config.done()
})
}
代码比较多,必要的备注都写了,不再赘述。
这里主要实现了以下几方面:
-
通过apiReqs把项目所有API进行统一管理。
-
通过apiRequest方法,实现了统一的token验证、登录状态失效报错以及请求错误报错等业务逻辑。
为什么signIn和signOut方法没有像getUserList和modifyUser一样调用apiRequest呢?
因为signIn和signOut的逻辑比较特殊,signIn并没有读取localStorage,而signOut需要清除localStorage,这两个逻辑是与其他API不同的,所以单独实现了。
10.3 Mock.js安装与使用
在开发过程中,为了方便前端独自调试接口,经常使用Mock.js拦截Ajax请求,并返回预置好的数据。本小节介绍下如何在React项目中使用Mock.js。
执行安装:
yarn add mockjs
新建src/mock.js,代码如下:
import Mock from 'mockjs'
const domain = '/api/'
// 模拟login接口
Mock.mock(domain + 'login', function () {
let result = {
code: 200,
message: 'OK',
data: {
loginUid: 10000,
nickname: '兔子先生',
token: 'yyds2023',
},
}
return result
})
然后在src/index.js中引入mock.js:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { globalRouters } from '@/router'
import { ConfigProvider } from 'antd'
import { store } from '@/store'
import { Provider } from 'react-redux'
+ import './mock'
...(略)
如此简单。这样,在项目中请求/api/login的时候,就会被Mock.js拦截,并返回Mock.js中模拟好的数据。
※注:
正式上线前,一定不要忘记关掉Mock.js!!!直接在src/index.js中注释掉import './mock’这段代码即可。
10.4 发起API请求:实现登录功能
继续完善Login页面,实现一个API请求。
修改src/pages/login/index.js:
+ import { useState } from 'react'
+ import { apiReqs } from '@/api'
import { useNavigate } from 'react-router-dom'
import { Button, Input } from 'antd'
import imgLogo from './logo.png'
import './login.styl'
function Login() {
// 创建路由钩子
const navigate = useNavigate()
+ // 组件中自维护的实时数据
+ const [account, setAccount] = useState('')
+ const [password, setPassword] = useState('')
+ // 登录
+ const login = () => {
+ apiReqs.signIn({
+ data: {
+ account,
+ password,
+ },
+ success: (res) => {
+ console.log(res)
+ navigate('/home')
+ },
+ })
+ }
return (
<div className="P-login">
<img src={imgLogo} alt="" className="logo" />
<div className="ipt-con">
M <Input placeholder="账号" value={account} onChange={(e)=>{setAccount( e.target.value)}} />
</div>
<div className="ipt-con">
M <Input.Password placeholder="密码" value={password} onChange={(e)=>{setPassword(e.target.value)}} />
</div>
<div className="ipt-con">
M <Button type="primary" block={true} onClick={login}>登录</Button>
</div>
</div>
)
}
export default Login
运行项目,进入http://localhost:3000/#/login
,账号、密码随便输入,点击“登录”,已经通过mock模拟请求成功了。
查看浏览器localStorage,登录信息也成功写入。
11 一些细节问题
11.1 解决Modal.method跟随主题换肤的问题
Antd的Modal提供了直接的函数式调用,比如Modal.success、Modal.error、Modal.error、Modal.confirm等。
这种方式并没有使用<Modal>
包裹,所以是无法跟随主题换肤的。
下面通过完善退出登录的交互,来复现下这个问题。
修改src/pages/home/index.js:
- import { useNavigate } from 'react-router-dom'
M import { Button, theme, Modal } from 'antd'
M import { logout, goto } from '@/api'
import './home.styl'
const { useToken } = theme
function Home() {
- // 创建路由钩子
- // const navigate = useNavigate()
// 获取Design Token
const { token } = useToken()
+ // 退出登录
+ const exit = () => {
+ Modal.confirm({
+ title: '是否退出登录?',
+ onOk() {
+ logout()
+ },
+ })
+ }
return (
<div className="P-home">
<h1 style={{ color: token.colorText }}>Home Page</h1>
<div className="ipt-con">
<Button
onClick={() => {
goto('/login')
}}
>
组件外跳转
</Button>
</div>
<div className="ipt-con">
M <Button type="primary" onClick={exit}>返回登录</Button>
</div>
</div>
)
}
export default Home
这里通过Modal.confirm来确认是否退出登录,点击后可以发现,在暗色主题下,Modal.confirm并未跟随主题。
继续修改src/pages/home/index.js:
...(略)
function Home() {
// 获取Design Token
const { token } = useToken()
+ const [modal, contextHolder] = Modal.useModal()
// 退出登录
const exit = () => {
+ // 把之前的Modal改为modal
M modal.confirm({
title: '是否退出登录?',
onOk() {
logout()
},
})
}
return (
<div className="P-home">
...(略)
+ {
+ // 这是最终解决Modal.method跟随换肤的关键,contextHolder在组件DOM中随便找个地方放就行
+ contextHolder
+ }
</div>
)
}
export default Home
必要的逻辑直接看注释吧。contextHolder是关键,通过它来获取上下文从而解决主题换肤问题。效果如下:
Ant Design的Modal.useModal()说明:
https://ant-design.antgroup.com/components/modal-cn#modalusemodal
Account页面的“返回登录”也用同样的方式修改,不再赘述。
※注:
从@/api中引入的logout()方法,会清除localStorage中的登录信息并跳转至Login页面。具体可参看src/api/index.js中该方法的注释。
11.2 路由守卫
现在实现一个简单的路由守卫,通过Entry进行登录状态验证,未登录用户访问Home或者Account页面则强制跳转至Login页面。
修改src/router/index.js,加入以下代码:
import { createHashRouter, Navigate } from 'react-router-dom'
import Login from '@/pages/login'
import Home from '@/pages/home'
import Account from '@/pages/account'
import Entry from '@/pages/entry'
+ import { globalConfig } from '@/globalConfig'
...(略)
+ // 路由守卫
+ export function PrivateRoute(props) {
+ // 判断localStorage是否有登录用户信息,如果没有则跳转登录页
+ return window.localStorage.getItem(globalConfig.SESSION_LOGIN_INFO) ? (
+ props.children
+ ) : (
+ <Navigate to="/login" />
+ )
+ }
然后再修改src/pages/entry/index.js:
import { Outlet, useLocation } from 'react-router-dom'
import Header from '@/components/header'
import { useSelector } from 'react-redux'
import { ConfigProvider, theme } from 'antd'
+ import { PrivateRoute } from '@/router'
import './entry.styl'
...(略)
function Entry() {
...(略)
return (
+ <PrivateRoute>
<ConfigProvider theme={antdTheme}>
...(略)
</ConfigProvider>
+ </PrivateRoute>
)
}
export default Entry
再次运行项目,这时,如果未经Login页面正常登录(即localStorage里没有登录信息),直接通过浏览器地址栏输入http://localhost:3000/#/home
或者http://localhost:3000/#/account
则会直接返回到Login页面。这是因为在Entry框架页面中引入了PrivateRoute,先检查localStorage是否有登录用户信息,没有则强制跳转至Login页面。
当然,如果你想在路由守卫中实现更多的业务逻辑判断,请自行丰富PrivateRoute方法即可。
11.3 设置开发环境的反向代理请求
在React开发环境中,与后端API联调时通常会遇到跨域问题。可以借助http-proxy-middleware工具实现反向代理。
执行安装命令:
yarn add http-proxy-middleware --dev
新建src/setupProxy.js:
/**
* 反向代理配置
*/
const { createProxyMiddleware } = require('http-proxy-middleware')
module.exports = function (app) {
app.use(
// 开发环境API路径匹配规则
'^/api',
createProxyMiddleware({
// 要代理的真实接口API域名
target: 'http://xxxx',
changeOrigin: true,
})
)
}
这代码的意思就是,只要请求地址是以"/api"开头,那就反向代理到http://xxxx
域名下,跨域问题解决!大家可以根据实际需求进行修改。
一定记得要把mock.js注释掉,否则会先被mock.js拦截,到不了反向代理这一步。
※注:
setupProxy.js设置后,一定要重启项目才生效。
12 build项目
在build前还需要做一步配置,否则build版本网页中的文件引用都是绝对路径,运行后是空白页面。
修改package.json:
"name": "react-app",
"version": "0.1.0",
"private": true,
+ "homepage": "./",
...(略)
然后执行:
yarn build
生成的文件在项目根目录的build目录中,打开index.html即可看到正常运行的项目。
13 项目Git源码
本项目已上传至Gitee和GitHub,方便各位下载。
Gitee:
https://gitee.com/betaq/react-app-2023spring
GitHub:
https://github.com/Yuezi32/react-app-2023spring
结束语
以上就是本次React全家桶教程的全部内容。篇幅较长,确实是花了很长时间精心整理、反复验证、句句斟酌的完整教程,希望能够帮助到你。更多精彩详实的开发教程,欢迎阅读我的微信公众号「卧梅又闻花」
。