Skip to content

React 极客园

一、项目前置准备

1. 创建项目

1.1 基于CRA创建项目

CRA是一个底层基于webpack快速创建React项目的脚手架工具

bash
# 使用npx创建项目
npx create-react-app react-jike
# 进入到项目
cd react-jike
# 安装依赖
npm i
# 启动项目
npm start
image-20240905224323536

1.2 基于 Vite 创建项目

vite 下一代的前端工具链,为开发提供极速响应,使用 vite可以创建 Vue,React... 项目

pnpm 速度快、节省磁盘空间的软件包管理器

bash
# 使用 vite 创建react 项目
pnpm create vite pnpm create vite project-jike --template react
# 进入到项目
cd project-jike
# 安装依赖
pnpm i
# 启动项目
pnpm run dev
image-20240905224424132

2. 调整项目目录结构

bash
-src
  -apis           项目接口函数
  -assets         项目资源文件,比如,图片等
  -components     通用组件
  -pages          页面组件
  -store          集中状态管理
  -utils          工具,比如,token、axios 的封装等
  -router		  路由配置
  -hooks		  自定义hook
  -App.jsx        根组件
  -index.css      全局样式
  -main.jsx       主文件

src/main.jsx

jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx' // 引入根组件
import './index.css' // 引入全局样式

createRoot(document.getElementById('root')).render(
  <StrictMode>// 开启严格模式,(开发环境下)一些hooks会执行两次
    <App />
  </StrictMode>,
)

src/App.jsx

jsx
const App = () => {
  return <div>this is app</div>
}
export default App

3. 使用scss预处理器

SASS 是一种预编译的 CSS,支持一些比较高级的语法,可以提高编写样式的效率

实现步骤

  1. 安装解析 sass 的包 pnpm i sass -D
  2. 创建全局样式文件:index.scss
css
body {
  margin: 0;
  div {
    color: blue;
  }
}

4. 组件库antd使用

我们的项目是一个传统的PC管理后台,有现成的组件库可以使用,帮助我们提升开发效率,其中使用最广的就是antD

官网:Ant Design of React - Ant Design

实现步骤(Vite)

  1. 安装 antd 组件库:pnpm install antd --save
  2. 修改 src/App.js,引入 antd 的按钮组件进行测试
jsx
import React from 'react';
import { Button } from 'antd';

const App = () => (
  <div className="App">
    <Button type="primary">Button</Button>
  </div>
);

export default App;

![image-20240905230846217](./React 极客园.assets/image-20240905230846217.png)

5. 配置基础路由

单页应用需要对应的路由支持,我们使用 react-router-dom 最新版本

实现步骤

  1. 安装路由包 pnpm i react-router-dom
  2. 准备 LayoutLogin俩个基础组件
  3. 配置路由
  4. 在指定位置注入一级路由和二级路由

代码实现pages/Layout/index.jsx

jsx
const Layout = () => {
  return <div>this is layout</div>
}
export default Layout

pages/Login/index.jsx

jsx
const Login = () => {
  return <div>this is login</div>
}
export default Login

router/index.jsx

jsx
import { createBrowserRouter } from "react-router-dom";
import Layout from "../pages/layout";
import Login from "../pages/Login";
import NotFound from "../pages/NotFound";

const router = createBrowserRouter([
  // 根路由
  {
    path: "/",
    element: <Layout />,
  },
  // 登录一级路由
  {
    path: "/login",
    element: <Login />,
  },
  // 404一级路由
  {
    path: "*",
    element: <NotFound />,
  },
]);

export default router;

App.js

jsx
import React from "react";
import { RouterProvider } from "react-router-dom";
import router from "./router";

const App = () => (
  <div className="App">
    <RouterProvider router={router} />
  </div>
);

export default App;

6. 配置别名路径

项目背景:在业务开发过程中文件夹的嵌套层级可能会比较深,通过传统的路径选择会比较麻烦也容易出错,设置路径别名可以简化这个过程 @ => /src

6.1 路径编译配置(CRA构建的项目)

  1. 安装 craco 工具包
  2. 增加 craco.config.js 配置文件
  3. 修改 scripts 命令
  4. 测试是否生效
bash
npm i @craco/craco -D
javascript
const path = require('path')

module.exports = {
  // webpack 配置
  webpack: {
    // 配置别名
    alias: {
      // 约定:使用 @ 表示 src 文件所在路径
      '@': path.resolve(__dirname, 'src')
    }
  }
}
json
"scripts": {
  "start": "craco start",
  "build": "craco build",
  "test": "craco test",
  "eject": "react-scripts eject"
}
javascript
import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import Layout from '@/pages/Layout'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
  },
  {
    path: '/login',
    element: <Login />,
  },
])

export default router

6.2 路径编译配置(vite构建的项目)

修改 vite.config.js

json
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import path from "path";

export default defineConfig({
  plugins: [react()],
+  resolve: {
+    alias: {
+      //  @ ==> /src
+      "@": path.resolve(__dirname, "src"),
+    },
+  },
});

6.3 VsCode提示配置(重要)

VsCode 中输入 @/ 会自动提示 src/ 文件夹下的文件

实现步骤

  1. 在项目根目录创建 jsconfig.json 配置文件
  2. 在配置文件中添加以下配置

代码实现

json
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

说明:VSCode会自动读取jsconfig.json 中的配置,让vscode知道 @ 就是src目录

7. 使用gitee管理项目

实现步骤:

  1. 在gitee上创建一个空项目仓库
  2. 初始化本地项目仓库
  3. 把远程仓库和本地仓库连接
  4. 提交代码到远程仓库

二、登录模块

1. 基本结构搭建

![image-20240914100820496](./React 极客园.assets/image-20240914100820496.png)

实现步骤

  1. pages/Login/index.js 中创建登录页面基本结构
  2. Login 目录中创建 index.scss 文件,指定组件样式
  3. logo.pnglogin.png 拷贝到 assets 目录中

代码实现pages/Login/index.js

jsx
import { Card } from "antd";
import "./index.scss";
import logo from "@/assets/logo.png";
import { Button, Form, Input } from "antd";
function Login() {
  return (
    <>
      <Card hoverable className="login-card">
        {/* logo */}
        <img className="login-logo" src={logo} alt="logo" />
        {/* 登录表单 */}
        <Form>
          <Form.Item>
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item>
            <Input size="large" placeholder="请输入验证码" />
          </Form.Item>
          <Form.Item>
            <Button
              type="primary"
              htmlType="submit"
              style={{ width: "100%" }}
              size="large"
            >
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </>
  );
}

export default Login;

pages/Login/index.scss

css
.login {
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
  background: center/cover url("../../assets/login.png");
  .login-card {
    width: 400px;
    margin: 130px auto;
    .login-logo {
      display: block;
      width: 200px;
      margin: 0 auto;
    }
  }
}

2. 表单校验实现

表单校验可以在提交登录之前校验用户的输入是否符合预期,如果不符合就阻止提交,显示错误信息

image-20240914100917123

实现步骤

  1. Form 组件添加 validateTrigger 属性,指定校验触发时机的集合

  2. 为 Form.Item 组件添加 name 属性

    ![image-20240907101016803](./React 极客园.assets/image-20240907101016803.png)

  3. Form.Item 组件添加 rules 属性,用来添加表单校验规则对象

校验时机

  1. Form 组件添加 validateTrigger 属性统一设置字段触发验证的时机
    • Form.Item 组件添加 validateTrigger 属性设置单个字段校验的时机
  2. 也可以为 Button 组件添加 htmlType="submit" 在点击登录按钮时进行表单校验

**代码实现 ** page/Login/index.js

jsx
function Login() {
  return (
    <>
      <Card hoverable className="login-card">
        {/* logo */}
        <img className="login-logo" src={logo} alt="logo" />
        {/* 登录表单 */}
        {/* validateTrigger:表单校验触发时机 */}
+        <Form validateTrigger={["onBlur", "onChange"]}>
          <Form.Item
+            name="mobile" // 要校验字段名/绑定的属性值
+            // 设置多条校验规则
+            rules={[
+              {
+                required: true, // 是否为必填项
+                message: "请输入手机号!", // 校验不通过错误提示信息
+              },
+              { 
+                pattern: /^1[3-9]\d{9}$/, // 正则校验
+                message: "请输入正确的手机号", // 校验不通过错误提示信息
+              },
+            ]}
+          >
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item
+            name="code" // 要检验的字段名(表单收集数据字段名)
+            rules={[
+             {
+                required: true,
+                message: "请输入验证码",
+              },
+            ]}
+          >
            <Input size="large" placeholder="请输入验证码" />
          </Form.Item>
          <Form.Item>
            <Button
              type="primary"
+             htmlType="submit"  // 提交按钮,点击时会自动进行表单校验
              style={{ width: "100%" }}
              size="large"
            >
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </>
  );
}

3. 获取登录表单数据

当用户输入了正确的表单内容,点击确定按钮时需要收集到用户当前输入的内容,用来提交接口请求

实现步骤

  1. Form 组件添加 initialValues 属性,来初始化表单值
  2. Form 组件添加 onFinish 属性,该事件会在点击绑定有htmlType="submit"的登录按钮时触发(提交表单且数据验证成功后回调事件)
  3. onFinish 事件绑定回调函数,在回调函数参数 values 中获取到收集的表单值

代码实现pages/Login/index.js

jsx
function Login() {
  const onFinish = (values) => {
    console.log(values) // 收集的表单数据 key为FromItem的name属性
  };
  return (
    <>
{/* 登录表单 */}
  {/* validateTrigger:表单校验触发时机 */}
  <Form
    validateTrigger={["onBlur", "onChange"]}
+    initialValues={{ mobile: "13888888888", code: "246810" }}
+    onFinish={onFinish}
  >
    <Form.Item
+      name="mobile"
      // 设置多条校验规则
      rules={[
        {
          required: true,
          message: "请输入手机号!",
        },
        {
          pattern: /^1[3-9]\d{9}$/,
          message: "请输入正确的手机号",
        },
      ]}
    >
      <Input size="large" placeholder="请输入手机号" />
    </Form.Item>
    <Form.Item
+      name="code"
      rules={[
        {
          required: true,
          message: "请输入验证码",
        },
        {
          pattern: /^\d{6}$/,
          message: "请输入6位数字验证码",
        },
      ]}
    >
      <Input size="large" placeholder="请输入验证码" />
    </Form.Item>
    <Form.Item>
      <Button
        type="primary"
+        htmlType="submit"
        style={{ width: "100%" }}
        size="large"
      >
        登录
      </Button>
    </Form.Item>
  </Form>
}
image.png

4. 封装request工具模块

业务背景: 前端需要和后端拉取接口数据,axios是使用最广的工具插件,针对于项目中的使用,我们需要做一些简单的封装

接口文档:https://www.apifox.cn/apidoc/shared-fa9274ac-362e-4905-806b-6135df6aa90e/api-31967347

实现步骤

  1. 安装 axios 到项目

    pnpm i axios
  2. 创建 utils/request.js 文件

  3. 创建 axios 实例,配置 baseURL、超时时间、请求拦截器、响应拦截器

javascript
// src\utils\request.js
import { message } from "antd";
import axios from "axios";

// 创建请求示例对象
const request = axios.create({
  // 请求的基础路径
  baseURL: "http://geek.itheima.net/v1_0",
  // 超时时间
  timeout: 5000,
});

// 请求拦截器(请求发送前做拦截,配置一些自定义请求参数)
request.interceptors.request.use((config) => {
  return config;  // 返回配置对象
});

// 响应拦截器(在响应返回到客户端前做拦截,处理返回的数据)
request.interceptors.response.use(
  // 2xx 范围内的状态码都会触发该函数。
  (response) => {
    // 简化返回数据(axios 会对返回的数据进行二次封装)
    return response.data;
  },
  // 超出 2xx 范围的状态码都会触发该函数。
  (error) => {
    // 提示错误信息
    message.error(error.response.data.message);
    // 返回失败的 Promise 对象 终止请求(axios返回一个Promise)
    return Promise.reject(error);
  }
);

// 暴露请求对象
export default request;

5. 使用Redux管理token

token 作为用户的标识数据,需要在很多个模块中使用redux 可以方便的解决状态共享问题

5.1 安装Redux相关工具包

pnpm i react-redux @reduxjs/toolkit

5.2 配置Redux

创建 userStore 初始化用户相关数据,声明同步修改 token方法和异步登录获取 token的方法,并在主文件中注入仓库

javascript
// src\store\modules\user.js
import request from "@/utils/request";
import { createSlice } from "@reduxjs/toolkit";

const userStore = createSlice({
  // 仓库唯一标识
  name: "user",
  // 初始化数据
  initialState: {
    // 本地存储中如果没有token就设置为空字符串
    token: localStorage.getItem("token_key") || "",
  },
  // 同步修改方法
  reducers: {
    // 修改token的同步方法(store + localStorage)
    setToken(state, action) {
      // action.payload:调用setToken actionCreater传递的参数
      state.token = action.payload;
      // 将token存储到本地存储中
      localStorage.setItem("token_key", action.payload);
    },
  },
});

// 解构出actionCreater并暴露 使用dispatch调用
const { setToken } = userStore.actions;

// 异步获取用户数据的方法
const fetchLogin = (loginParams) => {
  return async (dispatch) => {
    // 发请求获取token
    const result = await request.post("/authorizations", loginParams);
    // 提交同步方法存储token
    dispatch(setToken(result.data.token));
  };
};

// 暴露actionCreater
export { setToken, fetchLogin };

// 获取reducer函数并导出
const reducer = userStore.reducer;
export default reducer;
javascript
// src\store\index.js
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./modules/user";

// 组合redux子模块 + 导出store实例
const store = configureStore({
  reducer: {
    user: userReducer,
  },
});

// 暴露仓库
export default store;
jsx
// src\main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.scss";
import { Provider } from "react-redux";
import store from "./store/index.js";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <Provider store={store}>  // 在应用中注入store
      <App />
    </Provider>
  </StrictMode>
);

6. 实现登录逻辑

业务逻辑:

  1. 表单校验通过后调用仓库中的 action 发送登录请求
  2. 登录成功后跳转到首页,提示用户登录成功
jsx
import { message } from 'antd'
import useStore from '@/store'
import { fetchLogin } from '@/store/modules/user'
import { useDispatch } from 'react-redux'

const Login = () => {
  const dispatch = useDispatch()
  const navigate = useNavigate()
  const onFinish = async formValue => {
    await dispatch(fetchLogin(formValue))
    navigate('/')
    message.success('登录成功')
  }
  return (
    <div className="login">
     <!-- 省略... -->
    </div>
  )
}

export default Login

登录按钮防抖:使用 lodash提供的 debounce方法

  1. 安装:pnpm i lodash

  2. 登录回调防抖处理(避免用户多次点击登录按钮,发送不必要的请求)

    jsx
    +import _ from "lodash";
    
    function Login() {
    +  const onFinish = _.debounce(async (values) => {
        // fetchLogin:异步函数返回一个Promise
        await dispatch(fetchLogin(values));
        // 跳转到首页
        navigate("/");
        // 成功提示
        message.success("登录成功");
        notification.open({
          message: "欢迎回来",
          duration: 2,
          showProgress: true,
          icon: (
            <SmileOutlined
              style={{
                color: "#108ee9",
              }}
            />
          ),
        });
    +  }, 1000);

7. token持久化

业务背景: Token数据具有一定的时效时间,通常在几个小时,有效时间内无需重新获取,而基于Redux的存储方式又是基于内存的,刷新就会丢失,为了保持持久化,我们需要单独做处理

解决方案:

获取并存储Token: Redux + LocalStroge

初始化Token : LocalStorageLocalStroge : 空字符串

7.1 封装存取方法

对于在多个模块中都使用到的方法,可以进行封装成工具函数便于复用

javascript
// src\utils\token.js
// 封装 token 的存储、获取、删除方法
const TOKEN_KEY = "token_key";

const setToken = (token) => {
  localStorage.setItem(TOKEN_KEY, token);
};
const getToken = () => {
  return localStorage.getItem(TOKEN_KEY);
};
const removeToken = () => {
  localStorage.removeItem(TOKEN_KEY);
};

export { setToken, getToken, removeToken };

7.2 实现持久化逻辑

javascript
import { getToken, removeToken, setToken as _setToken } from "@/utils/token";
const userStore = createSlice({
  // 仓库唯一标识
  name: "user",
  // 初始化数据
  initialState: {
    // 本地存储中如果没有token就设置为空字符串
    token: getToken() || "",
  },
  // 同步修改方法
  reducers: {
    // 修改token的同步方法(store + localStorage)
    setToken(state, action) {
      // action.payload:调用setToken actionCreater传递的参数
      state.token = action.payload;
      // 将token存储到本地存储中
      _setToken(action.payload);
    },
  },
});

刷新浏览器,通过Redux调试工具查看token数据 ![image.png](./React 极客园.assets/06.png)

8. 请求拦截器注入token

业务背景: Token作为用户的数据标识,在接口层面起到了接口权限控制的作用,也就是说后端有很多接口都需要通过查看当前请求头信息中是否含有token数据,来决定是否正常返回数据

token.png

拼接方式(后端决定):config.headers.Authorization = Bearer ${token}}

utils/request.js

javascript
// 请求拦截器(请求发送前做拦截,配置一些自定义请求参数)
request.interceptors.request.use((config) => {
  // 在请求头中添加token(按照后端的格式进行拼接)
  // 获取本地存储中的token
  let token = getToken();
  // 如果本地存储中有token将其添加到请求头中
  if (token) config.headers.Authorization = "Bearer " + token;
  // 返回配置对象(注意)
  return config;
});

9. 路由鉴权实现

有些页面的内容信息比较敏感,如果用户没有登录获取到有效的 token ,是没有权限访问的,根据 token 的有无控制当前路由是否可以跳转就是权限控制

业务背景:封装 AuthRoute 路由鉴权高阶组件,实现未登录拦截,并跳转到登录页面

实现思路:判断本地是否有token,如果有,就返回路由组件,否则就重定向到登录Login

高阶组件:以一个组件作为 children 参数,并返回一个组件

实现步骤

  1. components 目录中,创建 AuthRoute/index.jsx 文件(封装一个用于路由鉴权的组件)
  2. 登录时,直接渲染相应页面组件
  3. 未登录时,重定向到登录页面
  4. 修改路由配置文件 src\router\index.jsx,在需要鉴权的路由组件外包裹<AuthRoute></AuthRoute>组件(将路由组件以 children 的形式传递给 AuthRoute组件)

代码实现components/AuthRoute/index.jsx

jsx
import { getToken } from "@/utils/token";
import { Navigate } from "react-router-dom";

// 封装高阶组件(传递一个组件,返回一个组件)
// 核心逻辑:有token正常跳转,无token去登录
/* 使用:修改路由配置文件src\router\index.jsx,在需要鉴权的路由组件外包裹<AuthRoute></AuthRoute>组件
将路由组件以 children 的形式传递给 AuthRoute 组件 */
function AuthRoute({ children }) {
  // 获取token,根据token的有无判断渲染路由组件还是登录组件
  let token = getToken();
  // Navigate 重定向组件 to重定向的路由
  return <>{token ? children : <Navigate to="/login" />}</>;
}

export default AuthRoute;

src/router/index.jsx

jsx
import { createBrowserRouter } from "react-router-dom";
import Layout from "@/pages/layout";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";
import AuthRoute from "@/components/AuthRoute";

const router = createBrowserRouter([
  // 首页路由
  {
    path: "/",
    element: (
      <AuthRoute> 
        <Layout /> // 将需要进行鉴权的路由组件使用 <AuthRoute> 标签进行包裹
      </AuthRoute>
    ),
  },
  // 登录一级路由
  {
    path: "/login",
    element: <Login />,
  },
  // 404一级路由
  {
    path: "*",
    element: <NotFound />,
  },
]);

export default router;

三、Layout模块

1. 基本结构和样式重置

1.1 结构创建

![image-20240915100519824](./React 极客园.assets/image-20240915100519824.png)![image-20240915100535177](./React 极客园.assets/image-20240915100535177.png)

实现步骤

  1. 打开 antd/Layout 布局组件文档,找到合适的示例
  2. 拷贝示例代码到我们的 Layout 页面中
  3. 分析并调整页面布局

代码实现pages/Layout/index.js

jsx
import React, { useState } from "react";
import {
  DiffOutlined,
  EditOutlined,
  HomeOutlined,
  LogoutOutlined,
  MenuFoldOutlined,
  MenuUnfoldOutlined,
} from "@ant-design/icons";
import { Button, Layout, Menu } from "antd";
import "./index.scss";
import { Popconfirm } from "antd";
import { message } from "antd";
const { Header, Sider, Content } = Layout;
import logo from "@/assets/logo.png";
import classNames from "classnames";
function GeekLayout() {
  // 控制侧边栏的展开收起
  const [collapsed, setCollapsed] = useState(false);
  // 菜单数据
  const items = [
    {
      label: "首页",
      key: "1",
      icon: <HomeOutlined />,
    },
    {
      label: "文章管理",
      key: "2",
      icon: <DiffOutlined />,
    },
    {
      label: "创建文章",
      key: "3",
      icon: <EditOutlined />,
    },
  ];
  // 气泡确认框点击确定/取消按钮的回调
  const confirm = (e) => {
    console.log(e);
    message.success("Click on Yes");
  };
  const cancel = (e) => {
    console.log(e);
    message.error("Click on No");
  };
  return (
    <Layout>
      {/* 侧边栏 */}
      <Sider trigger={null} collapsible collapsed={collapsed} reverseArrow>
        {/* logo */}
        <div className="demo-logo-vertical">
          <img
            src={logo}
            className={classNames("logo", collapsed && "collapsed")} 
            // 使用动态类名控制展开与折叠时图片大小
            alt="logo"
          />
        {/* 菜单 */}
        <Menu
          theme="dark"  // 主题色为暗色
          mode="inline" // 内嵌模式
          defaultSelectedKeys={["1"]} // 默认选中菜单项的key
          items={items}  // 菜单内容
        />
      </Sider>
      {/* 右侧内容 */}
      <Layout>
        {/* 头部 */}
        <Header className="header">
          {/* 侧边栏切换按钮 */}
          <Button
            type="text"
            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
            onClick={() => setCollapsed(!collapsed)}
            className="trigger"
          />
          {/* 头部右侧用户信息 */}
          <div className="user-info">
            <div className="user-name">xxxx</div>
            <div className="logout">
              {/* 气泡确认框 */}
              <Popconfirm
                title="提示"
                description="你确定要退出登录吗?"
                onConfirm={confirm}
                onCancel={cancel}
                okText="确定"
                cancelText="取消"
              >
                <LogoutOutlined /> 退出
              </Popconfirm>
            </div>
          </div>
        </Header>
        <Content className="content">Content</Content>
      </Layout>
    </Layout>
  );
}
export default GeekLayout;

pages/Layout/index.scss

scss
.ant-layout {
  width: 100%;
  min-height: 100vh;
  .logo {
    width: 200px;
    margin-left: -10px;
    height: 60px;
    transition: all 0.2s; // 添加过渡动画
    &.collapsed {
      transition: all 0.2s;
      height: 30px;
      width: 100px;
    }
  }
  .header {
    padding: 0;
    background-color: #fff;
    height: 54px;
    .trigger {
      font-size: 16px;
      width: 54px;
      height: 54px;
    }
    .user-info {
      float: right;
      display: flex;
      height: 54px;
      justify-content: space-between;
      align-items: center;
      color: #001529;
      .logout {
        cursor: pointer;
        margin: 0 20px 0 15px;
      }
    }
  }
  .content {
    margin: 24px 16px;
    padding: 24px;
    min-height: 280px;
    background-color: #ffffff;
    border-radius: 10px;
  }
}

1.2 样式reset

使用 normalize.css来重置样式

  1. 安装 pnpm i normalize.css

  2. 使用

    在主文件 main.jsx中导入 normalize.css(重置标签样式)

    import "normalize.css";

2. 二级路由配置

image-20240908105146998

使用步骤

  1. pages 目录中,分别创建:Home(数据概览)Article(内容管理)Publish(发布文章)页面文件夹
  2. 分别在三个文件夹中创建 index.jsx 并创建基础组件后导出
  3. router/index.js 中配置嵌套子路由,在Layout中配置二级路由出口 Outlet

代码实现pages/Home/index.js

jsx
const Home = () => {
  return <div>Home</div>
}
export default Home

pages/Article/index.js

jsx
const Article = () => {
  return <div>Article</div>
}
export default Article

pages/Publish/index.js

jsx
const Publish = () => {
  return <div>Publish</div>
}
export default Publish

router/index.js

jsx
const router = createBrowserRouter([
  // 首页路由
  {
    path: "/",
    element: (
      // <AuthRoute>组件用于路由鉴权
      <AuthRoute>
        <GeekLayout />
      </AuthRoute>
    ),
    // 二级子路由
    children: [
      {
        // path: "home",
        index: true, // 设置为默认二级路由
        element: <Home />,
      },
      {
        path: "article",
        element: <Article />,
      },
      {
        path: "publish",
        element: <Publish />,
      },
    ],
  },
  // 登录一级路由
  {
    path: "/login",
    element: <Login />,
  },
  // 404一级路由
  {
    path: "*",
    element: <NotFound />,
  },
]);

配置二级路由出口

jsx
<Content className="content">
  {/* 二级路由展示区域 */}
  <Outlet />
</Content>

3. 路由菜单点击交互实现

image-20240908142557384

3.1 点击菜单跳转路由

思路分析:

  1. 左侧菜单要和路由形成 一一对应的关系
  2. 点击时拿到路由路径调用路由方法跳转

实现步骤:

  1. items 中每一项key的值设置为对应菜单项的 path
  2. Menu组件添加 onClick事件,在事件回调参数中获取当前菜单项的 key
  3. 通过 navigate() 进行路由跳转
jsx
import { Outlet, useNavigate } from 'react-router-dom'

const items = [
  {
    label: "首页", // 菜单标签名
    key: "/", // 菜单项唯一标识
    icon: <HomeOutlined />, // 菜单图标
  },
  {
    label: '文章管理',
    key: '/article',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '/publish',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  const navigate = useNavigate()
  // 点击导航菜单的事件回调
  const menuClick = (route) => {
    // route.key 当前点击MenuItem的key值(path)
    navigate(route.key); // 二级路由切换
  };
  return (
     {/* 菜单 */}
        <Menu
          theme="dark"
          mode="inline"
          defaultSelectedKeys={["/"]}
          items={items}
          onClick={menuClick}
        />
  )
}
export default GeekLayout

3.2 根据当前路由路径高亮菜单

问题:

  1. 在地址栏中输入路径进行跳转,对应菜单导航项不会自动高亮
  2. 刷新页面时,当前激活的菜单项会变为默认项,不与当前展示路由组件对应

解决方案:

  1. 获取到当前激活的菜单项的路径(使用 useLocation hook函数 获取地址栏中当前路径)
  2. 设置 Menu组件的 selectedKeys属性( 当前选中的菜单项 key 数组)为当前当前路径

Tips:react-router提供的钩子函数 useLocation调用返回当前location对象

tsx
const GeekLayout = () => {
  // 省略部分代码
  // 获取当前路由地址(将当前路由路径作为Menu组件selectedKeys的值)
+  const location = useLocation();
+  let selectedKey = location.pathname;
  
  return (
    <Menu
       theme="dark"
       mode="inline"
+       selectedKeys={[selectedKey]} // 当前选中的菜单项的key组成的数组
       items={items}
       onClick={menuClick}
    />
  )
}

4. 展示个人信息

![image-20240908151015833](./React 极客园.assets/image-20240908151015833.png)

实现步骤

  1. Reduxstore中存储用户信息,并编写修改用户信息的同步方法,和获取用户信息的异步方法
  2. Layout组件的 useEffect钩子函数中触发异步获取用户数据的action(获取数据并存储)
  3. Layout组件使用使用 useSelector钩子函数获取store中的数据进行用户名的渲染

代码实现

用户仓库逻辑src\store\modules\user.js

javascript
import request from "@/utils/request";
import { getToken, removeToken, setToken as _setToken } from "@/utils/token";
import { createSlice } from "@reduxjs/toolkit";

const userStore = createSlice({
  // 仓库唯一标识
  name: "user",
  // 初始化数据
  initialState: {
    // 本地存储中如果没有token就设置为空字符串
    token: getToken() || "",
    // 用户信息
    userInfo: {},
  },
  // 同步修改方法
  reducers: {
    // 修改token的同步方法(store + localStorage)
    setToken(state, action) {
      // action.payload:调用setToken actionCreater传递的参数
      state.token = action.payload;
      // 将token存储到本地存储中
      _setToken(action.payload);
    },
    // 修改用户信息的同步方法
    setUserInfo(state, action) {
      state.userInfo = action.payload;
    },
  },
});

// 解构出actionCreater
const { setToken, setUserInfo } = userStore.actions;

// 异步登录的方法
const fetchLogin = (loginParams) => {
  return async (dispatch) => {
    // 发请求获取token
    const result = await request.post("/authorizations", loginParams);
    // 提交同步方法存储token
    dispatch(setToken(result.data.token));
  };
};
// 异步获取用户信息的方法
const fetchUserInfo = () => {
  return async (dispatch) => {
    const result = await request.get("/user/profile");
    // 将用户信息存储到仓库中
    dispatch(setUserInfo(result.data));
  };
};

// 暴露actionCreater
export { fetchLogin, fetchUserInfo };

// 获取reducer函数并导出
const reducer = userStore.reducer;
export default reducer;

获取仓库中用户数据并展示pages/Layout/index.js

jsx
function GeekLayout() {
  .....
  // 获取dispatch方法用于提交actionCreater
  const dispatch = useDispatch();
  // 获取仓库中的用户信息
  const { userInfo } = useSelector((state) => state.user);
  ....
  // 组件渲染完毕获取用户信息
  useEffect(() => {
    dispatch(fetchUserInfo()); // 调用仓库中的异步action获取用户数据
  }, [dispatch]);
  return (
   <>
    .....
    <div className="user-info">
    {/* 展示用户名 */}
    <div className="user-name">{userInfo.name}</div>

5. 退出登录实现

image-20240908151205706

实现步骤

  1. 使用 Popconfirm组件为退出按钮添加气泡确认框并设置确认回调事件
  2. store/userStore.js 中新增退出登录的action函数,在其中清除 store中的tokenuserInfo并移除 localStorage中的token
  3. 在回调事件中使用 dispatch 调用userStore中的退出action
  4. 清除用户信息成功,返回登录页面并提示用户退出登录成功

代码实现store/modules/user.js

javascript
.....
const userStore = createSlice({
  name: 'user',
  // 数据
  initialState: {
    token: getToken() || '',
    userInfo: {}
  },
  // 同步修改方法
  reducers: {
    .......
+    // 清除用户信息的同步方法
+    clearUserInfo(state) {
+      // 清除用户信息和token
+      state.userInfo = {};
+      state.token = "";
+      // 清除本地存储中的token
+      removeToken();
+    },
  }
})

// 解构出actionCreater
+const { setToken, setUserInfo, clearUserInfo } = userStore.actions;
// 暴露actionCreater
+export { fetchLogin, fetchUserInfo, clearUserInfo };
......

pages/Layout/index.js

jsx
const GeekLayout = () => {
  // 气泡确认框点击确定按钮的回调
  const logout = () => {
    // 调用仓库中的清除用户信息的action
    dispatch(clearUserInfo());
    // 跳转到登录页面
    navigate("/login");
    // 提示退出登录成功
    message.success("退出登录成功");
  };

  return (
   <>
    .....
    <div className="logout">
      {/* 气泡确认框 */}
      <Popconfirm
        title="提示"
        description="你确定要退出登录吗?"
        onConfirm={confirm}
        okText="确定"
        cancelText="取消"
      >
        <LogoutOutlined /> 退出
      </Popconfirm>
    </div>
   </>
  )
}

6. 处理Token失效

业务背景:如果用户一段时间不做任何操作,到时之后应该清除所有过期用户信息跳回到登录

  1. 什么是 Token 失效?

    为了用户的安全和隐私考虑,在用户长时间未在网站中做任何操作规定的失效时间到达之后,当前的 Token 就会失效,一旦失效,不能再作为用户令牌标识请求隐私数据

  2. 如何知道token失效?

    请求接口后端会返回状态码 401

  3. Token失效前端应该做什么?

    axios响应拦截器中监控状态码是否为 401,如果状态码为 401清除失效 token,跳转登录页面

Tips:登录成功后会返回一个刷新token,可以使用刷新token去重新获取token(在响应拦截器中实现)

javascript
// src\utils\request.js
// 响应拦截器(在响应返回到客户端前做拦截,处理返回的数据)
request.interceptors.response.use(
  // 2xx 范围内的状态码都会触发该函数
  (response) => {
    // 简化返回数据(axios 会对返回的数据进行二次封装)
    return response.data;
  },
  // 超出 2xx 范围的状态码都会触发该函数。
  (error) => {
    // 判断响应状态码是否为 401(token失效)
    if (error.response.status === 401) {
      // 清除失效token返回登录页
      removeToken();
      // 跳转到登录页
      router.navigate("/login");
      // 刷新页面(防止页面为刷新,清除store中的数据)
      window.location.reload();
    }
    // 提示错误信息
    message.error(error.response.data.message);
    // 返回失败的 Promise 对象 终止请求(axios返回一个Promise)
    return Promise.reject(error);
  }
);

7. 首页Home图表展示

home.png

7.1 Echats基本使用

  1. 安装echarts pnpm i echarts

  2. 基础使用步骤

jsx
// 1. 导入echarts包
import * as echarts from "echarts";
import { useRef } from "react";
import { useEffect } from "react";

function Home() {
  // 2. 获取要渲染的dom节点
  const chartRef = useRef(null); // chartRef.current DOM对象
  // 保证dom可用(图标要渲染在dom节点上),才进行图标的渲染
  useEffect(() => {
    // 3. 初始化echarts实例
    const myChart = echarts.init(chartRef.current);
    // 3. 配置数据
    const option = {
      xAxis: {
        type: "category",
        data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
      },
      yAxis: {
        type: "value",
      },
      series: [
        {
          data: [120, 200, 150, 80, 70, 110, 130],
          type: "bar",
        },
      ],
    };
    // 4. 使用图表参数完成图表渲染
    myChart.setOption(option);
  }, []);
  return (
    <>
      {/* 用于渲染图表的dom节点(必须有宽高) */}
      <div ref={chartRef} style={{ width: "500px", height: "400px" }}></div>
    </>
  );
}

export default Home;

注意:

  1. 保证渲染的**dom可用**(图标要渲染在dom节点上),在 useEffect()钩子函数中进行渲染
  2. 渲染图表的节点必须设置有宽高

7.2 Echarts组件封装

对功能相同的组件进行封装主要解决了组件复用问题(封装为公共组件)

图表组件抽象:

  1. 把功能代码都放到组件中
  2. 把可变的部分封装成props参数
jsx
// src\pages\Layout\Home\components\BarChart.jsx
/* 封装柱状图组件
1. 把功能代码都放到组件中
2. 把可变的部分封装成props参数 */

// 导入echarts包
import * as echarts from "echarts";
import { useRef, useEffect } from "react";

function BarChart({ title, xData, yData,yName }) {
  // 获取要渲染的dom节点
  const chartRef = useRef(null);
  // 保证dom可用(图标要渲染在dom节点上),才进行图标的渲染
  useEffect(() => {
    // 初始化echarts实例
    const myChart = echarts.init(chartRef.current);
    // 配置数据
    const option = {
      title: {
        text: title,
      },
      xAxis: {
        type: "category",
        data: xData,
        name:"名称"
      },
      yAxis: {
        type: "value",
        name:yName
      },
      series: [
        {
          data: yData,
          type: "bar",
        },
      ],
    };
    // 使用图表参数完成图表渲染
    myChart.setOption(option);
  }, []);
  return (
    <>
      {/* 用于渲染图表的dom节点(必须有宽高) */}
      <div ref={chartRef} style={{ width: "450px", height: "400px" }}></div>
    </>
  );
}

export default BarChart;

图表组件使用:

​ 不同的使用场景传入不同的 props 参数

jsx
// src\pages\Layout\Home\index.jsx
import BarChart from "./components/BarChart";
import "./index.scss";
function Home() {
  return (
    <>
      {/* 图表组件 */}
      <div className="chart">
        <div className="useChart">
          <BarChart
            title={"三大前端框架使用率"}
            xData={ ["Vue", "Angular","React"]}
            yData={[515, 332, 2446]}
            yName="使用率(万)"
          />
        </div>
        <div className="commentChart">
          <BarChart
            title={"三大前端框架好评度"}
            xData={["React", "Vue", "Angular"]}
            yData={[900, 446, 132]}
            yName="好评度(万)"
          />
        </div>
      </div>
    </>
  );
}

export default Home;
image-20240908224640658

8. API 模块封装

问题:当前的接口请求放到了功能实现的位置,没有在固定的模块内维护,不便于复用和后期维护

解决思路:把项目中的所有接口按照业务模块以函数的形式统一封装到apis模块中

实现步骤:

  1. src 目录下创建 apis文件夹
  2. 以不同的功能模创建不同的文件
  3. 在文件中封装相关请求函数
image-20240908225639486

封装与用户相关的请求函数

js
// src\apis\user.js
/**
 * 用户登录的接口方法
 * @param loginData 登录参数
 * @returns Promise
 */
export const loginAPI = (loginData) =>
  request.post("/authorizations", loginData);

/**
 * 获取用户信息的接口方法
 * @returns 用户信息
 */
export const getUserInfoAPI = () => request.get("/user/profile");

封装的接口函数名格式一般为 xxxAPI

使用封装好的请求函数

jsx
// src\store\modules\user.js
// 异步登录的方法
const fetchLogin = (loginParams) => {
  return async (dispatch) => {
    // 发请求获取token
+    const result = await loginAPI(loginParams);
    // 提交同步方法存储token
    dispatch(setToken(result.data.token));
  };
};
// 异步获取用户信息的方法
const fetchUserInfo = () => {
  return async (dispatch) => {
+    const result = await getUserInfoAPI();
    // 将用户信息存储到仓库中
    dispatch(setUserInfo(result.data));
  };
};

调用封装的接口函数,返回的是一个 Promise 可以使用 .then.catchasync await获取返回结果

四、发布文章模块

整体效果:

![image-20240915125305016](./React 极客园.assets/image-20240915125305016.png)

1. 实现基础文章发布

1.1 创建基础结构

![image-20240909191351375](./React 极客园.assets/image-20240909191351375.png)

使用到的 antd的组件有:

  • 面包屑导航组件 Breadcrumb
  • 表单组件 Form
  • 输入框组件 Input
  • 下拉框组件 Select—Option
  • 按钮组件 Button
jsx
import { Button, Card, Input, Select, Form, Breadcrumb } from "antd";
import { Link } from "react-router-dom";

function Publish() {
  // 面包屑导航数据
  const breadcrumbData = [
    {
      title: <Link to="/">首页</Link>,
    },
    {
      title: "发布文章",
    },
  ];
  // 下拉列表框的数据
  const selectOptions = [
    {
      value: "0",
      label: "推荐",
    },
    {
      value: "1",
      label: "JavaScript",
    },
  ];
  return (
    <>
      <div className="publish">
        {/* 面包屑导航 */}
        <Breadcrumb items={breadcrumbData} />
        {/* 表单 */}
        <Form
          labelCol={{
            span: 6,
          }}
          wrapperCol={{
            span: 10,
          }}
          initialValues={{
            channel_id: "0",
          }}
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: "请输入文章标题" }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: "请选择文章频道" }]}
          >
            {/* 下拉选择列表框 */}
            <Select
              style={{
                width: 400,
              }}
              options={selectOptions}
            />
          </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: "请输入文章内容" }]}
          ></Form.Item>
          <Form.Item
            wrapperCol={{
              offset: 6,
            }}
          >
            <Button type="primary" htmlType="submit">
              发布文章
            </Button>
          </Form.Item>
        </Form>
      </div>
    </>
  );
}

export default Publish

1.2 准备富文本编辑器

实现步骤

  1. 安装富文本编辑器
  2. 导入富文本编辑器组件以及样式文件
  3. 渲染富文本编辑器组件
  4. 调整富文本编辑器的样式

代码落地

  1. 安装 react-quillpnpm i react-quill

  2. 导入组件以及样式并渲染src\pages\Layout\Publish\index.jsx

jsx
+ import ReactQuill from 'react-quill' // 组件
+ import 'react-quill/dist/quill.snow.css' // 样式文件

const Publish = () => {
  return (
    // ...
    <Form
      labelCol={{ span: 4 }}
      wrapperCol={{ span: 16 }}
    >
      <Form.Item
        label="内容"
        name="content"
        rules={[{ required: true, message: '请输入文章内容' }]}
      >
+        {/* 富文本编辑器 */}
+        <ReactQuill
+          className="publish-quill" // 用于调整富文本编辑器的大小
+          theme="snow"
+          placeholder="请输入文章内容"
+        />
      </Form.Item>
    </Form>
  )
}
  1. 调整样式pages/Publish/index.scss 审查找到对应组件标签的类名,进行样式修改
css
.publish {
  position: relative;
  .publish-quill {
    width: 650px;
    .ql-editor {
      min-height: 150px;
    }
  }
}

1.3 频道数据获取

image.png

实现步骤

  1. 封装获取频道列表数据的接口函数
  2. 使用useState初始化数据和修改数据的方法
  3. useEffect中调用接口并保存数据
  4. 使用数据渲染对应模版

代码实现

src\apis\article.js

js
/**
 * 封装获取频道列表的接口方法
 * @returns []
 */
export const getChannelListAPI = () => request.get("/channels");

src\pages\Layout\Publish\index.jsx

jsx
function Publish() {
+  // 下拉列表框频道数据
+  const [channelList, setChannelList] = useState([]);
.....
+  // 获取频道列表数据并存储
+  const getChannelList = async () => {
+    const result = await getChannelListAPI();
+    setChannelList(
+      result.data.channels.map((item) => { // 将后端返回的数据格式变为 options 所需的格式
+        return {
+          label: item.name, // 下拉列表框显示的文字
+          value: item.id, // 下拉列表框的值
+        };
+      })
+    );
+  };

+  // 组件渲染完毕调用方法
+  useEffect(() => {
+    getChannelList();
+  }, []);
  return (
    <>
      <div className="publish">
         ......
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: "请选择文章频道" }]}
          >
            {/* 下拉选择列表框 */}
            <Select
              style={{
                width: 400,
              }}
+              options={channelList}
            />
          </Form.Item>
         .....
      </div>
    </>
  );
}

1.4 发布文章

需求:收集表单数据提交表单

实现步骤:

  1. 封装接口函数
  2. Form 组件的 onFinish 事件回调中收集表单数据(前提:Button组件绑定 htmlType="submit"属性,且每个 Form.Item组件绑定了name属性)
  3. 按照接口文档整理收集到的表单数据
  4. 调用接口函数,验证是否成功
  5. 发布成功,进行提示并跳转到文章管理页面

Form组件的 onFinish事件回调触发的时机:点击了绑定有 htmlType="submit"Button按钮,且表单校验全部通过

From组件的 onFinish事件回调中收集数据的 keyForm.Itemname属性决定

js
// src\apis\article.js
import request from "@/utils/request";

/**
 * 封装发布文章的接口方法
 * @param {*} data   要发布的文章内容
 * @param {*} query  文章状态: true-草稿, false-发布(默认)
 * @returns 发布文章的id
 */
export const publishArticleAPI = (data, query = false) =>
  request.post(`/mp/articles?draft=${query}`, data);
jsx
//src\pages\Layout\Publish\index.jsx
function Publish() {
......
  // 点击发布按钮提交表单且数据验证成功后回调事件
  const pubArticle = async (values) => {
    // 整理请求参数
    const params = {
      ...values,
      cover: { type: 0, images: [] }, // 默认文章封面为无图模式
    };
    // 调用接口
    const result = await publishArticleAPI(params);
    if (result.data.id) {
      message.success("发布成功");
      // 跳转文章列表页面
      setTimeout(() => {
        navigate("/article");
      }, 1000);
    } else {
      message.error("发布失败");
    }
  };
 return (
    <>
      <div className="publish">
        {/* 面包屑导航 */}
        <Breadcrumb items={breadcrumbData} />
        {/* 表单 */}
        <Form
          labelCol={{
            span: 5,
          }}
          wrapperCol={{
            span: 10,
          }}
          initialValues={{
            channel_id: 0,
          }}
+          onFinish={pubArticle}
        >
       ......
image-20240909204729703

2. 上传封面实现

2.1 准备上传结构

image.png
tsx
<Form.Item label="封面">
  <Form.Item name="type">
    <Radio.Group>
      <Radio value={1}>单图</Radio>	
      <Radio value={3}>三图</Radio>
      <Radio value={0}>无图</Radio>
    </Radio.Group>
  </Form.Item>
  <Upload
    listType="picture-card" // 上传列表的样式
    showUploadList // 是否展示文件列表
  >
    <div style={{ marginTop: 8 }}>
      <PlusOutlined />
    </div>
  </Upload>
</Form.Item>

2.2 实现基础上传

实现步骤

  1. Upload 组件添加 action 属性,配置封面图片上传接口地址(完整地址)
  2. Upload组件添加 name属性(发到后台的文件参数名), 接口要求的字段名
  3. Upload 添加 onChange事件,在事件回调中中拿到当前上传图片数据

onChange:上传文件改变时的回调,上传每个阶段都会触发该事件(删除图片时也会触发且file.status=removed

代码实现

jsx
import { useState } from 'react'

const Publish = () => {
  // 已经上传的文件列表 UploadFile[] (内部有服务器返回的图片url)
  const [imageList, setImageList] = useState([]);
  // 上传文件状态改变时的回调,上传每个阶段都会触发该事件(删除图片时也会触发且file.status=removed)
  const handlerChange = ({ file, fileList }) => {
    // file:当前操作的文件对象  fileList:当前的文件列表
    // 不断修改fileList实现当前上传图片状态的更新
    setImageList(fileList);
  });
  return (
   	<Form.Item label="封面">
      <Radio.Group>
        <Radio value={0}>无图</Radio>
        <Radio value={1}>单图</Radio>
        <Radio value={3}>三图</Radio>
       </Radio.Group>
        <Upload
          name="image" // 发到后台的文件参数名
          className="image-uploader"
          listType="picture-card" // 上传列表的样式
          action="http://geek.itheima.net/v1_0/upload" // 上传的地址
          fileList={imageList} //已经上传的文件列表
        >
           <PlusOutlined />
       </Upload>
    </Form.Item>
  )
}

3. 切换图片Type

image-20240910170849801image-20240910170918995

需求:

  1. 点击单选框时拿到当前的类型 value
  2. 根据单选框的值动态渲染上传组件(大于零时才显示)
  3. 动态显示上传图片按钮

实现步骤:

  1. 使用 useState 声明 imageType用于存储不同封面图片个数的 value
  2. Radio.Group 组件绑定 valueonChange事件,在事件回调中获取当前选中单选钮的 value值并存储到 imageType
  3. 使用条件渲染根据 imageType的值动态渲染上传组件
  4. 使用条件渲染根据 imageType(图片总数)和 imageList.length(以上传图片数)作比较动态渲染上传按钮
jsx
const Publish = ()=>{
+  // 上传图片个数,用于upload组件上传图片个数限制
+  const [imageType, setImageType] = useState(0);
  // 已经上传的文件列表 UploadFile[]
  const [imageList, setImageList] = useState([]);

  // 改变封面图片个数的事件回调
+  const imageTypeChange = (e) => {
+    // 修改上传图片的个数
+    setImageType(e.target.value);
+  };
  
  return (
    .....
    <Form.Item label="封面">
+     <Radio.Group value={imageType} onChange={imageTypeChange}>
        <Radio value={0}>无图</Radio>
        <Radio value={1}>单图</Radio>
        <Radio value={3}>三图</Radio>
      </Radio.Group>
+     {/* 上传图片组件(imageType>0展示) */}
+     {imageType > 0 && (
        <Upload
          name="image" // 发到后台的文件参数名
          className="image-uploader"
          listType="picture-card" // 上传列表的样式
          action="http://geek.itheima.net/v1_0/upload" // 上传的地址
          fileList={imageList} //已经上传的文件列表
        >
+         {/* 当已上传图片数量小于imageType时显示上传按钮 */}
+         {imageList.length < imageType && <PlusOutlined />}
        </Upload>
      )}
    </Form.Item>
  )
}

4. 限制上传图片的格式和大小

需求:

  1. 当上传的图片类型为 jpg/png且大小不超过 2M才能上传成功,否则提示上传失败

实现步骤:

  1. Upload组件添加 beforeUpload钩子函数(上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传,若停止上传在 onChange事件的回调函数中接收的 file参数无 status属性)
  2. beforeUpload回调中对文件的大小和格式进行判断,如果不满足条件返回 false停止上传
  3. onChange事件回调中判断 file.status是否为 undefined,若是将 fileLsit中最后一项删除(不显示不符合上传条件的图片)
jsx
 const Publish = () => {
  // 上传图片个数,用于upload组件上传图片个数限制
  const [imageType, setImageType] = useState(0);
  // 已经上传的文件列表 UploadFile[]
  const [imageList, setImageList] = useState([]);
+  // 限制上传图片的格式和大小
+  const beforeUpload = (file) => {
+    const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png";
+    if (!isJpgOrPng) {
+      message.error("只可以上传JPG/PNG格式的图片!");
+    }
+    const isLt2M = file.size / 1024 / 1024 < 2;
+    if (!isLt2M) {
+      message.error("上传图片大小不能超过2MB!");
+    }
+    // 返回true表示验证通过
+    return isJpgOrPng && isLt2M;
+  };
  // 上传文件状态改变时的回调,上传每个阶段都会触发该事件(删除图片时也会触发且file.status=removed)
  const handlerChange = ({ file, fileList }) => {
    // file:当前操作的文件对象  fileList:当前的文件列表
    // 不断修改fileList实现当前上传图片状态的更新
+    setImageList(fileList);
    // console.log(file.status); loading/error/done
    // beforeUpload 拦截的文件没有status状态属性
+    if (file.status === undefined) {
+      // 图片在beforeUpload中被阻止上传,则将fileList中不符合要求元素删除
+      setImageList(fileList.filter((item) => item.uid !== file.uid));
+    }
  };
  return (
   .....
   <Form.Item label="封面">
      <Radio.Group value={imageType} onChange={imageTypeChange}>
        <Radio value={0}>无图</Radio>
        <Radio value={1}>单图</Radio>
        <Radio value={3}>三图</Radio>
      </Radio.Group>
      {/* 上传图片组件(imageType>0展示) */}
      {imageType > 0 && (
        <Upload
          name="image" // 发到后台的文件参数名
          className="image-uploader"
          listType="picture-card" // 上传列表的样式
          action="http://geek.itheima.net/v1_0/upload" // 上传的地址
+         fileList={imageList} //已经上传的文件列表
+         beforeUpload={beforeUpload} // 上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传
          onChange={handlerChange} // 上传文件改变时的回调,上传每个阶段都会触发该事件
        >
          {/* 当上传数量小于imageType时显示上传按钮 */}
          {imageList.length < imageType && <PlusOutlined />}
        </Upload>
      )}
    </Form.Item>

图片错误显示问题:虽然在 beforeUpload回调中返回 false阻止了文件的上传,但不符合要要求的图片依旧会添加到 fileList(已经上传的文件列表)中显示在已上传的图片列表中

解决方案:在 onChange事件的回调函数中,判断当上传图片的状态,file.status === undefined表示文件不符合要求被阻止上传,此时需要将 fileList中不符合要求的一项过滤即可

  • file.status === "done"表示文件正常上传且上传成功

  • beforeUpload 拦截的文件没有 status 状态属性

5. 控制最大上传图片数量

实现步骤

  1. 通过 Upload组件的 maxCount 属性限制图片的上传图片数量
jsx
<Form.Item label="封面">
    <Radio.Group value={imageType} onChange={imageTypeChange}>
      <Radio value={0}>无图</Radio>
      <Radio value={1}>单图</Radio>
      <Radio value={3}>三图</Radio>
    </Radio.Group>
    {/* 上传图片组件(imageType>0展示) */}
    {imageType > 0 && (
      <Upload
        name="image" // 发到后台的文件参数名
        className="image-uploader"
        listType="picture-card" // 上传列表的样式
+       maxCount={imageType} // 限制上传数量
        action="http://geek.itheima.net/v1_0/upload" // 上传的地址
        fileList={imageList} //已经上传的文件列表
        multiple={imageType > 1} // 开启多选
        beforeUpload={beforeUpload} // 上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传
        onChange={handlerChange} // 上传文件改变时的回调,上传每个阶段都会触发该事件
      >
        {/* 当上传数量小于imageType时显示上传按钮 */}
        {imageList.length < imageType && <PlusOutlined />}
      </Upload>
    )}
  </Form.Item>

6.移除+预览功能

image-20240910204100180image-20240911170254071

  1. 点击图片上的删除按钮,会自动触发 onChange事件,在事件回调中将最新的 fileList赋值给了 imageList,即实现滤掉imageList中的该图片
  2. 准备渲染预览图片的结构,使用状态数据控制其显示与隐藏,并使用状态数据保存当前需要预览图片的url
  3. Upload组件添加 onPreview 事件绑定在点击文件链接或预览图标时的回调,在回调中获取需要预览图片的url和显示预览图片的结构
jsx
function Publish() {
  // 已经上传的文件列表 UploadFile[]
  const [imageList, setImageList] = useState([]);
  // 控制是否预览图片
  const [previewOpen, setPreviewOpen] = useState(false);
  // 预览图片地址
  const [previewImage, setPreviewImage] = useState("");
   .....	
  // 点击图片预览的回调事件
  const handlePreview = async (file) => {
    setPreviewImage(file.response.data.url); // 设置要预览图片地址
    setPreviewOpen(true); // 显示预览图片结构
  };
 return (
    ....
   <Form.Item label="封面">
    <Radio.Group value={imageType} onChange={imageTypeChange}>
      <Radio value={0}>无图</Radio>
      <Radio value={1}>单图</Radio>
      <Radio value={3}>三图</Radio>
    </Radio.Group>
    {/* 上传图片组件(imageType>0展示) */}
    {imageType > 0 && (
      <Upload
        name="image" // 发到后台的文件参数名
        className="image-uploader"
        listType="picture-card" // 上传列表的样式
        maxCount={imageType} // 限制上传数量
        action="http://geek.itheima.net/v1_0/upload" // 上传的地址
        fileList={imageList} //已经上传的文件列表
        multiple={imageType > 1} // 开启多选
        beforeUpload={beforeUpload} // 上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传
        onChange={handlerChange} // 上传文件改变时的回调,上传每个阶段都会触发该事件
+       onPreview={handlePreview} // 点击图片预览的回调
      >
        {/* 当上传数量小于imageType时显示上传按钮 */}
        {imageList.length < imageType && <PlusOutlined />}
      </Upload>
      // 预览图片
    )}
    {/* 预览图片 */}
    {previewImage && (
      <Image
        wrapperStyle={{
          display: "none",
        }}
        preview={{
          visible: previewOpen, // 是否显示
          onVisibleChange: (visible) => setPreviewOpen(visible),
          afterOpenChange: (visible) => !visible && setPreviewImage(""),
        }}
        src={previewImage} // 预览图片的地址
      />
    )}
  </Form.Item>

7. 暂存图片列表实现

业务描述 如果当前为三图模式,已经完成了上传,选择单图只显示一张,再切换到三图继续显示三张,该如何实现?

实现思路 通过一个变量 cacheImageList 临时存储 imageList 的属性值,根据选择图片个数更新 imageList的值

实现步骤

  1. 通过 useState 创建一个状态 cacheImageList值与 imageList的相同
  2. 如果是单图模式,就从 cacheImageList 里取第一张图,存入imageList
  3. 如果是三图模式,就把 cacheImageList 里所有的图片,存入imageList

代码实现

jsx
const Publish = () => {
  ......
  // 已经上传的文件列表 UploadFile[]
  const [imageList, setImageList] = useState([]);
+  // imageList的副本(上传阶段保存同步(相同)),用于切换图片个数使用
+  const [cacheImageList, setCacheImageList] = useState([]);
  ...
  // 改变封面图片个数的事件回调
  const imageTypeChange = (e) => {
    // 修改上传图片的个数
    setImageType(e.target.value);
    /* 修改imageList的值
    无图,imageList为空
    单图,imageList截取cacheImageList第一张展示
    三图,imageList取cacheImageList所有图片展示 */
+    if (e.target.value !== 0) {
+      e.target.value === 1
+        ? setImageList(cacheImageList[0] ? [cacheImageList[0]] : [])
+        : setImageList(cacheImageList);
+    }else {
+      setImageList([]);
+    }
+  };
   ...
  // 上传文件状态改变时的回调,上传每个阶段都会触发该事件(删除图片时也会触发且file.status=removed)
  const handlerChange = ({ file, fileList }) => {
    // file:当前操作的文件对象  fileList:当前的文件列表
    // 不断修改fileList实现当前上传图片状态的更新
+    setImageList(fileList);
+    setCacheImageList(fileList); // 当上传图片列表发生改变时修改CacheImage的值(增加/删除)
    // console.log(file.status); loading/error/done
    // beforeUpload 拦截的文件没有status状态属性
    if (file.status === undefined) {
      // 图片在beforeUpload中被阻止上传,则将fileList中不符合要求元素删除
      setImageList(fileList.filter((item) => item.uid !== file.uid));
+      setCacheImageList(fileList.filter((item) => item.uid !== file.uid));
    }
  };
  return 
    <>
    <Upload
    name="image" // 发到后台的文件参数名
    className="image-uploader"
    listType="picture-card" // 上传列表的样式
    maxCount={imageType} // 限制上传数量
    action="http://geek.itheima.net/v1_0/upload" // 上传的地址
+   fileList={imageList} //已经上传的文件列表
    multiple={imageType > 1} // 开启多选
    beforeUpload={beforeUpload} // 上传文件之前的钩子,参数为上传的文件,若返回 false 则停止上传
    onChange={handlerChange} // 上传文件改变时的回调,上传每个阶段都会触发该事件
    onPreview={handlePreview} // 点击图片预览的回调
  >
    {/* 当上传数量小于imageType时显示上传按钮 */}
    {imageList.length < imageType && <PlusOutlined />}
  </Upload>
}

注意:需要给Upload组件添加fileList属性,达成受控的目的

8. 发布带封面的文章

  1. 点击发布按钮校验图片类型和数量是否吻合
  2. 整理请求参数,将 imageList数组由 UploadFile[] 格式转换为 string[]格式
  3. 调用接口方法发送请求
  4. 成功提示+页面跳转
jsx
 // 点击发布按钮提交表单且数据验证成功后回调事件
  const pubArticle = async (values) => {
 +   // 1.校验图片类型和数量是否吻合
 +   if (imageList.length !== imageType)
 +     return message.error("图片类型和数量不匹配");
    // 2.整理请求参数
    const params = {
      ...values,
+      cover: {
+        type: imageType,
+        images: imageList.map((item) => item.response.data.url),
+      }, // 默认文章封面为无图模式
    };
    // 调用接口
    const result = await publishArticleAPI(params);
    if (result.data.id) {
      message.success("发布成功");
      // 跳转文章列表页面
      setTimeout(() => {
        navigate("/article");
      }, 1000);
    } else {
      message.error("发布失败");
    }
  };

五、文章列表模块

1. 静态结构创建

1.1 筛选区结构搭建

image.png
  1. 设置RangePicker日期范围选择框选择中文 (国际化配置

    1. 安装 dayjspnpm i dayjs

    2. 全局配置国际化(中文):main.jsx

      jsx
      import zhCN from 'antd/locale/zh_CN';
      import 'dayjs/locale/zh-cn';
      import { ConfigProvider } from "antd";
      
      return (
        <ConfigProvider locale={zhCN}>
          <App />
        </ConfigProvider>
      );
  2. 初始化表单数据

    1. 为每个 Form.Item组件添加 name属性(收集表单项数据)

    2. Form组件添加 initialValues属性,配置默认值

      jsx
      <Form initialValues={{ status: "" }} >

代码实现

jsx
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'

const { Option } = Select
const { RangePicker } = DatePicker

const Article = () => {
  return (
    <div>
      <Card
          style={{ marginBottom: 40 }}
          title={
            <Breadcrumb
              items={[
                {title: <Link to="/">首页</Link>},
                {title: "文章列表",}]}
            />
          }
        >
          <Form
            labelCol={{
              span: 4,
            }}
            wrapperCol={{
              span: 14,
            }}
            layout="horizontal"
            style={{ marginBottom: -20 }}
            initialValues={{ status: "" }}
          >
            <Form.Item label="状态" name="status">
              <Radio.Group>
                <Radio value={""}>全部</Radio>
                <Radio value={0}>草稿</Radio>
                <Radio value={2}>审核通过</Radio>
              </Radio.Group>
            </Form.Item>
            <Form.Item label="频道" name="channel_id">
              <Select
                placeholder="请选择文章频道"
                style={{
                  width: 140,
                }}
                options={[
                  {
                    value: "jack",
                    label: "Jack",
                  },
                ]}
              />
            </Form.Item>
            <Form.Item label="日期" name="date">
              <RangePicker
                style={{
                  width: 240,
                }}
              />
            </Form.Item>
            <Form.Item
              wrapperCol={{
                offset: 4,
              }}
            >
              <Button type="primary" htmlType="submit">
                筛选
              </Button>
            </Form.Item>
          </Form>
        </Card>
    </div>
  )
}

export default Article

1.2 表格区域结构

image.png

代码实现

jsx
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'

const Article = () => {
  // 准备列数据
  const columns = [
    {
      title: "封面", // 列标题
      dataIndex: "cover", // data中对应字段名
      width: 120, // 列宽
      align: "center", // 对齐方式
      render: (cover) => {
        // 生成复杂数据的渲染函数 注入该列数据
        return (
          <img
            src={cover.images[0] || "/src/assets/error.png"}
            width={80}
            height={60}
            alt=""
          />
        );
      },
    },
    {
      title: "标题",
      align: "center",
      dataIndex: "title",
      width: 170,
    },
    {
      title: "状态",
      dataIndex: "status",
      width: 80,
      align: "center",
      render: (data) => <Tag color="green">审核通过</Tag>,
    },
    {
      title: "发布时间",
      dataIndex: "pubdate",
      width: 180,
      align: "center",
    },
    {
      title: "阅读数",
      dataIndex: "read_count",
      align: "center",
    },
    {
      title: "评论数",
      dataIndex: "comment_count",
      align: "center",
    },
    {
      title: "点赞数",
      dataIndex: "like_count",
      align: "center",
    },
    {
      title: "操作",
      align: "center",
      render: (data) => {
        return (
          <Space size="middle">
            <Button
              type="primary"
              size="small"
              shape="circle"
              icon={<EditOutlined />}
            />
            <Button
              size="small"
              type="primary"
              danger
              shape="circle"
              icon={<DeleteOutlined />}
            />
          </Space>
        );
      },
    },
  ];
  // 准备表格body数据
  const data = [
    {
      id: "8218",
      comment_count: 0,
      cover: {
        images: [],
      },
      like_count: 0,
      pubdate: "2019-03-11 09:00:00",
      read_count: 2,
      status: 2,
      title: "wkwebview离线化加载h5资源解决方案",
    },
  ];
  return (
    <div>
       .....
      <Card title={"根据筛选条件共查询到 5 条结果:"}>
          <Table rowKey="id" columns={columns} dataSource={data} />
          {/*rowKey:表格行 key 的取值,可以是字符串或一个函数
            columns:表格列的配置描述
            dataSource:数据数组*/}
      </Card>
    </div>
  )
}

2. 渲染频道数据

由于频道列表下拉选择框需要在 ArticlePublish这两个组件中使用,为提高复用率可以采取以下两种方案:

  1. 将获取频道数据的逻辑封装成一个 hook并返回频道数据在不同组件中使用(推荐--降低耦合)
  2. 将频道数据存储到 redux仓库中
  3. 将下拉列表框组件封装为一个公共组件(涉及组件通信不推荐)

封装为 hook

js
// src\hooks\useChannel.js
import { getChannelListAPI } from "@/apis/article";
import { useEffect, useState } from "react";

// 封装获取频道列表数据的hook函数
export default function () {
  // 下拉列表框频道数据
  const [channelList, setChannelList] = useState([]);

  // 获取频道列表数据并存储
  const getChannelList = async () => {
    const result = await getChannelListAPI();
    setChannelList(
      result.data.channels.map((item) => { // 转换为Select组件options属性所需类型
        return {
          label: item.name, // 下拉列表框显示的文字
          value: item.id, // 下拉列表框的值
        };
      })
    );
  };
  // 组件渲染完毕获取频道列表数据
  useEffect(() => {   // hook中的useEffect函数会与组件中的useEffect函数都会执行
    getChannelList()
  }, []);

  // 返回数据
  return { channelList };
}

使用:

jsx
function Article() {
  // 获取频道列表数据
  const { channelList } = useChannel();

注意:自定义hook中的 useEffect函数和组件中的 useEffect函数都会执行,且自定义hookuseEffect会先于组件中的 useEffect执行

3. 渲染表格数据

实现步骤

  1. 使用useState声明参数相关数据管理
  2. 封装获取文章列表数据的接口方法
  3. useEffect中调用接口获取数据并渲染模板

代码实现

接口封装:src\apis\article.js

js
/**
 * 获取文章列表的接口方法
 * @param  query参数
 * @returns
 */
export const getArticleListAPI = (query) =>
  request.get("/mp/articles", { params: query });
jsx
// src\pages\Layout\Article\index.jsx
const Article = ()=>{
  // 文章列表数据
  const [articleList, setArticleList] = useState([]);
  // 文章列表数据总数
  const [total, setTotal] = useState(0);
  // 获取文章列表数据的方法
  const getArticleList = async () => {
    const res = await getArticleListAPI();
    setArticleList(res.data.results);
    setTotal(res.data.total_count);
  };
  .....
  // 组件渲染完毕获取文章列表数据
  useEffect(() => {
    getArticleList();
  }, []);
  
  // 模板渲染
  return (
   <Card title={`根据筛选条件共查询到 ${total} 条结果:`}>
    <Table rowKey="id" columns={columns} dataSource={articleList} />
    {/*rowKey:表格行 key 的取值,可以是字符串或一个函数
      columns:表格列的配置描述
      dataSource:数据数组*/}
  </Card>
  )
}

4. 适配文章状态

image-20240912090238341

**需求:**根据不同的文章状态显示,待审核 / 审核通过 Tag标签

实现思路:

  1. 如果要适配的状态只有两个可以使用 三元条件渲染
  2. 如果要适配的状态有多个推荐使用 枚举渲染
jsx
function Article() {
  ......
  // 枚举标签类型
  const tagList = {
    0: <Tag color="orange">草稿</Tag>,
    1: <Tag color="blue">待审核</Tag>,
    2: <Tag color="green">审核通过</Tag>,
  };
  // 准备列数据
  const columns = [
    .......
    {
      title: "状态",
      dataIndex: "status", // data中对应字段名
      width: 80,
      align: "center",
      // 自定义渲染函数(返回要渲染的数据) data为当前行数据
      /*  render: (data) =>
        data === 1 ? (
          <Tag color="orange">待审核</Tag>
        ) : (
          <Tag color="green">审核通过</Tag>
        ), */
      // 使用枚举方式
      render: (data) => tagList[data],
    },

5. 筛选功能实现

image-20240912163553119

实现步骤

  1. 为表单添加onFinish属性监听表单提交事件,获取参数
  2. 根据接口字段格式要求格式化参数格式
  3. 修改query 参数并重新请求数据

代码实现

jsx
// src\pages\Layout\Article\index.jsx
function Article() {
 // 获取频道列表的请求参数
+  const [reqChannelParams, setReqChannelParams] = useState({
+    page: 1, // 当前页码
+    per_page: 10, // 获取每页的条数
+    channel_id: null, // 频道id
+    status: null, // 文章状态
+    begin_pubdate: null, // 开始时间
+    end_pubdate: null, // 结束时间
  });
 // 点击筛选按钮的事件回调
  const onFilter = (values) => {
    // 解构出表单收集的数据
    const { channel_id, date, status } = values;
    /* 重新整理请求参数(由于reqChannelParams被设置为useEffect的依赖项,
    所以当其被修改时会重新执行useEffect重新获取数据) */
    setReqChannelParams({
+      ...reqChannelParams,
+      channel_id,
+      status,
+      begin_pubdate: date && dayjs(date[0]).format("YYYY-MM-DD"), // 开始时间
+      end_pubdate: date && dayjs(date[1]).format("YYYY-MM-DD"), // 结束时间
    });
  };
  // 获取文章列表数据的方法
  const getArticleList = async (query) => {
    const res = await getArticleListAPI(query);
    setArticleList(res.data.results);
    setTotal(res.data.total_count);
  };
  // 组件渲染完毕获取文章列表数据
+  useEffect(() => {
+    getArticleList(reqChannelParams);
+    // 当reqChannelParams发生变化时会重新执行副作用函数
+  }, [reqChannelParams]);

useEffect传入属性数组作为依赖项,当依赖项发生变化时会重新执行副作用函数,依赖项为空数组时副作用函数只会在组件渲染完毕后执行一次

6. 分页功能实现

![image-20240912163538342](./React 极客园.assets/image-20240912163538342.png)

实现步骤

  1. Table组件指定pagination属性来展示分页效果
  2. 在分页切换事件中获取到当前页数 page 和当前每页多少条数据 pageSize
  3. 修改请求参数,引起 useEffect 依赖变化引发数据重新获取

代码实现

jsx
function Article() {
+  // 页码或pageSize改变的回调,参数是改变后的页码及每页条数
+  const onChangePage = (page, pageSize) => {
+    // 修改reqChannelParams重新获取当前页文章列表(修改参数依赖项引发数据重新获取)
+    setReqChannelParams({
+      ...reqChannelParams, // 保留上一次的筛选数据
+      page: page,
+      per_page: pageSize,
+    });
+  };
  // 组件渲染完毕获取文章列表数据
  useEffect(() => {
    getArticleList(reqChannelParams);
    // 当reqChannelParams发生变化时会重新执行副作用函数
+  }, [reqChannelParams]);
  return (
    <>
     <Card title={`根据筛选条件共查询到 ${total} 条结果:`}>
          <Table
            rowKey="id"
            columns={columns}
            dataSource={articleList}
+            pagination={{
+              defaultPageSize: reqChannelParams.per_page, // 默认每页条数
+              total, // 总条数
+              position: ["bottomCenter"], // 分页器位置
+              onChange: onChangePage, // 页码或pageSize改变的回调,参数是改变后的页码及每页条数
+              showSizeChanger: true, // 是否展示 pageSize 切换器,当 total 大于 50 时默认为 true
+              pageSizeOptions: ["5", "10", "15"], // 每页可以显示的条数选择框
+            }}
+          />
          {/*rowKey:表格行 key 的取值,可以是字符串或一个函数
            columns:表格列的配置描述
            dataSource:数据数组
            pagination:分页器配置项*/}
        </Card>

7. 删除功能

image-20240912164641096

实现步骤

  1. 封装删除文章的接口方法
  2. 为删除按钮添加气泡确认框 Popconfirm组件,绑定点击确认按钮的事件回调并注入当前文章id
  3. 在确认回调函数中获取要删除的文章 id ,发请求删除数据
  4. 删除成功提示,更新列表(修改请求参数,重新获取第一页数据)

代码实现

封装接口方法:

js
src\apis\article.js
/**
 * 删除文章的接口方法
 * @param  id 要删除文章的id
 */
export const deleteArticleAPI = (id) => request.delete(`/mp/articles/${id}`);

组件中调用

jsx
// src\pages\Layout\Article\index.jsx
function Article() {
  .....
  // 点击气泡确认框确定按钮的回调
+  const confirmDelete = async (id) => {
+    // 调用删除文章的接口方法
+    await deleteArticleAPI(id);
+    message.success("删除成功");
+    // 修改useEffect依赖项reqChannelParams重新获取第一页数据
+    setReqChannelParams({
+      ...reqChannelParams,
+      page: 1,
+    });
+  };
  .....
    // 准备列数据
  const columns = [
    .....
    {
      title: "操作",
      align: "center",
      render: (data) => {
        // data 当前行数据
        return (
          <Space size="middle">
             ......
+            <Popconfirm
+              title="删除"
+              description="你确定要删除该篇文章吗?"
+              // 点击确定按钮回调(传递当前文章id)
+              onConfirm={() => confirmDelete(data.id)}
+              okText="确定"
+              cancelText="取消"
+            >
              <Button
                size="small"
                type="primary"
                danger
                shape="circle"
                icon={<DeleteOutlined />}
              />
            </Popconfirm>
          </Space>
        );
      },
    },
  ];
  // 组件渲染完毕获取文章列表数据
  useEffect(() => {
    getArticleList(reqChannelParams);
    // 当reqChannelParams发生变化时会重新执行副作用函数
+  }, [reqChannelParams]);

8. 加载动效

image-20240912165515697

当接口文章列表数据未返回时,使用 antd提供的 Spin组件显示加载效果,数据更新后关闭加载动效

页面局部处于等待异步数据或正在渲染过程时,合适的加载动效会有效缓解用户的焦虑

使用 useState 定义状态数据控制加载动效的显示与关闭(组件渲染完毕时开启加载动效,数据成功返回时关闭加载动效)

jsx
// src\pages\Layout\Article\index.jsx
function Article() {
+  // 控制文章列表的加载动效是否展示
+  const [isLoading, setIsLoading] = useState(false);
  ...
  // 获取文章列表数据的方法
  const getArticleList = async (query) => {
    const res = await getArticleListAPI(query);
    setArticleList(res.data.results);
    setTotal(res.data.total_count);
+    // 关闭加载动效
+    setIsLoading(false);
  };
 ....
  // 组件渲染完毕获取文章列表数据
  useEffect(() => {
    // 开启加载动效
+   setIsLoading(true);
    getArticleList(reqChannelParams);
    // 当reqChannelParams发生变化时会重新执行副作用函数
  }, [reqChannelParams]);
  return (
    <>
    {/* 使用 Spin组件包裹Table组件,实现加载动效 */}
+   <Spin tip="Loading" spinning={isLoading} size="large">
+      <Table
        ....
       </Table>
+   </Spin>

注意:在需要加载动效的组件外包裹 Spin 标签,并设置 spinning属性为 true

9. 编辑文章跳转

image-20240912183145389

点击编辑按钮跳转到文章发布页,并传递文章 id(以 query参数形式进行传递)

代码实现

jsx
const columns = [
  // ...
  {
    title: "操作",
    align: "center",
    render: (data) => {
      // data 当前行该列数据
      return (
        <Space size="middle">
          <Button
            type="primary"
            size="small"
            shape="circle"
            icon={<EditOutlined />}
            // 点击编辑按钮跳转到发布文章页面并携带当前文章id
+           onClick={() => navigate(`/publish?id=${data.id}`)}
          />
]

六、编辑文章

1. 基础数据回显

image.png

需求分析:点击编辑按钮后跳转到发布页面,在表单中显示该文章信息

实现步骤:

  1. 使用 useSearchParams()钩子函数获取传递的路由参数(文章 id
  2. useEffect函数中判断是否有文章 id,若存在则根据 id发请求获取文章详情
  3. From组件添加 form={form}属性并使用 const [form] = Form.useForm()获取实例对象
  4. 调用 form.setFieldsValue根据文章详情修改表单中的数据(数据回显)

封装获取文章详情接口方法

js
/**
 * @param  id 文章id
 * @returns 文章详情
 */
export const getArticleDetailAPI = (id) => request.get(`/mp/articles/${id}`);

获取路由参数发请求获取文章详情并进行数据回显

jsx
function Publish() {
  .....
+  // 获取查询路由参数
+  const [searchParams] = useSearchParams(); // hook函数只能在函数组件顶层调用
+  // 获取路由参数中的id
+  let id = searchParams.get("id");
+  /* Form组件提供的hook(Form.useForm),创建Form实例,
+  用于管理所有数据状态(需要为Form组件添加 form={form} 属性) */
+  const [form] = Form.useForm();
+  // 是否正在加载中(获取文章详情数据)
+  const [isLoading, setIsLoading] = useState(false);
  .....
+  // 获取文章详情数据,并设置为Form组件初始值
+  const getArticleDetail = async () => {
+    // 开启加载动效
+    setIsLoading(true);
+    const res = await getArticleDetailAPI(id);
+    const { channel_id, content, title } = res.data;
+    // 将文章详情设置为初始值(进行数据回显)
+    // 注意:Form组件的initialValues不能被setState动态更新需要使用form.setFieldsValue来动态改变表单值
+    form.setFieldsValue({
+      channel_id,
+      content,
+      title,
+    });
+    // 回显封面类型
+    setImageType(res.data.cover.type);
+    // 关闭加载动效
+    setIsLoading(false);
+  };
+  // 如果有路由参数id,则获取文章详情数据(进行数据回显)
+  useEffect(() => {
+    id && getArticleDetail();
+  }, [id]);
  return (
    <>
     ......
+     <Spin tip="加载中..." size="large" spinning={isLoading}>
       {/* 表单 */}
          <Form
+            form={form} // 用于获取表单实例
            labelCol={{
              span: 5,
            }}
            wrapperCol={{
              span: 10,
            }}
            onFinish={pubArticle}
          >

注意:被设置了 name 属性的 Form.Item 包装的控件,表单控件会自动添加 value onChange,数据同步将被 Form 接管,这会导致以下结果:

  1. 不再需要也不应该onChange 来做数据收集同步,但还是可以继续监听 onChange 事件
  2. 不能用控件的 valuedefaultValue 等属性来设置表单域的值,默认值可以用 Form 里的 initialValues 来设置
  3. Form组件的initialValues不能被setState动态更新需要使用form.setFieldsValue来动态改变表单值

获取 From组件实例:

​ 使用Form组件提供的hook(Form.useForm),创建Form实例,用于管理所有数据状态(需要为Form组件添加 form={form} 属性)

2. 回显封面图

![image.png](./React 极客园.assets/24.png)

javascript
function Publish() {
  // 获取文章详情数据,并设置为Form组件初始值
  const getArticleDetail = async () => {
 +   // 开启加载动效
 +   setIsLoading(true);
    const res = await getArticleDetailAPI(id);
    const { channel_id, content, title,cover } = res.data;
    // 将文章详情设置为初始值(进行数据回显)
    // 注意:Form组件的initialValues不能被setState动态更新需要使用form.setFieldsValue来动态改变表单值
    form.setFieldsValue({
      channel_id,
      content,
      title,
    });
+    // 回显封面类型
+    setImageType(cover.type);
+    // 回显封面图片  (设置Upload组件的fileList属性url为图片地址)
+    setImageList(
+      cover.images.map((item) => ({ // 与上传图片格式保持一致
+        url: item, // 用于显示的图片地址
+        response: { data: { url: item } },  // 保持与上传的图片格式相同
+      }))
+    );
+    // 缓存数据用于二图、三图模式切换图片列表数据不丢失
+    setCacheImageList(
+      cover.images.map((item) => ({
+        url: item,
+        response: { data: { url: item } },
+      }))
+    );
   // setCacheImageList(imageList) 错误(调用set函数不能改变运行中代码的状态)此时的imageList值还未更新
    // 关闭加载动效
+    setIsLoading(false);
  };

注意:

  1. set 函数 仅更新 下一次 渲染的状态变量如果在调用 set 函数后读取状态变量,则 仍会得到在调用之前显示在屏幕上的旧值

    jsx
    function handleClick() {
      setName('Robin');
      console.log(name); // Still "Taylor"!
    }
  2. 根据先前的 state 更新 state

    jsx
    function handleClick() {
      setAge(age + 1); // setAge(42 + 1)
      setAge(age + 1); // setAge(42 + 1)
      setAge(age + 1); // setAge(42 + 1)
    }

    为了解决这个问题,你可以向 setAge 传递一个 *更新函数*,而不是下一个状态

    jsx
    function handleClick() {
      setAge(a => a + 1); // setAge(42 => 43)
      setAge(a => a + 1); // setAge(43 => 44)
      setAge(a => a + 1); // setAge(44 => 45)
    }

3.适配按钮文字

image-20240913153507707image-20240913153542750

如果为发布文章(无id)则底部按钮和面包屑导航为 "发布文章",若为更新文章(有id)则为 "更新文章"

jsx
<Card
  title={
    <Breadcrumb items={[
      { title: <Link to={'/'}>首页</Link> },
      { title: `${id ? '编辑文章' : '发布文章'}` },
    ]}
    />
  }
>
// 发布按钮
<Button type="primary" htmlType="submit"> {id ? "更新文章" : "发布文章"} </Button>

4. 更新文章

  1. 封装更新文章的接口方法

  2. 适配 url 参数(上传成功的图片 urliamgeList.response.data.url中,而获取已有文章的 url存储在 imageList.url中)将回显的图片格式修改为上传成功图片的格式

    ![image-20240913155705804](./React 极客园.assets/image-20240913155705804.png)

  3. 点击更新按钮,发送请求

封装接口请求

js
// src\apis\article.js
/**
 * 更新文章的接口方法
 * @param id  文章id
 * @param data  文章数据
 * @returns data.id 文章id
 */
export const updateArticleAPI = (id, data) =>
  request.put(`/mp/articles/${id}`, data);

适配 url参数

js
 // 获取文章详情数据,并设置为Form组件初始值
  const getArticleDetail = async () => {
   ......
    // 回显封面图片  
    setImageList(
      cover.images.map((item) => ({
        url: item,
+       response: { data: { url: item } }, // 与上传图片返回的数据url格式相同便于数据的收集
      }))
    );
    // 缓存数据用于二图、三图模式切换图片列表数据不丢失
    setCacheImageList(
      cover.images.map((item) => ({
        url: item,
+       response: { data: { url: item } },
      }))
    );
    // 关闭加载动效
    setIsLoading(false);
  };
.....
 // 点击发布/更新按钮提交表单且数据验证成功后回调事件
  const pubArticle = async (values) => {
    // 1.校验图片类型和数量是否吻合
    if (imageList.length !== imageType)
      return message.error("图片类型和数量不匹配");
    // 2.整理请求参数
    const params = {
      ...values,
      cover: {
        type: imageType,
+       images: imageList.map((item) => item.response.data.url),
      }, // 默认文章封面为无图模式
    };
+    // 调用接口(有id更新无id新增)
+    const result = await (id
+      ? updateArticleAPI(id, params)
+      : publishArticleAPI(params));
+    if (result.data.id) { 
+      message.success(id ? "更新成功" : "发布成功");
+      // 跳转文章列表页面
+      setTimeout(() => {
+        navigate("/article");
+      }, 1000);
+    } else {
+      message.error(id ? "更新失败" : "发布失败");
+    }
+  };

七、项目打包

1. 项目打包

将项目中的源代码和资源文件进行处理,生成可在生产环境中运行的静态文件的过程

运行:pnpm run build

打包成功:会在项目根目录下生成一个 dist文件夹存放打包后的静态文件(不同构建工具打包出的文件夹名不同 webpack:build Vite:dist

image-20240913162317066

2. 项目本地预览

实现步骤

  1. 全局安装本地服务包 npm i -g serve  (全局安装)该包提供了serve命令,用来启动本地服务器
  2. 在项目根目录中执行命令 serve -s ./dist  在dist目录中开启服务器
  3. 在浏览器中访问:http://localhost:3000/ 预览项目
image-20240913163556395

3. 解决图片无法显示问题

问题:

​ 如果在 html中直接使用相对路径/绝对路径引用 assets中的图片,可能会导致图片无法加载或打包后不显示图片

错误示例:

html
<img className="../../assets/images/logo.png" src={logo} alt="logo" />

原因:

vite 官方默认的配置,如果资源文件在assets文件夹打包后会把图片名加上 hash值,但是直接通过 src="" data-missing="imgSrc"方式引入并不会在打包的时候解析,导致开发环境可以正常引入,打包后却不能显示

解决方法:

​ 可以先将图片的路径进行导入通过数据绑定进行使用

jsx
import logo from "@/assets/logo.png";  // js

<img className="login-logo" src={logo} alt="logo" /> // html

4. 优化-路由懒加载

什么是路由懒加载?

​ 路由的 JS 资源只有在被访问时才会动态获取,目的是为了优化项目首次打开的时间

image-20240913165927245

使用步骤

  1. 使用 lazy 方法导入路由组件
  2. 使用内置的 Suspense 组件渲染路由组件

<Suspense> 允许在子组件完成加载前展示后备方案,在内部组件未加载完毕时展示 fallback提供的组件

jsx
<Suspense fallback={ <Spin size="large" style={{ width: "100%", marginTop: "12%" }} />}>      // SomeComponent组件未加载完毕时展示加载组件
     <SomeComponent /> // 要展示的组件
</Suspense>

代码实现router/index.js

jsx
import { createBrowserRouter } from "react-router-dom";
import { lazy, Suspense } from "react";
import AuthRoute from "@/components/AuthRoute";
import GeekLayout from "@/pages/layout";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";
import { Spin } from "antd";

// 1. 使用lazy函数对组件进行导入(路由懒加载)
const Home = lazy(() => import("@/pages/Layout/Home"));
const Publish = lazy(() => import("@/pages/Layout/Publish"));
const Article = lazy(() => import("@/pages/Layout/Article"));

const router = createBrowserRouter([
  // 首页路由
  {
    path: "/",
    element: (
      // <AuthRoute>组件用于路由鉴权
      <AuthRoute>
        <GeekLayout />
      </AuthRoute>
    ),
    // 二级子路由
    children: [
      {
        // path: "home",
        index: true, // 设置为默认二级路由
        element: (
          <Suspense
            fallback={
              <Spin size="large" style={{ width: "100%", marginTop: "12%" }} />
            }
          >
            <Home />
          </Suspense>
        ),
      },
      {
        path: "article",
        element: (
          <Suspense
            fallback={
              <Spin size="large" style={{ width: "100%", marginTop: "12%" }} />
            }
          >
            <Article />
          </Suspense>
        ),
      },
      {
        path: "publish",
        element: (
          <Suspense
            fallback={
              <Spin size="large" style={{ width: "100%", marginTop: "12%" }} />
            }
          >
            <Publish />
          </Suspense>
        ),
      },
    ],
  },
  // 登录一级路由
  {
    path: "/login",
    element: <Login />,
  },
  // 404一级路由
  {
    path: "*",
    element: <NotFound />,
  },
]);

export default router;

查看效果 我们可以在打包之后,通过切换路由,监控network面板资源的请求情况,验证是否分隔成功

若不使用路由懒加载,在访问网站时会加载全部的路由组件,使用路由懒加载后在访问该路由时才会加载对应的路由组件

5. 打包-打包体积分析

业务背景

通过分析打包体积,才能知道项目中的哪部分内容体积过大,方便知道哪些包如何来优化

5.1 使用 CRA(webpack)构建的项目

  1. 安装分析打包体积的包:npm i source-map-explorer
  2. package.json 中的 scripts 标签中,添加分析打包体积的命令
  3. 对项目打包:npm run build(如果已经打过包,可省略这一步)
  4. 运行分析命令:npm run analyze
  5. 通过浏览器打开的页面,分析图表中的包体积

package.json

json
"scripts": {
  "analyze": "source-map-explorer 'build/static/js/*.js'",// webpack
}
image.png

5.2 使用 Vite构建的项目

  1. 安装依赖:pnpm i rollup-plugin-visualizer -D

  2. 配置vite.config.ts

    json
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import path from "path";
    + import { visualizer } from "rollup-plugin-visualizer";
    
    export default defineConfig({
      plugins: [
        react(),
    +    // 打包体积分析
    +    visualizer({
    +      open: true,
    +      filename: "visualizer.html", //分析图生成的文件名
    +    }),
      ],
      resolve: {
        alias: {
          //  @ ==> /src
          "@": path.resolve(__dirname, "src"),
        },
      },
    });
  3. 运行 pnpm run build 打包完成会生成一个html文件,就可以看到各个包的体积大小了

    ![image-20240913181556006](./React 极客园.assets/image-20240913181556006.png)

6. 优化-配置CDN加速

什么是 CDN

CDN 是一种内容分发网络服务,当用户请求网站内容是,由离用户最近的服务器缓存的资源内容传递给用户

哪些资源可以放到 CDN 服务器?

体积较大的 非业务JS文件,比如 reactreact-domdayjs

  1. 体积较大,需要利用 CDN文件在浏览器的缓存特性,加快加载时间
  2. 非业务 JS 文件,不需要经常做变动,CDN不用频繁更新缓存

作用:减小打包后文件体积

在打包时不再将node_modules中的较大且不经常改变的JS文件打包到静态资源中,而是使用 CDN的方式将其引入

6.1 使用 CRA(webpack)构建的项目

通过 craco 来修改 webpack 配置,从而实现 CDN 优化

craco.config.js

javascript
// 添加自定义对于webpack的配置
const path = require('path')
const { whenProd, getPlugin, pluginByName } = require('@craco/craco')

module.exports = {
  // webpack 配置
  webpack: {
    // 配置别名
    alias: {
      // 约定:使用 @ 表示 src 文件所在路径
      '@': path.resolve(__dirname, 'src')
    },
    // 配置webpack
    // 配置CDN
    configure: (webpackConfig) => {
      let cdn = {
        js:[]
      }
      whenProd(() => {
        // key: 不参与打包的包(由dependencies依赖项中的key决定)
        // value: cdn文件中 挂载于全局的变量名称 为了替换之前在开发环境下
        webpackConfig.externals = {
          react: 'React',
          'react-dom': 'ReactDOM'
        }
        // 配置现成的cdn资源地址
        // 实际开发的时候 用公司自己花钱买的cdn服务器
        cdn = {
          js: [
            'https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js',
            'https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js',
          ]
        }
      })
      // 通过 htmlWebpackPlugin插件 在public/index.html注入cdn资源url
      const { isFound, match } = getPlugin(
        webpackConfig,
        pluginByName('HtmlWebpackPlugin')
      )
      if (isFound) {
        // 找到了HtmlWebpackPlugin的插件
        match.userOptions.files = cdn
      }
      return webpackConfig
    }
  }
}

public/index.html

html
<body>
  <div id="root"></div>
  <!-- 加载第三方包的 CDN 链接 -->
  <% htmlWebpackPlugin.options.files.js.forEach(cdnURL => { %>
    <script src="" data-missing="<%= cdnURL %>"></script>
  <% }) %>
</body>

6.2 使用 Vite构建的项目

  1. 安装插件:pnpm i rollup-plugin-external-globals -D

  2. 配置vite.config.ts

    json
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import path from "path";
    import { visualizer } from "rollup-plugin-visualizer";
    + import externalGlobals from "rollup-plugin-external-globals";
    
    + const globals = externalGlobals({
    +  dayjs: "dayjs", //不需要引入的包
    +  echarts: "echarts",
    + });
    
    export default defineConfig({
      plugins: [
        react(),
        // 打包体积分析
        visualizer({
          open: true,
          filename: "visualizer.html", //分析图生成的文件名
        }),
      ],
      resolve: {
        alias: {
          //  @ ==> /src
          "@": path.resolve(__dirname, "src"),
        },
      },
    +  build: {
    +    rollupOptions: {
    +      //打包时不引入外部模块,使用cdn引入
    +      external: ["dayjs", "echarts"],
    +      plugins: [globals],
    +    },
      },
    });
  3. index.html文件的head标签内引入对应库的CDN

    具体的CDN链接根据自己需要去官网或是CDN网站查询,**cdn网站:cdnjs.com/**或使用 BootCDN

    html
    <!DOCTYPE html>
    <html lang="en">
      <head>
    +   <script src="https://cdn.bootcdn.net/ajax/libs/dayjs/1.11.13/dayjs.min.js"></script>
    +   <script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.5.0/echarts.min.js"></script>
        <meta charset="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>极客园</title>
      </head>
      <body>
        <div id="root"></div>
        <script type="module" src="/src/main.jsx"></script>
      </body>
    </html>

![image-20240915232618750](./React 极客园.assets/image-20240915232618750.png)

7. terser 压缩和去除console+debugger

Terser压缩是一种JavaScript代码压缩和优化工具,主要用于减少文件体积并提升加载速度

  1. 安装:pnpm i terser -D

  2. 配置 vite.config.js

    json
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import path from "path";
    import { visualizer } from "rollup-plugin-visualizer";
    import externalGlobals from "rollup-plugin-external-globals";
    
    const globals = externalGlobals({
      dayjs: "dayjs",
    });
    
    export default defineConfig({
      plugins: [
        react(),
        // 打包体积分析
        visualizer({
          open: true,
          filename: "visualizer.html", //分析图生成的文件名
        }),
      ],
      resolve: {
        alias: {
          //  @ ==> /src
          "@": path.resolve(__dirname, "src"),
        },
      },
      build: {
    +    minify: "terser",
    +    // 清除所有console和debugger
    +    terserOptions: {
    +      compress: {
    +        drop_console: true,
    +        drop_debugger: true,
    +      },
    +    },
        rollupOptions: {
          //打包时不引入外部模块,使用cdn引入
          external: ["dayjs"],
          plugins: [globals],
        },
      },
    });

注:如果不想使用terser,可以选择使用esbuild,这是Vite默认的压缩工具。在vite.config.js中添加以下配置:

json
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
  plugins: [vue()],
  build: {
    minify: 'esbuild',
    esbuild: {
      drop: ['console', 'debugger'],
    },
  },
})

重新运行npm run build,生成的构建结果将不包含consoledebugger信息

vite项目打包优化