2023新春版:看这篇大宝典就够了!从零搭建React项目全家桶

共 103066字,需浏览 207分钟

 ·

2023-05-18 00:28



97c6dd1ed64d3c081312626f84574fd1.webp



4f8f51561e1dc429a4cead1258e71f8d.webp


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.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/



dd424b8890a18b7f78c6efc11ce6ca30.webp






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>





运行效果如下:



d7717b9dcff74f386239a5d785c8fd94.webp






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:



e32a7af7d50b3e58df2783820b812d2a.webp


可以看到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'






46547cd0951cd9be5848cce876741850.webp





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'


看看效果:



ff19a9d645bb8a6a5938fa0005166447.webp


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'


看看效果:


ad98a124bca862ed4edd3b8048630729.webp


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/#/login




Home页面:



http://localhost:3000/#/home




Account页面:



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组件)中,这样就非常适合封装统一的处理逻辑。


560bfc306bb1dad7f3392bbae13e7ea7.webp


后续章节会讲述如何封装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/)找到心仪的图片,然后点击按钮。


fa362d815f3f706b5ef0bffa31fd66d9.webp


第三步:在弹出的图标详情弹层里,点击“复制SVG代码”。






ce92a16d38c198089e9a0753ead2edcd.webp


第四步:将选好的SVG代码依次粘贴到src/components/extraIcons/index.js中对应的位置。







※注:一定要仔细坚持以下三方面。



1. 检查svg代码中是否有class以及与颜色相关的fill、stroke等属性,如有,必须连带属性一起删除。



2. 确保<SVG>标签中有fill="currentColor",否则图标的颜色将不能改变。



3. 确保<SVG>标签中width和height属性的值为1em,否则图标的大小将不能改变





这里以“太阳”图标为例:


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


简单说明一下:


1. 使用Antd的<Card>组件,是为了跟随主题换肤,否则Header的背景色、边框色、文字色等元素的换肤都要单独实现。


2. <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>







...(略)





运行效果如下:






07aac3f7a176d21e15feede19991e3f0.webp



6.4 组件传参


使用过Vue的同学都知道,Vue组件有dataprops


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>


...(略)


运行看下已经生效。


e0e80429ad78ac6cdb6ca0271882b348.webp






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/#/login




Home页面:



http://localhost:3000/#/home




Account页面:



http://localhost:3000/#/account





运行效果如下:






e47e2ae356e486f36c1b11956aefb59c.webp


改造后,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





运行效果如下:



f3cbd992b581794f666917ebb46b1ded.webp


使用useLocation方法,可以很方便实现页面位置导航及当前页面状态显示等交互需求,非常适合与Antd的Menu导航菜单组件、Breadcrumb面包屑组件搭配使用。



8 React Developer Tools浏览器插件


为了更方便调试React项目,建议安装Chrome插件。


先科学上网,在Chrome网上应用店里搜索“React Developer Tools”并安装。



aad503db6e74700f2525a214b7d6fc0e.webp


安装完成后,打开Chrome DevTools,点击Components按钮,可以清晰的看到React项目代码结构以及各种传参。


2fd555a2fe0637495099b8d72b256176.webp



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





再啰嗦一下这部分的关键逻辑:


1. 先从localStorage里获取主题配置,这么做是为了将用户的主题配置保存在浏览器中,用户在刷新或者重新打开该项目的时候,会直接应用之前设置的主题配置。


2. 如果localStorage没有主题配置,则从globalConfig读取默认值,然后再写入localStorage。这种情况一般是用户使用当前浏览器第一次浏览该项目时会用到。


3. setDark用来设置“亮色/暗色主题”,setColorPrimary用来设置“主题色”。每次设置后,除了变更store里的值(为了项目全局动态及时生效),还要同步写入localStorage(为了刷新或重新打开时及时生效)。


4. “亮色/暗色主题”和“主题色”虽然都是颜色改变,但是完全不同的两个维度的换肤。“亮色/暗色主题”主要是对默认的文字、背景、边框等基础元素进行黑白切换,而“主题色”则是对带有“品牌色”的按钮等控件进行不同色系的颜色切换。



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页面包起来。


运行效果如下:



318374085798db4ed648c07e3a2d8502.webp


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">


...(略)


运行效果如下:


f0cc5926cfaaf25189cd062f46567bba.webp


这里将“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组件最右侧的主题色按钮,可以弹出主题色换肤对话框。


ce107a6bb8c52c458216c12889f1d225.webp


但现在点击颜色后还不能生效,这是因为还没有把主题色传递给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 (


...(略)


现在点击主题色对话框里的颜色就会立即生效了,刷新页面或者重新打开网页也会保留上次的主题色。


ae7fb648cc9c5d76be8e7bec4a94187b.webp


9.9 安装Redux调试浏览器插件

本章节讲解的Redux使用,每次对store的操作变化跟踪如果用console.log()显然很麻烦,也不及时。为了更方便地跟踪Redux状态,建议安装Chrome插件。这个插件可记录每次Redux的变化,非常便于跟踪调式。


先科学上网,在Chrome网上应用店里搜索“Redux DevTools”并安装。



93b74f3c1fe21941e4bddb49351bf6db.webp


具体使用方法很简单,大家可在网上查阅相关资料,不再赘述。


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()


})


}


代码比较多,必要的备注都写了,不再赘述。


这里主要实现了以下几方面:


1. 通过apiReqs把项目所有API进行统一管理。


2. 通过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模拟请求成功了。


666e2ac33fd7569ca993ea50ade080ab.webp


查看浏览器localStorage,登录信息也成功写入。


b41d8889ee62a51f5ed2f197ee4300b8.webp



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并未跟随主题。


7d578cde751755543112ec79af10bbd4.webp


继续修改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是关键,通过它来获取上下文从而解决主题换肤问题。效果如下:


56411c9e2a219f83c24db041424c5eb9.webp




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全家桶教程的全部内容。篇幅较长,确实是花了很长时间精心整理、反复验证、句句斟酌的完整教程,希望能够帮助到你。更多精彩详实的开发教程,欢迎阅读我的微信公众号「卧梅又闻花」






推荐阅读






2022新春版:React+Antd开发Chrome插件教程(Manifest V3)上篇



2022新春版:React+Antd开发Chrome插件教程(Manifest V3)下篇



2022新春版:手把手教你搭建Electron17+React17+Antd架构工程



看了就会的Next.js SSR/SSG 实战教程





欢迎关注我的微信公众号「卧梅又闻花」,随时获取最新文章。






浏览 46
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报