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

在这里插入图片描述
在这里插入图片描述

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

在这里插入图片描述
后续章节会讲述如何封装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中对应的位置。

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

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

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

  3. 确保 标签中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>
                
            ...(略)

运行效果如下:
在这里插入图片描述

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

Home页面:
http://localhost:3000/#/home

Account页面:
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

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

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

运行效果如下:

在这里插入图片描述

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

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

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

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

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

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mr兔子先生

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值