灏天阁

轻松搞定基于Vite4的React项目全家桶

· Yin灏

Vite4 发布有很长一段时间了,相信很多小伙伴早已经用上了 Vite,体验到了 Vite 带来的各种喜悦。

Vite 是一个基于浏览器原生 ES imports 的开发服务器,利用浏览器去解析 imports,在服务器端按需编译返回,相比 webpack,完全省去了打包这个过程,所以编译起来非常迅速,也不会随着项目模块增多而变慢。关于 Vite 的详细介绍,网上已经有很多相关内容了,本次分享主要聚焦如何使用 Vite 搭建 React+Antd 工程。

在几个月前,我刚发布了《2023 新春版:看这篇大宝典就够了!从零搭建 React 项目全家桶》。这篇文章是基于官方提供的 Create-React-App 进行构建。近期,React 官网改版了,全篇没有提到 Create-React-App,反而推荐使用其他社区的脚手架工具来使用 React,其中就提到了 Vite。而 Create-React-App 也一直停留在 5.0.1 版本,从 2022 年 4 月 13 日至今没有更新,看样子也被官方抛弃了。当然 Create-React-App 仍然具有它的使用价值。

编写本篇 Vite 版本教程,一是帮助小伙伴们省去自行摸索的时间,跟上前端技术的快车;二是关注 Vite 很久了,在项目中安心使用 Vite 的时机已成熟,是时候来次本系列教程的大版本更新了。下面就开始 Vite 的学习之旅吧!

先睹为快

先看下目录了解本教程都有哪些内容。

1 初始化项目
• 1.1 使用Vite新建项目
• 1.2 安装并运行项目
• 1.3 精简项目
2 Vite基础配置
• 2.1 配置国内镜像源
• 2.2 支持Sass/Scss/Less/Stylus
• 2.3 设置dev环境的Server端口号
• 2.4 设置dev环境自动打开浏览器
• 2.5 设置路径别名
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 在Header组件中添加页面导航示
• 6.5 组件传参
7 二级路由配置
• 7.1 创建二级路由的框架页面
• 7.2 配置二级路由
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 设置开发环境的反向代理请求
• 11.4 兼容js文件
• 11.5 允许dev环境的IP访问
• 11.6 批量升级全部项目npm依赖包
12 build项目
• 12.1 设置静态资源引用路径
• 12.2 设置build目录名称及静态资源存放目录
• 12.3 开启build项目生成map文件(不推荐)
• 12.4 执行build项目
13 项目Git源码
结束语

本次分享 Demo 的主要依赖包版本:

  • Node.js 18.16.0
  • vite 4.3.9
  • antd 5.5.2
  • axios 1.4.0
  • mockjs 1.1.0
  • react 18.2.0
  • react-redux 8.0.7
  • react-router-dom 6.11.2
  • less 4.1.3
  • sass 1.62.1
  • stylus 0.59.0
  • @reduxjs/toolkit 1.9.5

※注:

  • 代码区域每行开头的:
  • “+” 表示新增
  • “-” 表示删除
  • “M” 表示修改

跟着操作一遍,就可以快速上手 Vite 啦!下面请跟着新版教程一步步操作。

1 初始化项目

1.1 使用 Vite 新建项目

※注:Vite 需要 Node.js 版本 14.18+,16+。然而,有些模板需要依赖更高的 Node 版本才能正常运行,当你的包管理器发出警告时,请注意升级你的 Node 版本。

先进入想要创建项目的目录,在这个目录下执行安装命令。

如果使用 npm,执行:

npm create vite@latest

如果使用 yarn,执行:

yarn create vite

执行后,会要求填写项目名称,这里我填写的是 vite-react-app,可根据情况自定。

Project name: vite-react-app

然后,会要求选择框架,选择 React:

css

复制代码

 Select a framework:
    Vanilla
    Vue
>   React
    Preact
    Lit
    Svelte
    Others

最后,选择开发语言,本教程选择 JavaScript:

 Select a variant:
    TypeScript
    TypeScript + SWC
>   JavaScript
    JavaScript + SWC

回答以上“灵魂三问”后,即可完成 Vite 项目创建。

如果没有安装 yarn,可执行以下命令全局安装:

npm install --global yarn

yarn 中文网站: yarn.bootcss.com/

1.2 安装并运行项目

进入项目目录,运行命令进行项目依赖包的安装。

cd vite-react-app
yarn  或者 npm install

稍等片刻,安装完成后,执行以下命令运行项目:

yarn dev 或者 npm run dev

是不是感觉编译的速度非常快,几乎没有感知就完成了。

与 Create-React-App 不同,Vite 默认是不会自动启动浏览器打开项目页面的。

需要手动打开以下地址访问项目:

http://localhost:5173/

1.2_安装并运行项目.png

Vite 默认开启的端口是 5173,后续章节会讲解怎么修改端口号。

1.3 精简项目

接下来,删除用不到的文件,最简化项目。

   ├─ /node_modules
   ├─ /public
-  |  └─ vite.svg
   ├─ /src
-  |  ├─ /assets
-  |  |  └─ react.svg
-  |  ├─ App.css
   |  ├─ App.jsx
-  |  ├─ index.css
   |  └─ main.jsx
   ├─ .eslintrc.cjs
   ├─ .gitignore
   ├─ index.html
   ├─ package.json
   ├─ vite.config.js
   └─ yarn.lock

现在目录结构如下,清爽许多:

├─ /node_modules
├─ /public
├─ /src
|  ├─ App.jsx
|  └─ main.jsx
├─ .eslintrc.cjs
├─ .gitignore
├─ index.html
├─ package.json
├─ vite.config.js
└─ yarn.lock

以上文件删除后,页面会报错。这是因为相应的文件引用已不存在。需要继续修改代码,先让项目正常运行起来。

逐个修改以下文件,最终精简代码依次如下:

src/App.jsx

function App() {
  return <div className="App">Vite-React-App</div>;
}
export default App;

src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

index.html

<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

在上述index.html代码中,修改了网站图标,因此需要自行准备一个图标文件favicon.ico,存放在/public目录下。当然,也可以使用 svg 格式。

   ├─ /node_modules
   ├─ /public
+  |  └─ favicon.ico
   ├─ /src
   |  ├─ App.jsx
   |  └─ main.jsx
   ├─ .eslintrc.cjs
   ├─ .gitignore
   ├─ index.html
   ├─ package.json
   ├─ vite.config.js
   └─ yarn.lock

这里你可能会问,为什么在index.html中的<link>中引入图标的路径是"/favicon.ico",而 favicon.ico 明明是放在/public 目录下,却不是"/public/favicon.ico"呢?

按照 Vite 官方说明:引入 public 中的资源永远应该使用根绝对路径,并且,public 中的资源不应该被 JavaScript 文件引用。

public 目录 Vite 官方说明:

执行 yarn dev,运行效果如下:

1.3_精简项目.png

2 Vite 基础配置

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 或者 npm 的配置,可以执行以下命令查看:

yarn 查看方法:

yarn config list

npm 查看方法:

npm config list

※注: 本教程主要使用 yarn,后续不再复述对应的 npm 命令。

2.2 支持 Sass/Scss/Less/Stylus

Vite 本身提供了对.scss/.sass/.less/.styl/.stylus文件的内置支持。无需再安装特定的 Vite 插件,但必须安装相应的预处理器依赖。

支持 Sass/Scss,执行以下命令安装:

yarn add -D sass

支持 Less,执行以下命令安装:

yarn add -D less

支持 Stylus,执行以下命令安装:

yarn add -D stylus

安装后,就可以直接使用以上对应的 CSS 预处理语言了,非常方便。

CSS 预处理 Vite 官方说明:

2.3 设置 dev 环境的 Server 端口号

dev server 默认端口是 5173,如果想修改为其他端口(例如习惯使用 Create-React-App 的 3000 端口),可以进行以下设置。

修改vite.config.js

    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'

    // https://vitejs.dev/config/
    export default defineConfig({
+       server: {
+           // 指定dev sever的端口号,默认为5173
+           port: 3000,
+       },
        plugins: [react()],
    })

※注:与基于 webpack 的 Create-React-App 不同,Vite 修改项目配置后,不需要重启项目即可生效。

2.4 设置 dev 环境自动打开浏览器

使用 Create-React-App 创建的工程,在启动的时候会自动打开浏览器运行当前项目。但是基于 Vite 创建的工程默认情况并不会自动打开浏览器。如果想要保持 Create-React-App 的习惯,自动打开浏览器,可进行以下设置。

修改vite.config.js

    // https://vitejs.dev/config/
    export default defineConfig({
        server: {
            // 指定dev sever的端口号
            port: 3000,
+           // 自动打开浏览器运行以下页面
+           open: '/',
        },
        ...
    })

open 的"/“值,表示的是打开"localhost:3000/"。

如果想直接打开其他页面,例如"localhost:3000/#/home”,open 的值则设置为"/#/home"即可。

※注:open 的值修改后,虽然已经生效,但不会直接触发打开浏览器的行为。这个行为只发生在通过命令启动项目的时候,也就是执行 yarn dev 的时候。

2.5 设置路径别名

为了避免使用相对路径的麻烦,可以设置路径别名。

修改vite.config.js

    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
+   import path from 'path'

    // https://vitejs.dev/config/
    export default defineConfig({
        server: {
            ...
        },
+       resolve: {
+           alias: {
+               '@': path.resolve(__dirname, 'src'),
+           },
+       },
        ...
    })

这个配置里使用了 path,不要忘记在代码头部要import path from 'path'哦。

这样在 js 代码开头的 import 路径中,直接使用@表示“src 根目录”,不用去自己去数有多少个"../“了。

例如,src/main.jsx

// 表示该文件当前路径下的App.jsx(相对路径)
import App from "./App";
// 表示src/App.jsx,等价于上面的文件地址(绝对路径)
import App from "@/App";

3 项目架构搭建

3.1 项目目录结构设计

项目目录结构可根据项目实际灵活制定。这里分享下我常用的结构,主要分为公用模块目录、组件模块目录、页面模块目录、路由配置目录、Redux 目录等几个部分,让项目结构更加清晰合理。

├─ /node_modules
├─ /public
|  └─ favicon.ico        <-- 网页图标
├─ /src
|  ├─ /api               <-- api目录
|  |  └─ index.jsx       <-- api库
|  ├─ /common            <-- 全局公用目录
|  |  ├─ /fonts          <-- 字体文件目录
|  |  ├─ /images         <-- 图片文件目录
|  |  ├─ /js             <-- 公用js文件目录
|  |  └─ /styles         <-- 公用样式文件目录
|  |  |  ├─ frame.styl   <-- 全部公用样式(import本目录其他全部styl)
|  |  |  ├─ reset.styl   <-- 清零样式
|  |  |  └─ global.styl  <-- 全局公用样式
|  ├─ /components        <-- 公共模块组件目录
|  |  ├─ /header         <-- 头部导航模块
|  |  |  ├─ index.jsx    <-- header主文件
|  |  |  └─ header.styl  <-- header样式文件
|  |  └─ ...             <-- 其他模块
|  ├─ /pages             <-- 页面组件目录
|  |  ├─ /home           <-- home页目录
|  |  |  ├─ index.jsx    <-- home主文件
|  |  |  └─ home.styl    <-- home样式文件
|  |  ├─ /login          <-- login页目录
|  |  |  ├─ index.jsx    <-- login主文件
|  |  |  └─ login.styl   <-- login样式文件
|  |  └─ ...             <-- 其他页面
|  ├─ /route             <-- 路由配置目录
|  ├─ /store             <-- Redux配置目录
|  ├─ globalConfig.jsx   <-- 全局配置文件
|  ├─ main.jsx           <-- 项目入口文件
|  └─ mock.jsx           <-- mock数据文件
├─ .eslintrc.cjs         <-- ESLint配置文件
├─.gitignore
├─ index.html            <-- HTML页模板
├─ package.json
├─ vite.config.js        <-- Vite配置文件
└─ yarn.lock

这里需要注意的是,基于 Vite 脚手架的工程在 src 目录里并没有使用 js 文件,而是以 jsx 文件进行开发。默认情况下,js 文件是不能正常加载的。在后续章节会讲解如何通过修改 Vite 配置来兼容 js 文件,但是仍然不推荐这么做。除此之外,在 src 目录之外的vite.config.js则相反,没有使用 jsx 文件。

另外,以上项目结构已经没有src/App.jsx了,现在先不用删除,随着后续章节的讲解再删除。

接下来,就按照上面的目录结构设计开始构建项目。

3.2 关于样式命名规范

以我多年来的开发经验来讲,合理的样式命名规范对项目开发有很大的帮助,主要体现在以下方面:

(1)避免因样式名重复导致的污染。

(2)从命名上可直观区分“组件样式”、“页面样式”(用于给在此页面的组件样式做定制调整)、“全局样式”。

(3)快速定位模块,便于查找问题。

分享一下本教程的样式命名规范:

  1. G-xx: 表示全局样式,用来定义公用样式。

  2. P-xx: 表示页面样式,用来设置页面的背景色、尺寸、定制化调整在此页面的组件样式。

  3. M-xx: 表示组件样式,专注组件本身样式。

后续教程中,可以具体看到以上规范是如何应用的。

3.3 设置全局公用样式

我个人比较喜欢 Stylus 简洁的语法,因此本教程以 Stylus 作为 css 预处理语言。各位可以根据自己的习惯,自由选择 Sass/Scss、Less、Stylus。

新建清零样式文件,src/common/styles/reset.styl

由于 reset.css 代码较多,这里不再放出。非常推荐参考这个 reset css,代码比较全面,更新也比较及时(截至本文写作时,是 2023 年 2 月 14 日更新的)。

具体代码详见:github.com/elad2412/th…

新建全局样式文件,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/main.jsx里引入frame.styl

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

- // 全局样式
- import '@/common/styles/frame.styl'

ReactDOM.createRoot(document.getElementById('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.jsx 来验证下 Antd:

import { Button } from "antd";
function App() {
  return (
    <div className="App">
      <h1>Vite-React-App</h1>
      <Button type="primary">Button</Button>
    </div>
  );
}
export default App;

执行 yarn dev:

4.1_安装Ant Design.png

可以看到 Antd 的 Button 组件正常显示出来了。

※注:

  • Antd 5.x 已经没有全局污染的 reset 样式了。因此不用再担心使用了 Antd 会影响页面样式。

4.2 设置 Antd 为中文语言

Antd 默认语言是英文,需进行以下设置调整为中文。

修改src/main.jsx

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'

M ReactDOM.createRoot(document.getElementById('root')).render(

-       <ConfigProvider locale={zhCN}>
-           <App />
-       </ConfigProvider>
- )

现在还没开始构建页面,因此关于 Antd 5.x 酷炫的主题换肤在后续章节再讲解,先别着急。

5 页面开发

本次教程包含 Login、Home、Account 三个业务页面和一个二级路由页面 Entry。其中:

  • Login 页面不换肤,不需要验证登录状态。
  • Home 页面和 Account 页面,跟随换肤,并通过 Entry 进行登录状态验证及路由切换。

工程文件变动如下:

    ├─ /node_modules
    ├─ /public
    |  └─ favicon.ico        <-- 网页图标
    ├─ /src
    |  ├─ /api               <-- api目录
    |  |  └─ index.jsx       <-- api库
    |  ├─ /common            <-- 全局公用目录
    |  ├─ /components        <-- 公共模块组件目录
+   |  ├─ /pages
+   |  |  ├─ /account
+   |  |  |  ├─ index.jsx
+   |  |  |  └─ account.styl
+   |  |  ├─ /entry
+   |  |  |  ├─ index.jsx
+   |  |  |  └─ entry.styl
+   |  |  ├─ /home
+   |  |  |  ├─ index.jsx
+   |  |  |  └─ home.styl
+   |  |  ├─ /login
+   |  |  |  ├─ index.jsx
+   |  |  |  ├─ login.styl
+   |  |  |  └─ logo.png
    |  ├─ /route             <-- 路由配置目录
    |  ├─ /store             <-- Redux配置目录
    |  ├─ globalConfig.jsx   <-- 全局配置文件
    |  ├─ main.jsx           <-- 项目入口文件
    |  └─ mock.jsx           <-- mock数据文件
    ├─ .eslintrc.cjs         <-- ESLint配置文件
    ├─.gitignore
    ├─ index.html            <-- HTML页模板
    ├─ package.json
    ├─ vite.config.js        <-- Vite配置文件
    └─ yarn.lock

5.1 构建 Login 页面

页面构建代码不再详述,都是很基础的内容了。

新建src/pages/login/index.jsx

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

- import App from './App'
- import App from '@/pages/login'

运行效果如下:

5.1_构建Login页面.png

5.2 构建 Home 页面

直接上代码。

新建src/pages/home/index.jsx

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

- import App from '@/pages/login'
- import App from '@/pages/home'

运行效果如下:

5.2_构建Home页面.png

5.3 构建 Account 页面

基本与 Home 页面一样,直接上代码。

新建src/pages/account/index.jsx

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

- import App from '@/pages/home'
- import App from '@/pages/account'

看看效果:

5.3_构建Account页面.png

5.4 通过一级路由实现页面跳转

为了实现页面的跳转,需要安装 react-router-dom。

执行:

yarn add react-router-dom

接下来进行路由配置。

新建src/router/index.jsx

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

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'

ReactDOM.createRoot(document.getElementById('root')).render(
<ConfigProvider locale={zhCN}>

-           <App />
*           <RouterProvider router={globalRouters} />
</ConfigProvider>
)

这里使用了<RouterProvider>实现路由跳转。同时,为了减少项目文件的依赖层级深度,也删除了<App>,从此与src/App.jsx文件告别了。

记得删掉src/App.jsx

执行 yarn dev 启动项目,输入对应的路由地址,可以正常显示对应的页面了。

  1. Login 页面: http://localhost:3000/#/login

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

  3. Account 页面: http://localhost:3000/#/account

5.4_通过一级路由实现页面跳转.png

5.5 在 React 组件中实现页面路由跳转

下面要实现的功能是,点击 Login 页面的“登录”按钮,跳转至 Home 页面。

修改src/pages/login/index.jsx

+   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.jsx

+   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 页面;当 API 请求失败时,进行统一的报错提示。

以上这些情况的统一处理,当然是封装成公用的模块最合适。但往往这些纯功能性的模块都不是 React 组件,而是纯原生 js。所以就没办法使用 useNavigate()了。

下面介绍一下如何实现在非 React 组件中进行页面路由跳转。

新建src/api/index.jsx

import { globalRouters } from "@/router";
export const goto = (path) => {
  globalRouters.navigate(path);
};

以上代码在非 React 组件中引入全局路由,并封装了 goto 函数。

src/pages/home/index.jsx里调用 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 onClick={()=>{navigate('/login')}}>返回登录</Button>
                </div>

            </div>
        )
    }
    export default Home

在 Home 页点击“组件外跳转”按钮,可以正常跳转至 Login 页面了,而实际执行跳转的代码是在src/api/index.jsx(非 React 组件)中,这样就非常适合封装统一的处理逻辑。

5.6_在非React组件中实现页面路由跳转.png

后续章节会讲述如何封装 api 接口,并通过组件外路由的方式实现 API 调用失败时的统一跳转。

6 组件开发

为了配合后续章节介绍二级路由和主题换肤,构建一个公用的头部组件。

6.1 创建自定义 SVG 图标 Icon 组件

Antd 自带了很多 Icon,非常方便直接使用。但在项目中遇到 Antd 没有的图标怎么办?当然,前提要求是自己构建的图标也能支持随时改变颜色和大小等样式。

例如针对切换亮色/暗色主题功能,Antd 没有提供“太阳”“月亮”“主题色”的 Icon。

第一个方法是在 iconfont 网站(www.iconfont.cn/)上制作自己的 iconfont 字体,然后以字体文件的方式应用到项目中。这种方式相信从事前端开发的同学都很熟悉了,不再赘述。这种方式相对来说比较麻烦,每次图标有变动时,都要重新生成一遍,而且遇到 iconfont 网站打不开等突发情况时,只能干着急。不是很推荐。

这里推荐第二个方法,就是基于 Antd 的 Icon 组件制作本地的自定义图标,而且用起来跟 Antd 自带的 Icon 是一样的,也不用额外考虑换肤的问题。虽然 Antd 官网介绍了制作方法,但讲解得不够具体。

Ant Design 官方说明: ant-design.antgroup.com/components/…

下面具体分享一下这种高效的方案。

第一步:创建自定义图标库

新建src/components/extraIcons/index.jsx

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 网站(www.iconfont.cn/)找到心仪的图片,然后点击按钮。

6.1_创建自定义图标Icon组件-1.png

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

6.1_创建自定义图标Icon组件-2.png

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

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

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

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

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

    import { useNavigate } from 'react-router-dom'
    import { Button } from 'antd'
+   import Header from '@/components/header'
    import './account.styl'

    function Account() {

        // 创建路由钩子
        const navigate = useNavigate()

        return (
            <div className="P-account">
+               <Header />
                <h1>Account Page</h1>
                ...
        )
    }

运行效果如下:

6.3_引入Header组件.png

6.4 在 Header 组件中添加页面导航

现在,要在 Header 组件中添加页面导航,主要实现两个功能:

  1. 点击导航,跳转到对应的页面
  2. 根据当前所处的页面,将对应的导航进行“当前态”显示

在本示例中,Header 组件仅出现在 Home 和 Account 页面,因此导航中不包括 Login 页面。

修改src/components/header/index.jsx

M   import { Button, Card, Menu } from 'antd'
    import { MoonOutlined, ThemeOutlined } from '@/components/extraIcons'
+   import { HomeOutlined, UserOutlined } from '@ant-design/icons'
+   import { useLocation, useNavigate } from 'react-router-dom'
    import './header.styl'

    function Header() {

+       // 创建路由定位钩子
+       const location = useLocation()
+       // 创建路由钩子
+       const navigate = useNavigate()

+       // 定义导航栏
+       const menuItems = [
+           {
+               // 导航显示的名称
+               label: 'Home',
+               // 导航唯一标识,为便于当前态的显示,与当前路由保持一致
+               key: '/home',
+               // 导航的前置图标
+               icon: <HomeOutlined />,
+               // 点击跳转行为
+               onClick: () => {
+                   navigate('/home')
+               },
+           },
+           {
+               label: 'Account',
+               key: '/account',
+               icon: <UserOutlined />,
+               onClick: () => {
+                   navigate('/account')
+               },
+           },
+       ]

        return (
            <Card className="M-header">
                <div className="header-wrapper">
                    <div className="logo-con">Header</div>
+                   <div className="menu-con">
+                       <Menu mode="horizontal" selectedKeys={location.pathname} items={menuItems} />
+                   </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
    ...(略)
    .header-wrapper
        display: flex
        .logo-con
            display: flex
            font-size: 30px
            font-weight: bold
+           .menu-con
+               margin-left: 20px
+               width: 300px
        .opt-con
            display: flex
            flex: 1
            justify-content: flex-end
            gap: 20px

这里需要注意的就是 useLocation()的使用。通过 useLocation()的 pathname,可以得到当前页面所处的路由地址,结合 Menu 组件中对导航 key 的定义,就可以判断是否为当前页面了。使用 useLocation 方法,可以很方便实现页面位置导航及当前页面状态显示等交互需求,非常适合与 Antd 的 Menu 导航菜单组件、Breadcrumb 面包屑组件搭配使用。

运行效果如下:

6.4_在Header组件中添加页面导航.png

6.5 组件传参

使用过 Vue 的同学都知道,Vue 组件有 data 和 props。

data 是组件内的数据;

props 用来接收父组件传递来的数据。

在 React 中,如果使用的是 Class 方式定义的组件:

state 是组件内的数据;

props 用来接收父组件传递来的数据。

如果使用的是 function 方式定义的组件(也叫“无状态组件”或“函数式组件”):

使用 useState()管理组件内的数据(hook);

使用 props 接收父组件传递来的数据。

Class 组件有明确的声明周期管理,但是代码相对来说不如无状态组件简洁优雅。

无状态组件通过 hook 管理声明周期,效率更高。因此本教程全程使用无状态组件讲解。

下面简单演示下如何实现向子组件传递数据。

通过 Home 和 Account 分别向 Header 组件传递不同的值,并显示在 Header 组件中。

修改src/pages/home/index.jsx

    // ...(略)
M   <Header title="home" info={()=>{console.log('info:home')}} />
    // ...(略)

修改src/pages/account/index.jsx

    // ...(略)
M   <Header title="account" info={()=>{console.log('info:account')}} />
    // ...(略)

修改src/components/header/index.jsx

    // ...(略)

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>
                    // ...(略)

运行效果如下:

6.5_组件传参.png

7 二级路由配置

在第 6 章节中,将 Header 组件分别导入到 Home 和 Account 页面,这显然是一种非常低效的方式。如果有 N 个页面,那要引入 N 多次。结合这个问题,下面来讲解如何通过二级路由来解决这个问题。

7.1 创建二级路由的框架页面

新建src/pages/entry/index.jsx

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

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

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

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 两个页面。

改造后,各页面的访问地址还是保持不变:

  1. Login 页面: http://localhost:3000/#/login

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

  3. Account 页面: http://localhost:3000/#/account

运行效果如下:

7.2_配置二级路由.png

改造后,Header 组件的传参不见了。这是因为把 Header 放到 Entry 页面后,就没有给 Header 组件传递 title 参数了。关于组件间传参、使用 useLocation()定位当前路由,以及二级路由的使用,这些关键知识点已经讲解完,这里也就不再对 Header 组件进行修改了。

8 React Developer Tools 浏览器插件

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

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

8_React Developer Tools浏览器插件.png

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

8_React Developer Tools浏览器插件-2.png

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

/**
 * 全局配置
 */
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.jsx

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

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

    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'

    ReactDOM.createRoot(document.getElementById('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.jsx

    import { Button, Card, Menu } from 'antd'
+   // 新加入“太阳”图标
M   import { MoonOutlined, ThemeOutlined, SunOutlined } from '@/components/extraIcons'
    import { HomeOutlined, UserOutlined } from '@ant-design/icons'
    import { useLocation, useNavigate } from 'react-router-dom'
+   // 引入Redux
+   import { useSelector, useDispatch } from 'react-redux'
+   // 从主题换肤store分库引入setDark方法
+   import { setDark } from '@/store/slices/theme'
    import './header.styl'

    function Header(props) {

        // 创建路由定位钩子
        const location = useLocation()
        // 创建路由钩子
        const navigate = useNavigate()

        // 定义导航栏
        const menuItems = [
            ...
        ]

+       // 获取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="menu-con">
                        <Menu
                            mode="horizontal"
                            selectedKeys={location.pathname}
                            items={menuItems}
                        />
                    </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.jsx

    import { Outlet } 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() {

+       // 获取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 />
                    <div className="main-container">
                        <Outlet />
                    </div>
                </div>
+           </ConfigProvider>
        )
    }

    export default Entry

必要的注释已经写好了。主要逻辑就是从 store 里读取当前的主题配置,然后通过 Antd 提供的 ConfigProvider 带着 antdTheme,把 Entry 页面包起来。

运行效果如下:

9.6_store的使用:实现亮色暗色主题切换.png

9.7 非 Ant Design 组件的主题换肤

细心的同学可能发现了,上一章节中的主题切换,页面中的“Home Page”文字始终是白色,并没有跟随换肤。这是因为它并没有包裹在 Antd 的组件中。而 Header 组件能够换肤是因为其外层用了 Antd 的<Card>组件。所以在开发过程中,建议尽量使用 Antd 组件。当然,很可能会遇到自行开发的组件也要换肤。

接下来,就以“Home Page”文字换肤为目标,讲解下如何实现非 Ant Design 组件的主题换肤。

实现方式就是用 Ant Design 提供的 useToken 方法将当前主题的颜色赋值给非自定义组件。

修改src/pages/home/index.jsx

    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">
M               <h1 style={{color: token.colorText}}>Home Page</h1>
                <div className="ipt-con">
            // ...(略)

运行效果如下:

9.7_非Ant Design组件的主题换肤.png

这里将“Home Page”的文字色设为了 token.colorText,即当前 Antd 文本色,因此会跟随主题进行换肤。同理,如果想让自定义组件的背景色换肤,可以使用 token.colorBgContainer;边框色换肤,可以使用 token.colorBorder;使用当前 Antd 主题色,可以使用 token.colorPrimary。

以上这些 token,就是 Antd 官网所介绍的 SeedToken、MapToken、AliasToken,这些 token 涵盖了各种场景的颜色,大家参照官网列出的 token 说明挑选合适参数即可。

Ant Design 定制主题官方说明:

9.8 store 的使用:实现主题色切换

在 src/globalConfig.jsx 里的 customColorPrimarys 就是留给主题色换肤的。接下来讲解下具体实现方法。为了让交互体验稍微好一点,通过 Antd 的 Modal 组件来制作主题色选择功能。

9.8.1 创建主题色选择对话框组件

新建src/components/themeModal/index.jsx

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

+   import { useState } from 'react'
    import { Button, Card, Menu } from 'antd'
    // 新加入“太阳”图标
    import { MoonOutlined, ThemeOutlined, SunOutlined } from '@/components/extraIcons'
    import { HomeOutlined, UserOutlined } from '@ant-design/icons'
    import { useLocation, useNavigate } from 'react-router-dom'
    // 引入Redux
    import { useSelector, useDispatch } from 'react-redux'
    // 从主题换肤store分库引入setDark方法
    import { setDark } from '@/store/slices/theme'
+   import ThemeModal from '@/components/themeModal'
+   import { globalConfig } from '@/globalConfig'
    import './header.styl'

    function Header(props) {

        ...

+       // 是否显示主题色选择对话框
+       const [showThemeModal, setShowThemeModal] = useState(false)

        return (
            <Card className="M-header">
                <div className="header-wrapper">
                    <div className="logo-con">Header:{title}</div>
                    <div className="menu-con">
                        <Menu
                            mode="horizontal"
                            selectedKeys={location.pathname}
                            items={menuItems}
                        />
                    </div>
                    <div className="opt-con">
                        ...
-                       <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 组件最右侧的主题色按钮,可以弹出主题色换肤对话框。

9.8.2_引入主题色选择对话框组件.png

但现在点击颜色后还不能生效,这是因为还没有把主题色传递给 Antd。

9.8.3 将主题色配置应用于项目

修改src/pages/entry/index.jsx

    // ...(略)

    function Entry() {
        // ...(略)

        // Ant Design主题变量
        let antdTheme = {
            // 亮色/暗色配置
            algorithm: globalTheme.dark ? darkAlgorithm : defaultAlgorithm,
        }

+       // 应用自定义主题色
+       if (globalTheme.colorPrimary) {
+           antdTheme.token = {
+               colorPrimary: globalTheme.colorPrimary,
+           }
+       }

        return (
            // ...(略)

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

9.8.3_将主题色配置应用于项目.png

9.9 安装 Redux 调试浏览器插件

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

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

9.9_安装Redux调试浏览器插件.png

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

10 基于 axios 封装公用 API 库

为了方便 API 的维护,把各个 API 地址和相关方法集中管理是一个很不错的方案。

10.1 安装 axios

axios 是一款非常流行的 API 请求工具,先来安装一下。

执行:

yarn add axios

10.2 封装公用 API 库

直接上代码。

更新src/api/index.jsx

import { globalRouters } from "@/router";
import axios from "axios";
import { Modal } from "antd";
import { globalConfig } from "@/globalConfig";

// 配合教程演示组件外路由跳转使用,无实际意义
export const goto = (path) => {
  globalRouters.navigate(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页面
  goto("/login");
}

/*
 * API请求封装(带验证信息)
 * 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.jsx,代码如下:

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/main.jsx中引入mock.jsx

    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'
+   import './mock'
    ...

如此简单。这样,在项目中请求/api/login 的时候,就会被 Mock.js 拦截,并返回 Mock.js 中模拟好的数据。

※注:

  • 正式上线前,一定不要忘记关掉 Mock.js!!!直接在src/main.jsx中注释掉import './mock'这段代码即可。

10.4 发起 API 请求:实现登录功能

继续完善 Login 页面,实现一个 API 请求。

修改src/pages/login/index.jsx

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

10.4_发起API请求:实现登录功能.png

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

10.4_发起API请求:实现登录功能-2.png

11 一些细节问题

11.1 解决 Modal.method 跟随主题换肤的问题

Antd 的 Modal 提供了直接的函数式调用,比如 Modal.success、Modal.error、Modal.error、Modal.confirm 等。

这种方式并没有使用<Modal>包裹,所以是无法跟随主题换肤的。

下面通过完善退出登录的交互,来复现下这个问题。

修改src/pages/home/index.jsx

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

11.1_解决Modal.method跟随主题换肤的问题.png

继续修改src/pages/home/index.jsx

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

11.1_解决Modal.method跟随主题换肤的问题-2.png

Ant Design 的 Modal.useModal()说明:

Account 页面的“返回登录”也用同样的方式修改,不再赘述。

一般来说,“退出登录”的功能应该是放在 Header 组件中,感兴趣的同学可以参照上述方法自己试试。

※注:

  • 从@/api 中引入的 logout()方法,会清除 localStorage 中的登录信息并跳转至 Login 页面。具体可参看 src/api/index.jsx 中该方法的注释。

11.2 路由守卫

现在实现一个简单的路由守卫,通过 Entry 进行登录状态验证,未登录用户访问 Home 或者 Account 页面则强制跳转至 Login 页面。

修改src/router/index.jsx,加入以下代码:

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

    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 设置开发环境的反向代理请求

基于 Vite 架构的工程,设置反向代理就很容易了,无需安装额外的依赖包。

修改vite.config.js

    // ...(略)

    // https://vitejs.dev/config/
    export default defineConfig({
        server: {
            // 指定dev sever的端口号
            port: 3000,
            // 自动打开浏览器运行以下页面
            open: '/',
+           // 设置反向代理
+           proxy: {
+               // 以下示例表示:请求URL中含有"/api",则反向代理到http://localhost
+               // 例如: http://localhost:3000/api/login -> http://localhost/api/login
+               '/api': {
+                   target: 'http://localhost/',
+                   changeOrigin: true,
+               },
+           },
        },
        ...
    })

这代码的意思就是,只要请求地址是以”/api"开头,那就反向代理到http://localhost域名下,跨域问题解决!大家可以根据实际需求进行修改。

更详细的 Vite 反向代理设置,请参阅官方说明:

一定记得要把 mock.js 注释掉,否则会先被 mock.js 拦截,到不了反向代理这一步。

11.4 兼容 js 文件

在本项目中,src 目录下并没有使用 js 文件,而是 jsx 文件。如果想引入 js 文件,可以通过以下设置来支持。

修改vite.config.js

    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    import path from 'path'
+   import fs from 'fs/promises'

    // https://vitejs.dev/config/
    export default defineConfig({

        ...

+       esbuild: {
+           loader: 'jsx',
+           include: /src\/.*\.jsx?$/,
+           exclude: [],
+       },
+       optimizeDeps: {
+           esbuildOptions: {
+               plugins: [
+                   {
+                       name: 'load-js-files-as-jsx',
+                       setup(build) {
+                           build.onLoad(
+                               { filter: /src\/.*\.js$/ },
+                               async (args) => ({
+                                   loader: 'jsx',
+                                   contents: await fs.readFile(args.path, 'utf8'),
+                               })
+                           )
+                       },
+                   },
+               ],
+           },
+       },
        plugins: [react()],
    })

第一次启动的时候有小概率会报错,但是不影响实际运行,有报错强迫症的不建议使用 js:

The JSX syntax extension is not currently enabled
The esbuild loader for this file is currently set to "js" but it must be set to "jsx" to be able to parse JSX syntax. You can use "loader: { '.js': 'jsx' }" to do that.

进行以上配置后,把 src 目录下某个 jsx 文件改成 js 文件,项目依然可以正常运行。

如果你以前项目都是使用 js 文件,想迁移到 Vite 架构,但又懒得把 js 都改成 jsx,那就可以用以上配置来实现。

既然 Vite 默认不支持 js,那建议还是使用 jsx 吧。

11.5 允许 dev 环境的 IP 访问

上述内容中,都是通过localhost:3000来访问。如果换成本机 IP,则发现不能访问了,这就非常不利于被其他设备访问(例如手机访问 dev 环境页面)。

支持 IP 访问,修改vite.config.js

    ...

    // https://vitejs.dev/config/
    export default defineConfig({
        server: {
+           // 支持IP访问
+           host: true,

            ...

11.6 批量升级全部项目 npm 依赖包

如果你希望项目所有依赖包都保持最新版本,推荐一个快速检测 package.json 是否都为最新版的工具。

执行以下命令进行全局安装:

yarn add -g npm-check-updates

然后在有 package.json 的目录下执行:

ncu

就会快速检查所有依赖是否存在更新版本。

执行:

ncu -u

则会将 package.json 中所有依赖修改为最新版本。

最后,再执行:

yarn

进行依赖包的更新安装即可。

※注:更新依赖包有可能会出现不兼容的情况,更新前请先备份好 package.json,以便恢复。

12 build 项目

在 build 前还可以做一些配置,以下简述几个常用的配置。

12.1 设置静态资源引用路径

默认情况下,build 出来的项目,静态资源引用的一级路径都是"/",建议修改成相对路径"./",这样在部署上线的时候不需要太关注访问目录的问题。

修改vite.config.js

    // ...(略)

    // https://vitejs.dev/config/
    export default defineConfig({
+       // 静态资源引用路径,默认为"/"
+       base: './',
        // ...(略)
    })

12.2 设置 build 目录名称及静态资源存放目录

默认情况下,build 出来的项目,将静态文件(js、图片等)都存放在 assets 目录下。也可以通过配置,改为其他名称(例如 Create-React-App 使用 static)。

同样,Vite 默认 build 生成的项目目录名为 dist,也可以改为其他名称(例如 Create-React-App 使用 build)。

修改vite.config.js

    // ...(略)

    // https://vitejs.dev/config/
    export default defineConfig({
+       build: {
+           // build目录名称,默认为"dist"
+           outDir: 'build',
+           // 静态资源存放目录名称,默认为"assets"
+           assetsDir: 'static',
+       },
        // ...(略)
    })

12.3 开启 build 项目生成 map 文件(不推荐)

map 文件,即 Javascript 的 source map 文件,是为了解决被混淆压缩的 js 在调试的时候,能够快速定位到压缩前的源代码的辅助性文件。这个文件发布出去,会暴露源代码。因此,非常不建议在 build 时生成 map 文件。

Vite 默认是不生成 map 文件的,但如果需要排查 build 后的工程,可以配置生成 map 文件。

修改vite.config.js

    // ...(略)

    // https://vitejs.dev/config/
    export default defineConfig({
        build: {
            // build目录名称,默认为"dist"
            outDir: 'build',
            // 静态资源存放目录名称,默认为"assets"
            assetsDir: 'static',
+           // 生成map文件,默认为false(不建议设置)
+           sourcemap: true,
        },
        // ...(略)
    })

12.4 执行 build 项目

执行以下命令即可 build 项目:

yarn build

与 Create-React-App 不同的是,Create-React-App build 出来的项目,可以直接在本地双击 index.html 运行,而 Vite build 的项目必须部署在 Sever 环境下运行。当然了,项目最终都要运行在 Server 上。只不过,如果你的客户没有 Server,又想在本地进行预览,那 Vite 这种架构确实在这方面有点不方便。

13 项目 Git 源码

本项目已上传至 Gitee 和 GitHub,方便各位下载。

Gitee:

GitHub:

原文:《2023 盛夏版:轻松搞定基于 Vite4 的 React 项目全家桶》

更多精品阅读

《2023 新春版:手把手教你搭建 Electron24+React18+Antd5 架构工程》

《2023 新春版:React+Antd 开发 Chrome 插件教程(Manifest V3)》

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

- Book Lists -