react

开发环境搭建

使用create-react-app快速搭建开发环境,创建react开发环境的工具,底层由webpack构建,封装了配置细节,开箱即用:

执行命令:

npx create-react-app react-basic

  1. npx node.js工具命令,查找并执行后续命令
  2. create-react-app 核心包(固定写法),用于创建React项目
  3. react-basic React项目的名称(可以自定义)

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 导入React库,用于构建用户界面
import React from 'react';
// 导入ReactDOM库,用于将React组件渲染到Web页面中
import ReactDOM from 'react-dom/client';

// 导入App组件,该组件是应用的主要入口点
import App from "./App";

// 使用ReactDOM创建一个根DOM元素,与HTML中的root元素对应
const root = ReactDOM.createRoot(document.getElementById('root'));
// 渲染React组件到DOM中,此处将App组件渲染到root元素中
root.render(
<App />
);

APP.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义一个常量,用于在组件中显示欢迎信息
const message = "Hello World!";

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (
<div className="App">
<h1>this is {message}</h1>
</div>
);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;


JSX基础

概念和本质

JSX是JavaScript和xml(html)的缩写,表示在js代码中编写html模板结构,它是React中编写UI模板的方式

1
2
3
4
5
6
7
8
9
const message = 'this is message'
function APP(){
return(
<div>
<h1>this is title</h1>
{message}
</div>
)
}

优势:

  • HTML的声明式模板写法
  • js的可编程能力

JSX并不是标准的JS语法,它是JS的语法扩展,浏览器本身不能识别,需要通过解析工具做解析之后才能在浏览器中运行。

image-20240821205248339

JSX中使用JS表达式

在JSX中可以通过大括号语法{}识别JavaScript中的表达式,比如常见的变量,函数调用,方法调用等。

  1. 使用引号传递字符串
  2. 使用JavaScript变量
  3. 函数调用和方法调用
  4. 使用JavaScript对象

注意:if语句,switch语句,变量声明属于语句,表示表达式,不能出现在{}中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 定义一个常量,用于在组件中显示欢迎信息
const message = "Hello World!";
const getName = () => {
return "da Chun!";
};

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (<div className="App">
{/*1. 使用引号传递字符串*/}
{'你好 React!'}
<br/>
{/*2. 使用JavaScript变量*/}
{message}
<br/>
{/*3. 函数调用和方法调用*/}
{getName()}
<br/>
{new Date().toLocaleTimeString()}
<br/>
{/*4. 使用JavaScript对象*/}
<div style={{color: 'red'}}>React is awesome!</div>
<h1>this is {message}</h1>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821210346867

列表渲染

语法:在JSX中使用原生JS中map方法遍历渲染列表.

注意事项:加上一个独一无二的key,字符串或者number比如id。

key的作用:React框架内部使用 提升更新性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义一个常量,用于在组件中显示欢迎信息
const list = [
{id: 1, name: "John"},
{id: 2, name: "Jane"},
{id: 3, name: "Jim"}
]

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (<div className="App">
{/*列表渲染*/}
<ul>
{list.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821211512507

条件渲染

image-20240821211707115

语法:在React中,可以通过逻辑与运算符&&三元表达式(?:)实现基础的条件渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const flag = false;

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (<div className="App">

{/*逻辑与&&*/}
{flag && <span>hello</span>}
<br/>
{/* 三元运算符*/}
{flag ? <span>loading...</span> : <span>hello React!</span>}
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821212336275

复杂条件渲染

需求:列表中需要根据文章状态适配三种情况,单图,三图和无图三种模式

解决方案:自定义函数 + if判断语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const count = 1; // 0 1 3
function getImgCount() {
if (count === 0) {
return <div>我是无图</div>
} else if (count === 1) {
return <div>我是单图</div>
} else {
return <div>我是多图</div>
}
}

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (<div className="App">
{/* 调用复杂条件渲染*/}
{getImgCount()}
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821213224569

事件绑定

基本使用

语法:on+事件名称={事件处理程序},整体上遵循驼峰命名法。

使用事件对象参数

语法:在事件回调函数中设置形参e.

传递自定义参数

语法:事件绑定的位置改造成箭头函数的写法,在执行函数实际处理因为函数的时候传递实参.

注意:不能直接写函数调用,这里时间绑定需要一个函数引用.

同时传递事件对象和自定义参数

语法:在事件绑定的位置传递事件实参e和自定义参数,clickHandler中声明形参,注意顺序对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
// 基础使用
// function clickHandler() {
// alert("hello React!")
// }
// 使用事件对象参数
// function clickHandler(e){
// console.log(e)
// }
// 传递自定义参数
function clickHandler(name, e) {
console.log(name, e)
}

return (<div className="App">
{/* 基础使用*/}
{/*<button onClick={clickHandler}>click me</button>*/}
{/* 使用事件对象参数*/}
{/*<button onClick={clickHandler}>click me</button>*/}
{/*传递自定义参数 */}
<button onClick={(e) => clickHandler("张三", e)}>click me</button>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

组件

介绍

概念:一个组件就是用户界面的一部分,它可以有自己的逻辑和外观,组件之间可以相互嵌套,也可以复用多次

image-20240821215646740

组件化开发可以让开发者向搭积木一样构建一个完整的庞大的应用。


React组件

在React中,一个组件就是首字母大写的函数,内部存放了组件的逻辑和视图UI,渲染组件只需要把组件当成标签书写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义组件
const Button = () => {
// 业务逻辑组件
return <button>click me!</button>
}


/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
return (<div className="App">
{/*渲染组件*/}
<Button></Button>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821220444515

useState

useState是一个React Hook(函数),它允许我们想组件添加一个状态变量,从而控制影响组件的渲染结果

image-20240821222024512

本质:和普通JS变量不同的是,状态变量一旦发生组件的视图UI也会跟着变化(数据驱动视图)

1
const [count,setCount] = useState(0);
  1. useState是一个函数,返回值是一个数组
  2. 数组中的第一个参数是状态变量,第二个参数是set函数用来修改状态变量
  3. useState的参数将作为count的初始值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 导入useState
import {useState} from "react";

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {
const [count, setCount] = useState(0)

const handleAdd = () => {
setCount(count + 1)
}
const handleSub = () => {
setCount(count - 1)
}
return (<div className="App">
<button onClick={handleSub}>-</button>&nbsp;
{count}
&nbsp;<button onClick={handleAdd}>+</button>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;

image-20240821223220541

修改状态规则

状态不可变

在React中,状态被认为说只读的,我们一个始终替换它而不是修改它,直接修改状态不能引发视图更新。

image-20240821223506783

修改对象状态

规则:对于对象类型的状态变量,应该始终传给set方法一个全新的对象来进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 导入useState
import {useState} from "react";

/**
* App函数是React组件的实现,用于渲染页面的根组件
*
* @returns 返回根组件的JSX结构,包含一个显示欢迎信息的标题
*/
function App() {

const [form, setForm] = useState({
name: '张三'
})

const changeName = () => {
// 错误写法
// form.name = '李四'
// 正确写法
setForm({
...form,
name: "李四"
})
}

return (<div className="App">
<button onClick={changeName}>姓名:{form.name}</button>
</div>);
}

// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;


image-20240821224322185

组件基础样式

react组件基础样式控制有两种方式

  • 行内样式(不推荐)
  • class类名控制(推荐)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 导入useState
import "./index.css"

const style = {
color: 'red',
fontSize: '50px'
}

function App() {
return (<div className="App">
{/*方式一:行内样式*/}
<div style={{color: 'red', fontSize: '40px'}}>Hello React!</div>
<div style={style}>Hello React!</div>
{/* 方式二,外部引入*/}
<div className="foo">Hello React!</div>
</div>);
}


export default App;

image-20240821225548880

classNames优化类名控制

classNames是一个简单的JS库,可以方便的通过条件动态控制class类名的显示。

image-20240822122411095

缺点:字符串的拼接方式不够直观,容易出错。

优化:

1
className={classNames('nav-item', {active: type === item.type})}
  • nav-item:静态类名
  • {active: type === item.type}:动态类名,key表示要控制的类名,value表示条件,true的时候类名显示.

引用:

1
npm i classnames --save
1
import classNames from 'classnames'

受控表单绑定

概念:使用React组件的状态(useState)控制表单的状态

image-20240822123704356

  1. 准备一个React状态值
1
const [value, setValue] = useState('')
  1. 通过value属性绑定状态,通过onChange属性绑定状态同步的函数。
1
<input type='text' value={value} onChange={(e) => setValue(e.target.value)}></input>

获取DOM元素

在react组件中获取、操作DOM,需要使用useRef钩子函数,分为两步。

  1. 使用useRef创建ref对象,并与JSX绑定
1
2
const inputRef = useRef(null)
<input type="text" ref={inputRef} />
  1. 在DOM使用时,通过inputRef.current拿到DOM对象
1
2
console.log(inputRef.current)
console.dir(inputRef.current)

组件通信

介绍

概念:组件通信就是组件之间的数据传递,根据之间嵌套关系的不同,有不同的通信方法。

image-20240822133926700

父传子

image-20240822134017075

实现步骤:

  1. 父组件传递数据-在子组件标签上绑定属性
  2. 子组件接收数据——子组件通过props参数接收数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Child(props) {
return (<div>
<h1>hello React!</h1>
<br/>
<h2>{props.name}</h2>
</div>)
}
function App() {
const name = 'this is App Component'
return (<div className="App">
<Child name={name}></Child>
</div>);
}

export default App;

props说明

  1. props可传递任意的数据:

数字,字符串,布尔值,数组,对象,函数,JSX

image-20240822150744245

  1. props是只读对象:子组件只能读取props中的数据,不能直接进行修改,父组件的数据只能有父组件修改。

特殊的prop children

场景:当我们把内嵌套在子组件标签中是,父组件会自动在名为children的prop属性中接收该内容

image-20240822151746904

子传父

image-20240822151953457

思路:在子组件中调用父组件中的函数并传递参数

image-20240822152034306

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {useState} from "react";
// 形参接收函数
function Child({onMsg4Son}) {
const sonMsg = 'hello React!';
return (<div>
<button onClick={() => onMsg4Son(sonMsg)}>click me</button>
</div>)
}
function App() {
// 定义响应数据
const [msg, setMsg] = useState('')
// 定义传递的函数,形参为接收子组件的数据
const getMsg = (msg) => {
console.log(msg)
setMsg(msg)
}
return (<div className="App">
<h2>{msg}</h2>
<Child onMsg4Son={getMsg}></Child>
</div>);
}

export default App;

image-20240822153049157

兄弟通信

使用状态提升实现兄弟组件通信

image-20240822153146140

思路:借助”状态提升“机制,通过父组件进行兄弟组件之间的数据传递

  1. A组件先通过子传父的方式吧数据传给父组件APP
  2. APP拿到数据后同父传子的方式在传递给B组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import {useState} from "react";

/**
* A组件数据传递给B组件
* @returns {JSX.Element}
* @constructor
*/
function A({onAMsg}){
const aName = 'this A name';
return(<div>
<span>A Component</span>
<button onClick={() => onAMsg(aName)}>A Button</button>
</div>)
}

function B(props){
return(<div>
<div>
<span>B Component</span>
<h2>{props.name}</h2>
</div>
</div>)
}

function App() {

// 定义状态变量保存A的信息用于给B组件
const [name, setName] = useState('')
// 用于接收A组件的信息
const getAMsg = (msg) => {
console.log(msg)
setName(msg)
}
return (<div className="App">
<div>this App</div>
<A onAMsg={getAMsg}></A>
<B name={name}></B>
</div>);
}

export default App;

image-20240822154347018

Context机制夸层级组件通信

实现步骤:

  1. 使用createContext方法创建一个上下文对象Ctx。
  2. 在顶层组件(APP)中通过Ctx.provider组件提供数据
  3. 在底层组件(B)中通过useContext钩子函数使用数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import {useState, createContext, useContext} from "react";

// 1. 使用`createContext`方法创建一个上下文对象Ctx。
const MsgContext = createContext();


/**
* A组件数据传递给B组件
* @returns {JSX.Element}
* @constructor
*/
function A() {

return (<div>
this is A
<B></B>
</div>)
}

function B() {
// 3. 在底层组件(B)中通过`useContext`钩子函数使用数据。
const msg = useContext(MsgContext);
return (<div>
this is B {msg}
</div>)
}

function App() {
// 2. 在顶层组件(APP)中通过`Ctx.provider`组件提供数据
const msg = 'this is app msg'
return (<div className="App">
this is App
<MsgContext.Provider value={msg}>
<A></A>
</MsgContext.Provider>

</div>);
}

export default App;

image-20240822161105274

useEffect

介绍

useEffect是一个React Hook函数,用于在React组件中创建不是由事件引起而是由渲染本身引起的操作,比如发送Ajax请求,更改DOM等等。(类似vue中onMounted生命周期的钩子函数)

image-20240822161415428

说明:上面的组件中没有发生任何的用户事件,组件渲染完毕之后就需要和服务器请求数据,整个过程属于由渲染本身引起的操作

基础使用

需求:在组件渲染完毕之后,立刻从服务器获取频道列表数据并显示到页面中

语法:

1
useEffect(() => {},[])

参数1是一个函数,可以把它叫做副作用函数,在函数内部可以防止要执行的操作。

参数2是一个数组(可选参),在数组里放置依赖项,不同依赖项会影响第一个参数的执行,当是一个空数组的时候,副作用函数智慧在组件渲染完毕之后执行一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {useEffect, useState} from "react";
const URL = 'http://geek.itheima.net/v1_0/channels';
function App() {
const [list, setList] = useState([])
// 定义请求数据的方法
const getData = async () => {
const res = await fetch(URL);
const list = await res.json();
console.log(list)
setList(list.data.channels)
}
// 页面渲染时调用该函数
useEffect(() => {
getData();
},[])
return (<div className="App">
<ul>{list.map(item => <li key={item.id}>{item.name}</li>)}</ul>
</div>);
}
export default App;


image-20240822163509066

useEffect依赖项参数说明

useEffect副作用函数的执行时机存在多种情况,根据传入依赖项的不同,会有不同的执行表现。

image-20240822163836074

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {useEffect, useState} from "react";
function App() {
const [count, setCount] = useState(0)
// 1. 不传递依赖项,组件发生改变,就会执行一次副作用函数
// useEffect(() =>{
// console.log('副作用函数执行了')
// })
// 2. 传递空数组,副作用函数只会执行一次
// useEffect(() =>{
// console.log('副作用函数执行了')
// },[])

// 3. 指定依赖项,当依赖项发生改变,就会执行一次副作用函数
useEffect(() =>{
console.log('副作用函数执行了')
},[count])
const onChangeCount = (count) => {
setCount(count)
}
return (<div className="App">
<button onClick={() => onChangeCount(count+1)}>+{count}</button>
</div>);
}
export default App;


image-20240822164959695

image-20240822164852448

image-20240822165114569

清除副作用

在useEffect中编写的由渲染本身引起的对接组件外部的操作,社区也经常把它叫做副作用操作,比如在useEffect中开启了一个定时器,我们项在组件卸载时把这个定时器再清理掉,这个过程就是清理副作用。

image-20240822165731413

说明:清除副作用的函数最常见的执行时机是在组件卸载时自动执行

需求:在SON组件渲染开启一个定时器,卸载是清除这个定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import {useEffect, useState} from "react";

function A(){
useEffect(() => {
const timer = setInterval(() => {
console.log('副作用函数执行力..')
},1000)
// 消除副作用,当组件卸载时副作用函数中定时器也消除
return () => {
clearInterval(timer)
}
},[])
return(
<div>
this is A
</div>
)
}
function App() {
const [isShow, setIsShow] = useState(true)
const onChangeIsShow = () => {
setIsShow(false)
}
return (<div className="App">
{isShow && <A></A>}
<button onClick={() => onChangeIsShow()}>show time</button>
</div>);
}
export default App;


自定义Hook函数

介绍

概念:自定义Hook是以use打头的函数,通过自定义Hook函数可以用来实现逻辑的封装和复用

image-20240822172454767

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState} from "react";
// 自定义hook函数来控制组件的显示与隐藏
function useToggle() {
const [isShow, setIsShow] = useState(true)
const onChangeIsShow = () => {
setIsShow(!isShow)
}
return{
isShow,
onChangeIsShow
}
}

function App() {
// 导入自定义hook函数
const {isShow, onChangeIsShow} = useToggle()
return (<div className="App">
{isShow && <span>this is span</span>}
<button onClick={() => onChangeIsShow()}>show time</button>
</div>);
}
export default App;

使用规则

  1. 只能在组件中或者其他自定义Hook函数中调用
  2. 只能在组件的顶层调用,不能嵌套在if,for,其他函数中

image-20240822184221567

Redux

介绍

Redux是React最常用的集中状态管理工具,类似与vue中的pinia(Vuex),可以独立于框架运行

作用:通过集中管理的方式管理应用的状态。

image-20240822214001389

redux管理数据流程梳理

image-20240822214935348

为了职责清晰,数据流项明确,redux把整个数据修改的流程分成三个核心概念,分别是:state,action,reducer

  1. state:一个对象,存放我们管理的数据状态
  2. action:一个对象,用来描述你想怎么改数据
  3. reducer:一个函数,根据action的描述生成一个新的state。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<button id="decrement">-</button>
<span id="count">0</span>
<button id="increment">+</button>

<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>

<script>
// 1. 定义reducer函数
// 作用: 根据不同的action对象,返回不同的新的state
// state: 管理的数据初始状态
// action: 对象 type 标记当前想要做什么样的修改
function reducer (state = { count: 0 }, action) {
// 数据不可变:基于原始状态生成一个新的状态
if (action.type === 'INCREMENT') {
return { count: state.count + 1 }
}
if (action.type === 'DECREMENT') {
return { count: state.count - 1 }
}
return state
}

// 2. 使用reducer函数生成store实例
const store = Redux.createStore(reducer)

// 3. 通过store实例的subscribe订阅数据变化
// 回调函数可以在每次state发生变化的时候自动执行
store.subscribe(() => {
console.log('state变化了', store.getState())
document.getElementById('count').innerText = store.getState().count
})

// 4. 通过store实例的dispatch函数提交action更改状态
const inBtn = document.getElementById('increment')
inBtn.addEventListener('click', () => {
// 增
store.dispatch({
type: 'INCREMENT'
})
})

const dBtn = document.getElementById('decrement')
dBtn.addEventListener('click', () => {
// 减
store.dispatch({
type: 'DECREMENT'
})
})

// 5. 通过store实例的getState方法获取最新状态更新到视图中

</script>

Redux与React

配套工具

在React中使用redux,官方要求安装两个其他插件:Redux Toolkitreact-redux

  1. Redux Toolkit(RTK):官方推荐编写redux逻辑的方式,是一套工具的集合,简化书写。
    • 简化store的配置方式
    • 内置immer支持可变式状态修改
    • 内置thunk更好的异步创建
  2. react-redux:用来链接redux和react组件的中间件

image-20240822220206458

配置基础环境

  1. 使用CRA快速创建React项目
1
npx create-react-app 项目名
  1. 安装配套工具
1
npm i @reduxjs/toolkit react-redux
  1. 启动项目
1
npm run start

store目录结构设计

image-20240822220706454

  1. 通常集中状态管理的部分都会单独创建一个单独的”store”目录
  2. 应用通常会有很多个子store模块,所有创建一个”modules”目录,在内部编写分类的子store
  3. store中入口文件index.js的作用是组合modules中所有的子模块,并导出store

实现counter

image-20240822221316050

使用React Toolkit创建counterStore

image-20240822221530232

counterStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {createSlice} from "@reduxjs/toolkit";

const counterStore = createSlice({
name: 'counter',
// 初始化状态数据
initialState: {
count: 0
},

// 修改数据的同步方法
reducers: {
// 累加方法
increment(state){
state.count ++
},
// 减一
decrement(state){
state.count --;
}
}
})

// 解构出创建action对象的函数
const {increment, decrement} = counterStore.actions;
// 获取reducer函数
const counterReducer = counterStore.reducer;
// 导出创建action对象的函数和reducer函数
export {increment, decrement};
export default counterReducer;

store/index.js

1
2
3
4
5
6
7
8
9
10
11
12
import {configureStore} from "@reduxjs/toolkit";
import counterReducer from "./modules/counterStore";


// 创建根store组合子模块
const store = configureStore({
reducer: {
counter: counterReducer
}
})

export default store

为React注入Store

react-redux负责把Redux和react链接起来,内置provider组件通过store参数把创建好的store实例注入刀应用中,连接正式建立。(index.js)

image-20240822222118486

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {Provider} from "react-redux";
import store from "./store";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>

</React.StrictMode>
);

reportWebVitals();

React组件使用store中的数据:

在React组件中使用store中的数据,需要用到一个钩子函数——useSelector,它的作用是把store中数据映射到组件中,使用样例如下:

image-20240822222431768

App.js

1
2
3
4
5
6
7
8
9
10
11
import {useSelector} from "react-redux";
function App() {
const {count} = useSelector(state => state.counter);
return (
<div className="App">
{count}
</div>
);
}

export default App;

React组件修改store中的数据:

React组件中修改store中的数据需要借助另外一个hook函数——

useDispatch,它的作用是删除提交action对象的dispatch函数,使用后样例如下:

image-20240822222753797

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {useDispatch, useSelector} from "react-redux";
// 导入创建action对象的方法
import {decrement, increment} from "./store/modules/counterStore";
function App() {
const {count} = useSelector(state => state.counter);
// 得到dispatch函数
const dispatch = useDispatch();
return (
<div className="App">
{/*调用dispatch提交action对象*/}
<button onClick={() => dispatch(decrement())}>-</button>&nbsp;&nbsp;
<span>{count}</span>&nbsp;&nbsp;
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}

export default App;

提交action传参

需求:

组件中有两个按钮”add to 10”和”add to 20”可以直接把count值修改到对应的数字,目标count值是在组件中传递过去的,需要在提交action的时候传递参数

image-20240822224340321

实现步骤:

在reducers的同步修改方法中添加action对象参数,在调用actionCreater的时候传递参数,参数会被传递到action对象payload属性上。

image-20240822224553056

异步状态操作

image-20240822225013357

  1. 创建store的写法保存不变,配置好同步修改状态的方法
  2. 单独封装一个函数,在函数内部return一个新函数,在新函数中
    • 封装异步请求获取数据
    • 调用同步actionCreater传入异步数据生成一个action对象,并使用dispatch提交
  3. 组件中dispatch的写法保持不变

src/store/modules/channelStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import {createSlice} from "@reduxjs/toolkit";
import axios from "axios";

const channelStore = createSlice({
name: "channels",
initialState: {
channelList: []
},
reducers: {
setChannelList(state, action){
state.channelList = action.payload
}
}
})


// 解构出创建action的函数
const {setChannelList} = channelStore.actions;
// 获取reducer
const ChannelReducer = channelStore.reducer;
export {setChannelList};
export default ChannelReducer;

// 异步请求部分
const url = 'http://geek.itheima.net/v1_0/channels';
const fetchChannelList = () => {
return async (dispatch) => {
const res = await axios.get(url);
console.log(res);
dispatch(setChannelList(res.data.data.channels));
}
}

export {fetchChannelList}

src/store/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {configureStore} from "@reduxjs/toolkit";
import counterReducer from "./modules/counterStore";
import ChannelReducer from "./modules/channelStore";


// 创建根store组合子模块
const store = configureStore({
reducer: {
counter: counterReducer,
channels: ChannelReducer
}
})

export default store

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {Provider} from "react-redux";
import store from "./store";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>

</React.StrictMode>
);

reportWebVitals();

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {useDispatch, useSelector} from "react-redux";
// 导入创建action对象的方法
import {decrement, increment, addToNum} from "./store/modules/counterStore";
import {useEffect} from "react";
import {fetchChannelList} from "./store/modules/channelStore";
function App() {
const {count} = useSelector(state => state.counter);
const { channelList } = useSelector(state => state.channels)
// 得到dispatch函数
const dispatch = useDispatch();
// 异步调用
useEffect(() => {
dispatch(fetchChannelList())
}, [dispatch])
return (
<div className="App">
{/*调用dispatch提交action对象*/}
<button onClick={() => dispatch(decrement())}>-</button>
&nbsp;&nbsp;
<span>{count}</span>&nbsp;&nbsp;
<button onClick={() => dispatch(increment())}>+</button>
<br/>
{/*传参*/}
<button onClick={() => dispatch(addToNum(10))}>add To 10</button>
<button onClick={() => dispatch(addToNum(20))}>add To 20</button>
<ul>
{channelList.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}

export default App;

ReactRouter

介绍

概念:应该路径path对应一个组件component,当我们在浏览器中访问一个path的时候,path对应的中间会在页面中进行渲染。

image-20240823204007972

环境准备

  1. 创建项目并安装所有依赖
1
2
npx create-react-app (react-router-pro)项目名
npm i
  1. 安装最新的ReactRouter包
1
npm i react-router-dom
  1. 启动项目
1
npm run start

快速开始

需求:创建一个可以切换登录页和文章页的路由系统

image-20240823204712756

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';

import {createBrowserRouter, RouterProvider} from "react-router-dom";
const router = createBrowserRouter([
{
path: '/login',
element: <div>我是登录页</div>
},
{
path: '/article',
element: <div>我是文章页</div>
}
])

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<RouterProvider router={router}></RouterProvider>
</React.StrictMode>
);

reportWebVitals();

image-20240823223214159

抽象路由模块

实际项目配置

image-20240823205310212

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {createBrowserRouter} from "react-router-dom";
import Login from "../page/Login";
import Article from "../page/Article";


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

export default router;

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import router from "./router";
import { RouterProvider} from "react-router-dom";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<RouterProvider router={router}></RouterProvider>
</React.StrictMode>
);

reportWebVitals();

image-20240823224111830

路由导航

概念:路由系统中的多个路由组件需要进行路由跳转,并且在跳转的同时有可能需要传递参数进行通信

image-20240823224432399

声明式导航

声明式导航是指通过在模板中通过‘<Link/>’组件描述要跳转到哪里去,比如后台管理系统的左侧菜单通常使用这种方式进行。

image-20240823224827496

说明:通过给组件的to属性指定要跳转到路由path,组件会被渲染为浏览器支持的a链接,如果需要传参直接通过字符串拼接的方式拼接参数即可.

编程式导航

编程式导航是指同useNavigate钩子函数得到导航方法,然后通过调用方法以命令式的形式进行路由跳转,比如向在登录请求完毕之后跳转就可以选择这种方式,更加灵活。

image-20240823225416555

说明:通过调用navigate方法传入地址path实现跳转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {Link, useNavigate} from "react-router-dom";

function Login() {
const navigate = useNavigate()
return (
<div>
欢迎登录
<div>
{/*声明式导航,适合固定菜单*/}
<Link to={'/article'}>跳转到文章页</Link>
<br/>
{/*命令式导航,比较灵活,在js代码中使用*/}
<button onClick={() => navigate('/article')}>跳转到文章页</button>
</div>
</div>

)
}

export default Login

image-20240823230035895

路由导航传参

searchParams传参

image-20240823230227206

login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {useNavigate} from "react-router-dom";

function Login() {
const navigate = useNavigate()
return (
<div>
欢迎登录
<div>
<button onClick={() => navigate('/article?id=100&name=张三')}>跳转到文章页</button>
</div>
</div>

)
}

export default Login

article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {useSearchParams} from "react-router-dom";

function Article(){
const [params] = useSearchParams();
const id = params.get('id');
const name = params.get('name');
return (
<div>
hello 文章页 id:{id} -name:{name}
</div>
)
}

export default Article

image-20240823231123939

params传参

image-20240823230427664

login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {useNavigate} from "react-router-dom";

function Login() {
const navigate = useNavigate()
return (
<div>
欢迎登录
<div>
<button onClick={() => navigate('/article/18/男')}>跳转到文章页</button>
</div>
</div>

)
}

export default Login

router/index.js

image-20240823231529254

article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {useParams} from "react-router-dom";

function Article(){
const params = useParams();
const age = params.age;
const sex = params.sex;
return (
<div>
hello 文章页 年龄:{age} -性别:{sex}
</div>
)
}

export default Article

嵌套路由

概念:在一级路由中又内嵌了其他路由,这种关系叫做嵌套路由,嵌套值一级路由内的路由又称为二级路由。

image-20240823231807727

步骤:

  1. 使用children属性配置路由嵌套关系
  2. 使用<Outlet/>组件配置二级路由渲染位置。

image-20240823231909804

router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import {createBrowserRouter} from "react-router-dom";
import Login from "../page/Login";
import Article from "../page/Article";
import Layout from "../page/Layout";
import About from "../page/About";
import Panel from "../page/Panel";


const router = createBrowserRouter([
{
path: '/',
element: <Layout/>,
children: [
{
path: 'about',
element: <About/>
},
{
path: 'panel',
element: <Panel/>
}
]
},
{
path: '/login',
element: <Login/>
},
{
path: '/article/:age/:sex',
element: <Article/>
}
])

export default router;

Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {Link, Outlet} from "react-router-dom";

function Layout(){
return (
<div>我是Layout页
<div>
<Link to={'/about'}>关于页</Link>
<br/>
<Link to={'/panel'}>面板</Link>
</div>
<Outlet></Outlet>
</div>

)
}

export default Layout;

image-20240823233559978

默认二级路由

当访问的是以及路由是,默认的二级路由组件可以得到渲染,只需要在二级路由的位置去掉path,设置index属性为true

image-20240823234014788

404路由配置

场景:当浏览器输入url的路径在整个路由配置中都找不到对应的path,为了用户体验,可以使用404兜底组件进行渲染。

步骤:

  1. 准备一个NotFound组件
  2. 在路由表数组的末尾,以*号作为路由path配置路由

image-20240823234338690

两种路由模式

history模式hash模式ReactRouter分别由createBrowerRoutercreateHashRouter函数负责创建

image-20240823234950013

钩子函数

useReducer

作用:和useState作用类似,用来管理相对复杂的状态数据。

image-20240827123737810

基础用法:

  1. 定义一个reducer函数(根据不同的action返回不同的新状态)
  2. 在组件中调用useReducer,并传入reducer函数和和状态初始值。
  3. 事件发生事,通过dispatch函数分派一个action对象(通知reducer要返回哪个新状态并渲染UI)

image-20240827122820575

分派action是传参:

image-20240827123551611

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {useReducer} from "react";

function reducer(state,action){
switch (action.type){
case "INC":
return state + 1
case "DEC":
return state - 1
case "SET":
return action.payload
default:
return state
}
}

function App() {
const [state, dispatch] = useReducer(reducer, 0)
return (<div className="App">
<button onClick={() => dispatch({type: "DEC"})}>-</button>
{state}
<button onClick={() => dispatch({type: "INC"})}>+</button>
<button onClick={() => dispatch({type: "SET", payload: 100})}>update</button>
</div>);
}
export default App;

useMemo

作用:在组件每次重新渲染的时候缓存计算的结果

需求:

image-20240827124858746

基础语法:

image-20240827124926432

说明:使用useMemo做缓存之后可以保证只有count1依赖项发生事才会重新计算。

React.memo

作用:允许组件在Props没有改变的情况下跳过渲染

React组件默认的渲染机制:只要父组件重新渲染子组件就会重新渲染。

image-20240827141336760

语法:

image-20240827142128374

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {memo, useState} from "react";

const MemoSon = memo(function Son() {
console.log("son组件渲染")
return (
<div>
this is son
</div>
)
})

function App() {
const [count, setCount] = useState(0)
return (<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>+</button>
<MemoSon/>
</div>);
}

export default App;

props的比较机制

机制:在使用memo缓存组件之后,React会对每一个prop使用Object.is比较新值和老值,返回true,表示没有变化。

prop是简单类型

Object.is(3,3)=> true 没有变化

prop是引用类型(对象/数组)

Object([],[]) =》false 有变化,React只更新引用是否变化。

  1. 传递一个简单类型的prop时, 当prop变化时,子组件重新渲染
  2. 传递一个引用类型的prop, 当父组件重新渲染时,prop本身没有变化,但是prop变成了新的引用,子组件重新渲染
  3. 保证引用稳定->使用useMemo缓存该引用类型
1
2
3
const list = useMemo(()=> {
return [1,2,3]
},[])

useCallback

作用:在组件多次重新渲染时缓存函数。

image-20240827145201515

说明:使用useCallback包裹函数之后,函数可以保证在App重新渲染的时候保持引用稳定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {memo, useCallback, useState} from "react";

const MemoSon = memo(function Son({onChange}) {
console.log("son组件渲染")
return (
<div>
<input onChange={(e) => onChange(e.target.value)}/>
</div>
)
})

function App() {
const [count, setCount] = useState(0)
// 传递给子组件的函数,useCallback是父组件渲染时,保证子组件不会渲染,把该函数缓存下来
const change = useCallback((value) => console.log(value),[])
return (<div className="App">
{count}
<button onClick={() => setCount(count + 1)}>+</button>
<MemoSon onChange={change}/>
</div>);
}

export default App;

forwardRef

场景说明:

image-20240827145755412

语法实现:

image-20240827145931410

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {forwardRef, useRef} from "react";

const Son = forwardRef((props, ref) => {
return <input type={"text"} ref={ref}/>
})

function App() {
const inputRef = useRef()
const show = () => {
inputRef.current.focus()
}
return (<div className="App">
<Son ref={inputRef}></Son>
<button onClick={show}>聚焦</button>
</div>);
}

export default App;

useInperativeHandlle

将子组件内部的函数暴露给父组件使用

场景说明:

image-20240827150950167

语法:

image-20240827151012385

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import {forwardRef, useImperativeHandle, useRef} from "react";

const Son = forwardRef((props, ref) => {
const inpRef = useRef()
// 子组件提供聚焦函数
const focus = () => {
inpRef.current.focus()
console.log('子组件focus方法被调用')
}
// 将该函数暴露出来,给父组件调用
useImperativeHandle(ref, () => {
return {
focus
}
})
return <input type={"text"} ref={inpRef}/>
})

function App() {
const inputRef = useRef()
const show = () => {
// 调用子组件的focus方法
inputRef.current.focus()
}
return (<div className="App">
<Son ref={inputRef}></Son>
<button onClick={show}>聚焦</button>
</div>);
}

export default App;

Class API(如今已不常用)

类组件基础结构

类组件就是通过JS中的类来组织组件的代码

image-20240827152104728

  1. 通过类属性state定义状态数据
  2. 通过setState方法来修改状态数据
  3. 通过render来写UI模板(JSX语法一致)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Counter extends Component {
// 定义状态变量
state = {
count: 0
}
//定义修改状态变量的方法
setCount = () => {
this.setState({
count: this.state.count + 1
})
}

// jsx
render() {
return <button onClick={this.setCount}>{this.state.count}</button>
}
}

function App() {
return (<div className="App">
<Counter></Counter>
</div>);
}

export default App;

类组件的说明周期函数

概念:组件从创建到销毁的各个阶段自动执行的函数就是生命周期函数

image-20240827153120686

  1. componetDidMount:组件挂载完毕自动执行——异步数据获取
  2. componentWillUnmount:组件卸载时自动执行——清除副作用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Counter extends Component {
// 组件挂载完毕自动执行
componentDidMount() {
this.timer = setInterval(() => {
console.log("子组件渲染了")
},1000)
}
// 组件卸载时自动执行
componentWillUnmount() {
clearInterval(this.timer)
console.log("子组件被卸载了")
}

// jsx
render() {
return <button>son</button>
}
}

function App() {
const [show, setShow] = useState(true)
return (<div className="App">
{ show && <Counter/> }
<button onClick={() =>setShow(false)}>卸载子组件</button>
</div>);
}

export default App;

类组件的组件通信

概念:类组件和Hooks编写的组件在组件通信的思想完全一致

  1. 父传子:通过prop绑定数据
  2. 子传父:通过prop绑定绑定父组件中的函数,子组件调用
  3. 兄弟通信:状态提升,通过父组件做桥接

image-20240827163146165

zustand

一个简单状态管理工具

快速上手

image-20240827163245146

官网:https://zustand-demo.pmnd.rs/

image-20240827212041651

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { create } from 'zustand'

const useStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}))

function Counter() {
const { count, inc } = useStore()
return (
<div>
<span>{count}</span>
<button onClick={inc}>one up</button>
</div>
)
}
1
npm i zustand

注意事项:

  1. 函数参数必须返回一个对象,对象内部编写状态数据和方法
  2. set是用来修改数据的专门方法必须调用它来修改数据
    • 语法1: 参数是函数,需要用到老数据的场景, 如上述。
    • 语法2:参数直接是一个对象 set({count:100})

异步支持

对于异步的支持不需要特殊的操作,直接在函数中编写异步逻辑,最好只需要调用set方法传入新状态即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {create} from "zustand";
import axios from "axios";
import {useEffect} from "react";

const counterStore = create((set) => ({
count: 1,
inc: () => set(state => ({
count: state.count + 1
})),
// 异步调用
channelList: [],
fetchGetChannelList: async () => {
const res = await axios.get("http://geek.itheima.net/v1_0/channels");
console.log("=>(App.js:12) res", res);
// 修改状态数据
set({
channelList: res.data.data.channels
})

}
}))


function App() {
const {count, inc,fetchGetChannelList,channelList} = counterStore();
useEffect(() => {
fetchGetChannelList()
}, []);
return (<div className="App">
<button onClick={inc}>{count}</button>
<ul>
{channelList.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>);
}

export default App;

image-20240827215212048

切片模式

场景:当单个store比较大的时候,可以采用切片模式进行模块拆分组合,类似于模块化。

image-20240827215345544

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import {create} from "zustand";
import axios from "axios";
import {useEffect} from "react";
// 拆分子模块
const counterStore = (set) => {
return {
count: 1,
inc: () => set(state => ({
count: state.count + 1
})),
}
}

const channelsStore = (set) => {
return {
// 异步调用
channelList: [],
fetchGetChannelList: async () => {
const res = await axios.get("http://geek.itheima.net/v1_0/channels");
console.log("=>(App.js:12) res", res);
set({
channelList: res.data.data.channels
})

}
}
}
// 组合子模块
const useStore = create((...arg) => ({
...counterStore(...arg),
...channelsStore(...arg)

}))

// 调用不变
function App() {
const {count, inc, fetchGetChannelList, channelList} = useStore();
useEffect(() => {
fetchGetChannelList()
}, []);
return (<div className="App">
<button onClick={inc}>{count}</button>
<ul>
{channelList.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>);
}

export default App;

结构化模块

image-20240827221441244

src/store/modules/channelsStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import axios from "axios";

const channelsStore = (set) => {
return{
channelList: [],
fetchGetChannelList: async () => {
const res = await axios.get("http://geek.itheima.net/v1_0/channels");
console.log("=>(App.js:12) res", res);
set({
channelList: res.data.data.channels
})

}
}
}

export {channelsStore}

src/store/index.js

1
2
3
4
5
6
7
8
9
10
11
import {create} from "zustand";
import {channelsStore} from "./modules/channelsStore";
import {counterStore} from "./modules/counterStore";

const useStore = create((...args) => ({
...channelsStore(...args),
...counterStore(...args)
}))


export {useStore};

src/App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const {count, inc, fetchGetChannelList, channelList} = useStore();
useEffect(() => {
fetchGetChannelList()
}, []);
return (<div className="App">
<button onClick={inc}>{count}</button>
<ul>
{channelList.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>);
}

export default App;

React与TypeScript

环境搭建

1
npm create vite@latest 项目名 -- --template react-ts
1
npm i

项目目录结构

image-20240827225637251

useState

自动推导

通常React会根据传入useState的默认值来自动推导类型,不需要显示标注类型。场景:适合明确的初始值

image-20240827225905907

说明:

  1. value:类型为boolean
  2. toggle:参数类型为boolean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {useState} from "react";

function App() {
// 初始化为boolean,泛型就是boolean值
const [value, setValue] = useState(false);
const changeValue = () => {
// 修改数据只能为boolean值的
setValue(true)
}
return (
<>
this is app {value.toString()}
<button onClick={changeValue}>setValue</button>
</>
)
}
export default App

传递泛型参数

useState本身是一个泛型函数,可以传入具体的自定义类型。

image-20240827230847275

说明:

  1. 限制useState函数参数的初始值必须满足类型User | () => User
  2. 限制setUser函数的参数必须满足类型为User | () => User | undefined
  3. user状态数据具备User类型相关的类型提示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import {useState} from "react";

type User = {
name: string,
age: number
}

function App() {

const [user, setUser] = useState<User>({
name: 'zhang',
age: 18
})
const changeUser = () => {
setUser( {
name: 'huang',
age: 20
})
}
return (
<>
this is app {user.name}{user.age}
<button onClick={changeUser}>change</button>
</>
)
}
export default App

初始值为null

当我们不知道状态的初始值是什么,将useState的初始值为null是一个插件的做法,可以通过具体类型联合null来做显示式注解

image-20240827232102463

说明:

  1. 限制useState函数参数的初始值可以是User|null

  2. 限制setUser函数的参数类型可以是User|null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import {useState} from "react";

type User = {
name: string,
age: number
}

function App() {

const [user, setUser] = useState<User|null>(null)
const changeUser = () => {
setUser(null)
setUser( {
name: 'huang',
age: 20
})
}
return (
<>
{/*为了类型安全,可选链做类型守卫*/}
{/*只有user不为null,(不为空值)的时候才进行点运算*/}
this is app {user?.name}{user?.age}
<button onClick={changeUser}>change</button>
</>
)
}
export default App

Props

基础用法

为组件prop添加类型,本质是给函数的参数做类型注解,可以使用type对象类型或interface接口来做注解

image-20240827232806251

说明:Button组件只能传入名称为className的prop参数,类型为string,且为必填

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Props = {
className: string
}
function Button(props: Props) {
const {className} = props
return (
<>
<button className={className}>click me</button>
</>
)
}

function App() {
return (
<>
<Button className={'body'}></Button>
</>
)
}

export default App

为children添加类型

children是一个比较特殊的prop,支持多种不同类型的传入,需要同规格一个内置的ReactNode类型来做注解

image-20240827233650051

说明:注解之后,children可以是多种类型,包括:React.ReactElement,string,number,React.ReactFragment,React.ReactPortal,boolean,null,undefined

为事件prop添加类型

组件经常执行类型为函数的prop实现子传父,子类prop重点在于函数参数类型的注解

image-20240827234056245

说明:

  1. 在组件内部调用时需要遵守类型的约束,参数传递需要满足要求
  2. 绑定prop是如果绑定内联函数直接可以推断处参数类型,否则需要单独注解匹配的参数类型

image-20240827235148483

useRef

获取dom

获取dom场景,可以直接把要获取的dom元素的类型当成泛型参数传递给useRef,可以推导出.current属性的类型。

image-20240827235436334

引用稳定的存储器

把useRef当成引用稳定的存储器使用的场景可以通过泛型传入联合类型来做,比如定时器的场景。

image-20240827235703812

B站评论案例——基础

image-20240821225726458

功能:

  • 渲染评论列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    const [commentList, setCommentList] = useState(defaultList); 



{/* 评论列表 */}
<div className="reply-list">
{/* 评论项 */}
{commentList.map(item => (
<div key={item.rpid} className="reply-item">
{/* 头像 */}
<div className="root-reply-avatar">
<div className="bili-avatar">
<img
className="bili-avatar-img"
alt=""
src={item.user.avatar}
/>
</div>
</div>

<div className="content-wrap">
{/* 用户名 */}
<div className="user-info">
<div className="user-name">{item.user.uname}</div>
</div>
{/* 评论内容 */}
<div className="root-reply">
<span className="reply-content">{item.content}</span>
<div className="reply-info">
{/* 评论时间 */}
<span className="reply-time">{item.ctime}</span>
{/* 评论数量 */}
<span className="reply-time">点赞数:{item.like}</span>
<span className="delete-btn"> 删除</span>
</div>
</div>
</div>
</div>
))}

</div>
  • 删除评论

需求:只有自己的评论才显示删除按钮,点击删除按钮,删除当前评论,列表中不再显示。

思路:删除显示——条件渲染,删除功能——拿到当前项id以id为条件对评论列表做filter过滤。

1
2
3
4
5
6
7
const [commentList, setCommentList] = useState(defaultList);
const handleDel = (rpid) => {
console.log(rpid)
// defaultList.filter(rpid)
setCommentList(defaultList.filter(item => item.rpid !== rpid))
}
{user.uid === item.user.uid && <span onClick={() => handleDel(item.rpid)} className="delete-btn"> 删除</span>}
  • 渲染导航tab和高亮实现

需求:点击哪个tab项,哪个做高亮处理

核心思路:点击谁就把谁的type(独一无二的标识)记录下来,然后和遍历时的每一项的type做匹配,谁匹配到就设置负责高亮的类名

1
2
3
4
5
6
7
const [type, setType] = useState('hot')     const handleChangeTab = (type) => {
setType(type)
}
<li className="nav-sort">
{/* 高亮类名: active */}
{tabs.map(item => <span key={item.type} onClick={() => handleChangeTab(item.type)} className={`nav-item ${item.type === type && 'active'}`}>{item.text}</span>)}
</li>
  • 评论列表排序功能实现

需求:点击最新,评论列表按照创建时间倒序排列(新的在前),点击最热按照点赞排序(多的在前)。

思路:把评论列表状态数据进行不同的排序处理,当成新值传给set函数重新渲染视图UI

1
npm i lodash -D
1
2
3
4
5
6
7
8
9
10
11
import _ from 'lodash'

const [commentList, setCommentList] = useState(_.orderBy(defaultList,'like', 'desc'));
const handleChangeTab = (type) => {
setType(type)
if (type === 'hot'){
setCommentList(_.orderBy(commentList, 'like', 'desc'));
}else if(type === 'time'){
setCommentList(_.orderBy(commentList, 'ctime', 'desc'));
}
}

image-20240822122028661

  • 评论功能

image-20240822125910576

  1. 获取评论内容
  2. 点击发布按钮发布评论
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 发布评论
const [comment, setComment] = useState('')
const handleSendText = () => {
setCommentList([
...commentList,
{
rpid: 4,
user: {
uid: '30009257',
avatar,
uname: '黑马前端',
},
content: comment,
ctime: '10-30 09:00',
like: 0,
},
])
setComment("")
}

<div className="reply-box-wrap">
{/* 评论框 */}
<textarea
className="reply-box-textarea"
placeholder="发一条友善的评论"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{/* 发布按钮 */}
<div className="reply-box-send" onClick={handleSendText}>
<div className="send-text">发布</div>
</div>
</div>

image-20240822131849936

  • id处理和时间处理

image-20240822132108490

  1. rpid要求一个唯一的随机值id ——UUID
  2. ctime要求以当前时间为标准,生成固定格式——dayjs
1
npm i uuid
1
2
3
import {v4 as uuidV4} from 'uuid';

rpid: uuidV4()
1
npm i dayjs
1
2
3
import dayjs from 'dayjs'
// 格式: 月-日 时:分
ctime: dayjs(new Date()).format("MM-DD hh:mm")
  • 发布后清空输入框,并重新聚焦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
++ const inputRef = useRef(null)
const handleSendText = () => {
setCommentList([
...commentList,
{
rpid: uuidV4(),
user: {
uid: '30009257',
avatar,
uname: '黑马前端',
},
content: comment,
ctime: dayjs(new Date()).format('MM-DD hh:mm' ),
like: 0,
},
])
// 发布后清空输入框,并重新聚焦输入框
++ setComment("")
++ inputRef.current.focus();
}

{/* 评论框 */}
<textarea
className="reply-box-textarea"
placeholder="发一条友善的评论"
value={comment}
onChange={(e) => setComment(e.target.value)}
++ ref={inputRef}
/>

优化需求

  • 通过接口获取评论列表
  1. 使用json-server工具模拟接口服务,通过axios发送接口请求
    • json-server是一个快速以.json文件作为数据源模拟接口服务的工具。
    • axios是一个广泛使用的前端请求库
  2. 使用useEffect调用接口获取数据。

步骤:

1)安装依赖:

1
2
npm i json-server -D
npm i axios -D
1
2
3
4
5
6
// package.js
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
++ "serve": "json-server db.json --port 3009"
},

2)在scr同级目录创建db.json文件:

3)通过useEffect配合axios发送请求

1
2
3
4
5
6
// 获取json数据
useEffect(async () => {
const res = await axios.get("http://localhost:3009/list")
console.log("数据:",res)
setCommentList(res.data)
},[])
  • 自定义hook函数封装数据请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 自定义封装获取数据hook
function useGetList(){
const [commentList, setCommentList] = useState([]);
// 获取json数据
useEffect(async () => {
const res = await axios.get("http://localhost:3009/list")
console.log("数据:",res)
setCommentList(res.data)
},[])
return {
commentList,
setCommentList
}
}

const {commentList, setCommentList}= useGetList();
  • 封装评论项item组件

image-20240822211627695

抽象原则:APP作为”只能组件”否则数据的获取,item作为”UI组件”负责数据的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 评论项组件
function Item({item,onDel}){
return (
<div className="reply-item">
{/* 头像 */}
<div className="root-reply-avatar">
<div className="bili-avatar">
<img
className="bili-avatar-img"
alt=""
src={item.user.avatar}
/>
</div>
</div>

<div className="content-wrap">
{/* 用户名 */}
<div className="user-info">
<div className="user-name">{item.user.uname}</div>
</div>
{/* 评论内容 */}
<div className="root-reply">
<span className="reply-content">{item.content}</span>
<div className="reply-info">
{/* 评论时间 */}
<span className="reply-time">{item.ctime}</span>
{/* 评论数量 */}
<span className="reply-time">点赞数:{item.like}</span>
{user.uid === item.user.uid &&
<span onClick={() => onDel(item.rpid)} className="delete-btn"> 删除</span>}
</div>
</div>
</div>
</div>
)
}


{/* 评论列表 */}
<div className="reply-list">
{/* 评论项 */}
{/*父传子——item, 子传父——onDel*/}
{commentList.map(item => (
<Item key={item.rpid} onDel={handleDel} item={item}></Item>
))}

</div>

美团外卖案例——Redux

image-20240823133425074

开发思路:使用RTK(Redux Toolkit)来管理应用状态,组件负责数据渲染和dispatch action

环境准备

  1. 克隆项目到本地(内置了进程金泰组件和模板)
1
git clone http://git.itcast.cn/heimaqianduan/redux-meituan.git
  1. 安装所有依赖
1
npm i
  1. 启动mock服务(内置了json-server)
1
npm run serve
  1. 启动前端服务
1
npm run start

商品和列表渲染

思路:将商品数据保存状态管理redux中,通过useEffect异步发送请求获取mock数据,将数据渲染到页面。

src/store/modules/takeaway.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import {createSlice} from "@reduxjs/toolkit";
import axios from "axios";

const takeawayStore = createSlice({
name: "takeway",
initialState: {
foodsList: []
},
reducers: {
getFoodsList(state, action){
state.foodsList = action.payload
}
}
})

const {getFoodsList} = takeawayStore.actions
// 定义异步方法
const fetchTakewayList = () => {
return async(dispatch) => {
const res = await axios.get('http://localhost:3004/takeaway');
console.log(res)
dispatch(getFoodsList(res.data))
}
}

export {fetchTakewayList}
const takewayReducer = takeawayStore.reducer;
export default takewayReducer;

src/store/index.js

1
2
3
4
5
6
7
8
9
import {configureStore} from "@reduxjs/toolkit";
import takewayReducer from "./modules/takeaway";
const store = configureStore({
reducer: {
takewayStore: takewayReducer
}
})

export default store

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react'
import { createRoot } from 'react-dom/client'

import App from './App'
import {Provider} from "react-redux";
import store from "./store";


const root = createRoot(document.getElementById('root'))
root.render(
<Provider store={store}>
<App />
</Provider>

)

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import NavBar from './components/NavBar'
import Menu from './components/Menu'
import Cart from './components/Cart'
import FoodsCategory from './components/FoodsCategory'

import './App.scss'
import {useDispatch, useSelector} from "react-redux";
import {useEffect} from "react";
import {fetchTakewayList} from "./store/modules/takeaway";

const App = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchTakewayList())
},[dispatch])
const {foodsList} = useSelector(state => state.takewayStore)
return (
<div className="home">
{/* 导航 */}
<NavBar />

{/* 内容 */}
<div className="content-wrap">
<div className="content">
<Menu foodsList={foodsList}/>

<div className="list-content">
<div className="goods-list">
{/* 外卖商品列表 */}
{foodsList.map(item => {
return (
<FoodsCategory
key={item.tag}
// 列表标题
name={item.name}
// 列表商品
foods={item.foods}
/>
)
})}
</div>
</div>
</div>
</div>

{/* 购物车 */}
<Cart />
</div>
)
}

export default App

image-20240823173653068

导航样式动态激活

思路:状态管理activeIndex === 当前index显示,否则不显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 const {foodsList,activeIndex} =  useSelector(state => state.takewayStore)

{/* 内容 */}
<div className="content-wrap">
<div className="content">
<Menu foodsList={foodsList}/>

<div className="list-content">
<div className="goods-list">
{/* 外卖商品列表 */}
{foodsList.map((item,index) => {
return (
++ activeIndex === index && <FoodsCategory
key={item.tag}
// 列表标题
name={item.name}
// 列表商品
foods={item.foods}
/>
)
})}
</div>
</div>
</div>
</div>

image-20240823175846596

添加购物车

步骤:

  1. 使用RTK管理新状态cartList
  2. 如果添加过,只更新数量count,没有添加过,直接push进去
  3. 组件中点击时手机数据提交action添加购物车。

src/store/modules/takeaway.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 使用 createSlice 创建一个名为 "takeway" 的 Redux slice
const takeawayStore = createSlice({
name: "takeway", // slice 的名称
initialState: { // 初始状态
foodsList: [], // 食品列表,初始为空数组
activeIndex: 0,//动态激活导航样式,
cartList: [] // 购物车列表
},
reducers: { // 减少器,用于处理状态变化
...
// 添加购物车,若为添加过直接push,否则count+1
addCart(state,action){
const item = state.cartList.find(item => item.id === action.payload.id);
if (item) {
item.count ++;
}else{
state.cartList.push(action.payload);
}
}
}
})

src/components/FoodsCategory/FoodItem/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import './index.scss'
import {useDispatch, useSelector} from "react-redux";

import {addCart} from "../../../store/modules/takeaway";

const Foods = ({
id,
picture,
name,
unit,
description,
food_tag_list,
month_saled,
like_ratio_desc,
price,
tag,
count = 1
}) => {

const dispatch = useDispatch();

return (
<dd className="cate-goods">
<div className="goods-img-wrap">
<img src={picture} alt="" className="goods-img" />
</div>
<div className="goods-info">
<div className="goods-desc">
<div className="goods-title">{name}</div>
<div className="goods-detail">
<div className="goods-unit">{unit}</div>
<div className="goods-detail-text">{description}</div>
</div>
<div className="goods-tag">{food_tag_list.join(' ')}</div>
<div className="goods-sales-volume">
<span className="goods-num">月售{month_saled}</span>
<span className="goods-num">{like_ratio_desc}</span>
</div>
</div>
<div className="goods-price-count">
<div className="goods-price">
<span className="goods-price-unit">¥</span>
{price}
</div>
<div className="goods-count">
<span className="plus" onClick={() => dispatch(addCart({
id,
picture,
name,
unit,
description,
food_tag_list,
month_saled,
like_ratio_desc,
price,
tag,
count
}))}>+</span>
</div>
</div>
</div>
</dd>
)
}

export default Foods

统计区域

实现步骤:

  1. 基于store中的cartList的length渲染数量
  2. 基于store的cartList累加price*count
  3. 购物车cartlist的length不为零则高亮
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { cartList } = useSelector(state => state.takewayStore)
const totalPrice = cartList.reduce((sum, item) => sum + item.count * item.price, 0)


{/* fill 添加fill类名可以切换购物车状态*/}
{/* 购物车数量 */}
<div className={classNames('icon', cartList.length > 0 && 'fill' )}>
{true && <div className="cartCornerMark">{cartList.length}</div>}
</div>

{/* 结算 or 起送 */}
{cartList.length > 0 ? (
<div className="goToPreview">去结算</div>
) : (
<div className="minFee">¥20起送</div>
)}

image-20240823195413330

购物车列表

步骤:

  1. 使用cartList遍历渲染列表
  2. RTK中增加增减reducer,组件中提交action
  3. RTK中增加清除购物车reducer,组件中提交action
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 使用 createSlice 创建一个名为 "takeway" 的 Redux slice
const takeawayStore = createSlice({
name: "takeway", // slice 的名称
initialState: { // 初始状态
foodsList: [], // 食品列表,初始为空数组
activeIndex: 0,//动态激活导航样式,
cartList: [] // 购物车列表
},
reducers: { // 减少器,用于处理状态变化
......
// 购物车商品数量的增减
increment(state, action) {
const item = state.cartList.find(item => item.id === action.payload.id);
if (item) {
item.count++;
}
},
decrement(state, action) {
const item = state.cartList.find(item => item.id === action.payload.id);
if (item && item.count >= 1) {
item.count--;
}
},
// 清空购物车
clearCart(state) {
state.cartList = []
}
}
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const dispatch = useDispatch()

<span
onClick={() => dispatch(clearCart())}
className="clearCart">
清空购物车
</span>

<div className="skuBtnWrapper btnGroup">
<Count
count={item.count}
onPlus={() => dispatch(increment(item))}
onMinus={() => dispatch(decrement(item))}
/>
</div>

image-20240823202517418

控制购物车显隐

步骤:

  1. 使用useState声明控制显隐
  2. 点击统计区域设置状态为true
  3. 点击蒙层区域设置为false
1
2
3
4
5
6
7
8
9
10
11
12
const [show, setShow] = useState(false)
const handleShow = () => {
if (cartList.length > 0){
setShow(true)
}
}

{/* 遮罩层 添加visible类名可以显示出来 */}
<div
onClick={() => setShow(false)}
className={classNames('cartOverlay',show &&'visible')}
/>

image-20240823204311986

记账本-router

环境搭建

功能演示

image-20240824124345400

搭建

使用CRA创建项目,安装必要依赖,包括下列基础包。

  1. Redux状态管理——@reduxjs/toolkit,react-redux
  2. 路由——react-router-dom
  3. 时间处理——dayjs
  4. class类名处理——classnames
  5. 移动端组件库——antd-mobile
  6. 请求插件——axios
1
npm i @reduxjs/toolkit react-redux react-router-dom dayjs classnames antd-mobile axios

配置别名路径@

  1. 路径解析配置(webpack),把@/解析为src/
  2. 路径联想配置(VsCode),VsCode在输入@/时,自动联想出来对应的src/下的子级目录

image-20240824130706611

CRA本身把webpack配置包装到了黑盒里无法直接修改,需要借助一个插件——craco

步骤:

  1. 安装craco
1
npm i -D @craco/craco
  1. 项目根目录下创建配置文件“craco.config.js
  2. 配置文件中添加路径解析配置
  3. 包文件中配置启动和打包命令

image-20240824131021444

1
2
3
4
5
6
7
8
9
10
11
const path = require('path')

module.exports = {
// webpack配置
webpack: {
// 配置别名
alias: {
'@': path.resolve(__dirname, 'src')
}
}
}

联想路径配置

VsCode的联想配置,需要我们在项目目录下添加jsconfig.json,加入配置之后Vscode会自动读取配置帮助我们自动联想提示。

配置步骤:

  1. 根目录下新增文件——jsconfig.json
  2. 添加路径提示配置

image-20240824133327580

1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
}
}

数据Mock实现

在前后端分离开发模式下,前端可以在没有在实际后端接口的支持下先进行接口数据的模拟,进行正常的业务功能开发。

市场常见的Mock方式

image-20240824133803506

使用json-server工具模拟接口服务,通过axios发送接口请求

  • json-server是一个快速以.json文件作为数据源模拟接口服务的工具。

步骤:

1)安装依赖:

1
npm i json-server -D
1
2
3
4
5
6
// package.js
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
++ "serve": "json-server ./server/db.json --port 3009"
},

2)在scr同级目录创建server/db.json文件

image-20240824135615583

整体路由设计

image-20240824140324258

两个一级路由:/(Layout页), /new(新建页)

两个二级路由: /mouth(月收支账单),/year(年收支账单)

当前目录结构

image-20240824141558221

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {createBrowserRouter} from "react-router-dom";
import Layout from "@/page/Layout";
import Mouth from "@/page/Mouth";
import Year from "@/page/Year";
import New from "@/page/New";


const router = createBrowserRouter([
{
path:'/',
element: <Layout/>,
children: [
{
index: true,
element: <Mouth/>
},
{
path: 'year',
element: <Year/>
}
]
},
{
path: 'new',
element: <New/>
}
])

export default router;

src/index.js

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {RouterProvider} from "react-router-dom";
import router from "@/router";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
++ <RouterProvider router={router}></RouterProvider>
);

src/page/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
import {Outlet} from "react-router-dom";

function Layout(){
return (
<div>我是layout
++ <Outlet/>
</div>
)
}

export default Layout;

image-20240824141752371

antD-mobile主题定制

image-20240824141928293

定制方案

  1. 全局定制:整个应用范围内组件都生效
  2. 局部定制:只在某些元素内部的中间生效

image-20240824142249426

实现方式

image-20240824142233822

theme.css

1
2
3
4
5
6
7
8
/*全局注册*/
:root:root{
--adm-color-primary: rgb(105,172,120);
}
/*局部*/
.purple-theme{
--adm-color-primary: rgb(105,172,120);
}

Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {Outlet} from "react-router-dom";
import {Button} from "antd-mobile";

function Layout(){
return (
<div>我是layout
<Button color="primary">全局测试</Button>
<div className="purple-theme">
<Button color="primary">局部测试</Button>
</div>
<Outlet/>
</div>
)
}

export default Layout;

image-20240824160407101

全局定制的优先级大于局部定制。局部定制只能在局部使用,全局定制可以在全局使用。

Redux管理账目列表

image-20240824160606332

步骤:

src/store/modules/billStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import {createSlice} from "@reduxjs/toolkit";
import axios from "axios";

// 创建store
const billStore = createSlice({
// store 名字
name: 'bill',
// 数据状态管理,数据初始化
initialState: {
billList: []
},
// 同步修改数据状态的方法
reducers: {
setBillStore(state, action){
state.billList = action.payload;
}
}
})


// 结构异步修改的方法
const { setBillStore } = billStore.actions;
// 异步获取数据
const getBillList =() => {
return async (dispatch) => {
// 编写异步请求,dispatch异步修改状态管理的数据billList
const res = await axios.get("http://localhost:3009/ka")
dispatch(setBillStore(res.data))
}
}

export {getBillList}

// 将reducers导出,给index.js组合
const billReducer = billStore.reducer;
export default billReducer;

src/store/index.js

1
2
3
4
5
6
7
8
9
10
import {configureStore} from "@reduxjs/toolkit";
import billReducer from "@/store/modules/billStore";
// 组合子模块,将其导出
const store = configureStore({
reducer: {
bill: billReducer
}
})

export default store;

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {RouterProvider} from "react-router-dom";
import router from "@/router";
import '@/theme.css'
import {Provider} from "react-redux";
import store from "@/store";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
// 将Redux和react组合
<Provider store={store}>
{/*路由配置*/}
<RouterProvider router={router}></RouterProvider>
</Provider>

);

测试src/page/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {Outlet} from "react-router-dom";
import {useDispatch} from "react-redux";
import {useEffect} from "react";
import {getBillList} from "@/store/modules/billStore";

function Layout(){
// 调用异步获取billList
const dispatch = useDispatch();
// 页面创建时,触发调用函数
useEffect(()=> {
dispatch(getBillList());
},[dispatch])
return (
<div>
<Outlet/>
</div>
)
}
export default Layout;

image-20240824163342248

TabBar功能

需求:使用antD的TabBar标签栏组件进行布局以及路由的切换。

image-20240824163554196

src/page/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { TabBar } from "antd-mobile"
import { useEffect } from "react"
import {Outlet, useNavigate} from "react-router-dom"
import { useDispatch } from 'react-redux'
import { getBillList } from "@/store/modules/billStore"
import './index.scss'
import {
BillOutline,
CalculatorOutline,
AddCircleOutline
} from 'antd-mobile-icons'
// tab列表,通过循环展示
const tabs = [
{
key: '/month',
title: '月度账单',
icon: <BillOutline />,
},
{
key: '/new',
title: '记账',
icon: <AddCircleOutline />,
},
{
key: '/year',
title: '年度账单',
icon: <CalculatorOutline />,
},
]

const Layout = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(getBillList())
}, [dispatch])
const navigate = useNavigate();
// 切换路由
const switchRouter = (path) => {
navigate(path)
}
return (
<div className="layout">
<div className="container">
<Outlet />
</div>
<div className="footer">
<TabBar onChange={switchRouter}>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
</div>
</div>
)
}

export default Layout

src/page/Layout/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
.layout {
.container {
position: fixed;
top: 0;
bottom: 50px;
}
.footer {
position: fixed;
bottom: 0;
width: 100%;
}
}

月度账单

静态结构搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { NavBar, DatePicker } from 'antd-mobile'
import './index.scss'

const Month = () => {
return (
<div className="monthlyBill">
<NavBar className="nav" backArrow={false}>
月度收支
</NavBar>
<div className="content">
<div className="header">
{/* 时间切换区域 */}
<div className="date">
<span className="text">
2023 | 3月账单
</span>
<span className='arrow expand'></span>
</div>
{/* 统计区域 */}
<div className='twoLineOverview'>
<div className="item">
<span className="money">{100}</span>
<span className="type">支出</span>
</div>
<div className="item">
<span className="money">{200}</span>
<span className="type">收入</span>
</div>
<div className="item">
<span className="money">{200}</span>
<span className="type">结余</span>
</div>
</div>
{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={false}
max={new Date()}
/>
</div>
</div>
</div >
)
}

export default Month
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
.monthlyBill {
--ka-text-color: #191d26;
height: 100%;
background: linear-gradient(180deg, #ffffff, #f5f5f5 100%);
background-size: 100% 240px;
background-repeat: no-repeat;
background-color: rgba(245, 245, 245, 0.9);
color: var(--ka-text-color);

.nav {
--adm-font-size-10: 16px;
color: #121826;
background-color: transparent;
.adm-nav-bar-back-arrow {
font-size: 20px;
}
}

.content {
height: 573px;
padding: 0 10px;
overflow-y: scroll;
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}

> .header {
height: 135px;
padding: 20px 20px 0px 18.5px;
margin-bottom: 10px;
background-image: url(https://chunimages.oss-cn-guangzhou.aliyuncs.com/9ba0309483fdf5fb70b37cc24c0ba20c.jpeg);
background-size: 100% 100%;

.date {
display: flex;
align-items: center;
margin-bottom: 25px;
font-size: 16px;

.arrow {
display: inline-block;
width: 7px;
height: 7px;
margin-top: -3px;
margin-left: 9px;
border-top: 2px solid #121826;
border-left: 2px solid #121826;
transform: rotate(225deg);
transform-origin: center;
transition: all 0.3s;
}
.arrow.expand {
transform: translate(0, 2px) rotate(45deg);
}
}
}
}
.twoLineOverview {
display: flex;
justify-content: space-between;
width: 250px;

.item {
display: flex;
flex-direction: column;

.money {
height: 24px;
line-height: 24px;
margin-bottom: 5px;
font-size: 18px;
}
.type {
height: 14px;
line-height: 14px;
font-size: 12px;
}
}
}
}

点击切换选择框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const [isShow, setIsShow] = useState(false)
// 处理时间选择框点击确定的事件
const handleConfirm = () => {
setIsShow(false)
}
// 点击图标上下反转
<span className= {classNames('arrow', isShow && 'expand')} onClick={() => setIsShow(true)}></span>


{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={isShow}
max={new Date()}
// 点击蒙层关闭时间选择器
onClose={() => {setIsShow(false) }}
// 点击确认关闭时间选择器
onConfirm={handleConfirm}
// 点击取消关闭时间选择器
onCancel={() => {setIsShow(false) }}
/>

image-20240824173335838

点击确定切换时间显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 记录当前时间
const [currentData, setCurrentData] = useState(() => {
// 格式化时间格式
return dayjs(new Date()).format("YYYY-MM")
})
// 处理时间选择框点击确定的事件
const handleConfirm = (data) => {
setIsShow(false)
setCurrentData(dayjs(data).format("YYYY-MM"))
}

<span className="text">
{ currentData } 月账单
</span>

image-20240824174302518

账单数据按月分组

当前后端返回的数据时简单的平铺,表示按月份划分好的,而是要做的功能是以月为单位的统计

image-20240824184218123

1
2
// 对数组进行计算
npm i lodash -D
1
2
3
4
5
6
7
// 从状态管理中获取数据列表
const billList = useSelector(state => state.bill.billList)
// 按月份进行分组 useMemo-类似与vue的computed钩子函数
const billGroup = useMemo(() => {
return _.groupBy(billList,(item) => dayjs(item.date).format("YYYY-MM"))
}, [billList]);
console.log(billGroup)

image-20240824190946009

计算选择月份的统计数据

image-20240824191210533

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 //  处理时间选择框点击确定的事件
const handleConfirm = (data) => {
setIsShow(false)
const formatDate = dayjs(data).format("YYYY-MM");
// 修改当前时间格式
setCurrentData(formatDate)
// 获取该月的账单
++ setCurrentMouth(billGroup[formatDate])
}

// 记录当月的数据
const [currentMouth, setCurrentMouth] = useState([]);
// 计算当月的收入,支出,结余
const mouthResult = useMemo(() => {
// 若currentMouth为空返回默认
if (!currentMouth){
return {
pay:0,
income:0,
total: 0
} ;
}
// 收入
const pay = currentMouth.filter(item => item.type === 'pay').reduce((sum,item) => sum +item.money,0);
// 支出
const income = currentMouth.filter(item => item.type === 'income').reduce((sum,item) => sum +item.money,0);
return{
pay,
income,
total: pay + income
}
},[currentMouth])

image-20240824194752587

月度初始化时渲染统计数据

需求:打开月度账单是,把当前月份的统计数据渲染到页面中

image-20240824195201194

1
2
3
4
5
6
7
8
9
// 页面初始化时,显示当前月份的数据
useEffect(()=> {
const formatDate = dayjs().format("YYYY-MM");
if (billGroup[formatDate]){
console.log(billGroup[formatDate])
// 获取该月的账单
setCurrentMouth(billGroup[formatDate])
}
},[billGroup])

单日统计列表实现

需求:把当前月的账单数据以单日为单位进行统计显示.

image-20240824200853383

基础组件:

src/page/Mouth/components/DayBill/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import classNames from 'classnames'
import './index.scss'
import {useMemo} from "react";

const DailyBill = ({date, billList}) => {
// 计算当日的收入,支出,结余
const dayResult = useMemo(() => {
// 若currentMouth为空返回默认
if (!billList) {
return {
pay: 0,
income: 0,
total: 0
};
}
// 收入
const pay = billList.filter(item => item.type === 'pay').reduce((sum,item) => sum +item.money,0);
// 支出
const income = billList.filter(item => item.type === 'income').reduce((sum,item) => sum +item.money,0);
return{
pay,
income,
total: pay + income
}
},[billList])
return (
<div className={classNames('dailyBill')}>
<div className="header">
<div className="dateIcon">
<span className="date">{date}</span>
<span className={classNames('arrow')}></span>
</div>
<div className="oneLineOverview">
<div className="pay">
<span className="type">支出</span>
<span className="money">{dayResult.pay}</span>
</div>
<div className="income">
<span className="type">收入</span>
<span className="money">{dayResult.income}</span>
</div>
<div className="balance">
<span className="money">{dayResult.total}</span>
<span className="type">结余</span>
</div>
</div>
</div>
</div>
)
}
export default DailyBill;

src/page/Mouth/components/DayBill/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
.dailyBill {
margin-bottom: 10px;
border-radius: 10px;
background: #ffffff;

.header {
--ka-text-color: #888c98;
padding: 15px 15px 10px 15px;

.dateIcon {
display: flex;
justify-content: space-between;
align-items: center;
height: 21px;
margin-bottom: 9px;
.arrow {
display: inline-block;
width: 5px;
height: 5px;
margin-top: -3px;
margin-left: 9px;
border-top: 2px solid #888c98;
border-left: 2px solid #888c98;
transform: rotate(225deg);
transform-origin: center;
transition: all 0.3s;
}
.arrow.expand {
transform: translate(0, 2px) rotate(45deg);
}

.date {
font-size: 14px;
}
}
}
.oneLineOverview {
display: flex;
justify-content: space-between;

.pay {
flex: 1;
.type {
font-size: 10px;
margin-right: 2.5px;
color: #e56a77;
}
.money {
color: var(--ka-text-color);
font-size: 13px;
}
}

.income {
flex: 1;
.type {
font-size: 10px;
margin-right: 2.5px;
color: #4f827c;
}
.money {
color: var(--ka-text-color);
font-size: 13px;
}
}

.balance {
flex: 1;
margin-bottom: 5px;
text-align: right;

.money {
line-height: 17px;
margin-right: 6px;
font-size: 17px;
}
.type {
font-size: 10px;
color: var(--ka-text-color);
}
}
}

.billList {
padding: 15px 10px 15px 15px;
border-top: 1px solid #ececec;
.bill {
display: flex;
justify-content: space-between;
align-items: center;
height: 43px;
margin-bottom: 15px;

&:last-child {
margin-bottom: 0;
}

.icon {
margin-right: 10px;
font-size: 25px;
}
.detail {
flex: 1;
padding: 4px 0;
.billType {
display: flex;
align-items: center;
height: 17px;
line-height: 17px;
font-size: 14px;
padding-left: 4px;
}
}
.money {
font-size: 17px;

&.pay {
color: #ff917b;
}
&.income {
color: #4f827c;
}
}
}
}
}
.dailyBill.expand {
.header {
border-bottom: 1px solid #ececec;
}
.billList {
display: block;
}
}

src/page/Mouth/index.js

1
2
3
4
5
6
7
8
9
10
11
12
// 按日进行分组
const dayGroup = useMemo(() => {
const group = _.groupBy(currentMouth,(item) => dayjs(item.date).format("YYYY-MM-DD"))
const keys = Object.keys(group);
return{
group,
keys
}
}, [currentMouth]);


{ dayGroup.keys.map((item, index) => <DailyBill key={index} date={item} billList={dayGroup.group[item]}/>)}

单日账单列表显示

src/page/Mouth/components/DayBill/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 控制时间选择器显隐
const [isShow, setIsShow] = useState(false)
{/* 单日列表 */}
{isShow && <div className="billList">
{billList.map(item => {
return (
<div className="bill" key={item.id}>
<div className="detail">
<div className="billType">{billTypeToName[item.useFor]}</div>
</div>
<div className={classNames('money', item.type)}>
{item.money.toFixed(2)}
</div>
</div>
)
})}
</div>}

image-20240824224638808

账单类型图标组件封装

需求:封装一个图标组件,可以根据不同的账单类型显示不同的图标。

image-20240824225100252

src/components/Icon/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Icon = ({type}) => {
return (
<img
src={`https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/${type}.svg`}
alt="icon"
style={{
width: 20,
height: 20,
}}
/>
)
}

export default Icon

src/page/Mouth/components/DayBill/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{/* 单日列表 */}
{isShow && <div className="billList">
{billList.map(item => {
return (
<div className="bill" key={item.id}>
++ <Icon type={item.useFor}/>
<div className="detail">
<div className="billType">{billTypeToName[item.useFor]}</div>
</div>
<div className={classNames('money', item.type)}>
{item.money.toFixed(2)}
</div>
</div>
)
})}
</div>}

image-20240824225852815

新增账单

基础页面搭建

src/page/New/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { Button, DatePicker, Input, NavBar } from 'antd-mobile'
import Icon from '@/components/Icon'
import './index.scss'
import classNames from 'classnames'
import { billListData } from '@/contants'
import { useNavigate } from 'react-router-dom'

const New = () => {
const navigate = useNavigate()
return (
<div className="keepAccounts">
<NavBar className="nav" onBack={() => navigate(-1)}>
记一笔
</NavBar>

<div className="header">
<div className="kaType">
<Button
shape="rounded"
className={classNames('selected')}
>
支出
</Button>
<Button
className={classNames('')}
shape="rounded"
>
收入
</Button>
</div>

<div className="kaFormWrapper">
<div className="kaForm">
<div className="date">
<Icon type="calendar" className="icon" />
<span className="text">{'今天'}</span>
<DatePicker
className="kaDate"
title="记账日期"
max={new Date()}
/>
</div>
<div className="kaInput">
<Input
className="input"
placeholder="0.00"
type="number"
/>
<span className="iconYuan">¥</span>
</div>
</div>
</div>
</div>

<div className="kaTypeList">
{billListData['pay'].map(item => {
return (
<div className="kaType" key={item.type}>
<div className="title">{item.name}</div>
<div className="list">
{item.list.map(item => {
return (
<div
className={classNames(
'item',
''
)}
key={item.type}

>
<div className="icon">
<Icon type={item.type} />
</div>
<div className="text">{item.name}</div>
</div>
)
})}
</div>
</div>
)
})}
</div>

<div className="btns">
<Button className="btn save">
保 存
</Button>
</div>
</div>
)
}

export default New

src/page/New/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
.keepAccounts {
--ka-bg-color: #daf2e1;
--ka-color: #69ae78;
--ka-border-color: #191d26;

height: 100%;
background-color: var(--ka-bg-color);

.nav {
--adm-font-size-10: 16px;
color: #121826;
background-color: transparent;
&::after {
height: 0;
}

.adm-nav-bar-back-arrow {
font-size: 20px;
}
}

.header {
height: 132px;

.kaType {
padding: 9px 0;
text-align: center;

.adm-button {
--adm-font-size-9: 13px;

&:first-child {
margin-right: 10px;
}
}
.selected {
color: #fff;
--background-color: var(--ka-border-color);
}
}

.kaFormWrapper {
padding: 10px 22.5px 20px;

.kaForm {
display: flex;
padding: 11px 15px 11px 12px;
border: 0.5px solid var(--ka-border-color);
border-radius: 9px;
background-color: #fff;

.date {
display: flex;
align-items: center;
height: 28px;
padding: 5.5px 5px;
border-radius: 4px;
// color: #4f825e;
color: var(--ka-color);
background-color: var(--ka-bg-color);

.icon {
margin-right: 6px;
font-size: 17px;
}
.text {
font-size: 16px;
}
}

.kaInput {
flex: 1;
display: flex;
align-items: center;

.input {
flex: 1;
margin-right: 10px;
--text-align: right;
--font-size: 24px;
--color: var(--ka-color);
--placeholder-color: #d1d1d1;
}

.iconYuan {
font-size: 24px;
}
}
}
}
}

.container {
}
.kaTypeList {
height: 490px;
padding: 20px 11px;
padding-bottom: 70px;
overflow-y: scroll;
background: #ffffff;
border-radius: 20px 20px 0 0;
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
&::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}

.kaType {
margin-bottom: 25px;
font-size: 12px;
color: #333;

.title {
padding-left: 5px;
margin-bottom: 5px;
font-size: 13px;
color: #808080;
}
.list {
display: flex;

.item {
width: 65px;
height: 65px;
padding: 9px 0;
margin-right: 7px;
text-align: center;
border: 0.5px solid #fff;
&:last-child {
margin-right: 0;
}

.icon {
height: 25px;
line-height: 25px;
margin-bottom: 5px;
font-size: 25px;
}
}
.item.selected {
border: 0.5px solid var(--ka-border-color);
border-radius: 5px;
background: var(--ka-bg-color);
}
}
}
}

.btns {
position: fixed;
bottom: 15px;
width: 100%;
text-align: center;

.btn {
width: 200px;
--border-width: 0;
--background-color: #fafafa;
--text-color: #616161;
&:first-child {
margin-right: 15px;
}
}
.btn.save {
--background-color: var(--ka-bg-color);
--text-color: var(--ka-color);
}
}
}

image-20240824230441459

支出和收入切换功能实现

image-20240824230640191

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const [billType, setBillType] = useState("pay")



<div className="kaType">
<Button
shape="rounded"
++ className={classNames(billType === 'pay' && 'selected')}
++ onClick={() => setBillType('pay')}
>
支出
</Button>
<Button
++ className={classNames(billType === 'income' && 'selected')}
shape="rounded"
++ onClick={() => setBillType('income')}
>
收入
</Button>
</div>

{billListData[billType].map(item => {
return (...)})
}

新增账单

image-20240824231616616

src/page/New/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const dispatch = useDispatch();
const [billType, setBillType] = useState("pay")
const [money, setMoney] = useState('')
const [useFor, setUseFor] = useState('')

// 获取输入框中money
const onChangeMoney = (data) => {
setMoney(data)
}
// 新增账单
const addBill = () => {
const data = {
type: billType,
money: billType === 'pay' ? -money : +money,
date: new Date(),
useFor: useFor
}
dispatch(postBill(data))
}

<div className="kaInput">
<Input
className="input"
placeholder="0.00"
type="number"
value={money}
onChange={onChangeMoney}
/>
<span className="iconYuan">¥</span>
</div>

src/store/modules/billStore.js

1
2
3
4
5
6
7
8
9
10
// 同步修改数据状态的方法
reducers: {
setBillStore(state, action){
state.billList = action.payload;
},
//同步新增账单
addBill(state, action){
state.billList.push(action.payload)
}
}
1
2
3
4
5
6
7
8
9
// 异步新增账单
const postBill = (data) => {
return async (dispatch) => {
// 编写异步请求,dispatch异步修改状态管理的数据billList
const res = await axios.post("http://localhost:3009/ka", data)
dispatch(addBill(data))
}
}
export {getBillList,postBill}

极客-综合实践

搭建

初始化环境

  1. 使用CRA创建项目
1
npx create-react-app react-jike
  1. 按照业务规范整理项目目录(重点src目录)

image-20240825132547199

安装Scss

概念:Scss是一种后缀名为.scss的预编译CSS语言,支持一些原生CSS不支持的高级用法,比如变量使用,嵌套语法等,使用Scss可以让样式代码更加高效灵活

CRA项目接入scss:

1
npm i sass -D

测试.scss文件是否可用(嵌套语法)

image-20240825133308821

安装 Ant Design

介绍:Ant Design是有蚂蚁金服产品的社区使用最广的React PC端组件库,内置了常用的现成组件,可以帮助我们快速开发PC管理后台项目。

官网:https://ant-design.antgroup.com/docs/react/introduce-cn

image-20240825133834538

项目中引用:

1
npm install antd --save

示例:

1
2
3
4
5
6
7
8
import React from 'react';
import { DatePicker } from 'antd';

const App = () => {
return <DatePicker />;
};

export default App;

配置路由Router

  1. 安装路由包react-router-dom
  2. 准备两个基础路由组件Layout和Login
  3. 在router/index.js文件中任意组件进行路由配置,导出router实例
  4. 在入口文件中渲染<RouterProvider/>,传入router实例
1
npm i react-router-dom -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.scss';
import {RouterProvider} from "react-router-dom";
import router from "@/router";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<RouterProvider router={router}/>
</React.StrictMode>
);

配置别名路径@

  1. 路径解析配置(webpack),把@/解析为src/
  2. 路径联想配置(VsCode),VsCode在输入@/时,自动联想出来对应的src/下的子级目录

image-20240824130706611

CRA本身把webpack配置包装到了黑盒里无法直接修改,需要借助一个插件——craco

步骤:

  1. 安装craco
1
npm i -D @craco/craco
  1. 项目根目录下创建配置文件“craco.config.js
  2. 配置文件中添加路径解析配置
  3. 包文件中配置启动和打包命令

image-20240824131021444

1
2
3
4
5
6
7
8
9
10
11
const path = require('path')

module.exports = {
// webpack配置
webpack: {
// 配置别名
alias: {
'@': path.resolve(__dirname, 'src')
}
}
}

联想路径配置:

VsCode的联想配置,需要我们在项目目录下添加jsconfig.json,加入配置之后Vscode会自动读取配置帮助我们自动联想提示。

配置步骤:

  1. 根目录下新增文件——jsconfig.json
  2. 添加路径提示配置
1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
}
}

使用gitee管理项目

1
2
 git remote add origin https://gitee.com/beginner-chun/react-jike.git
git push -u origin "master"

登录

基础静态结构

src/pages/Login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
return (
<div className="login">
<Card className="login-container">
<img className="login-logo" src={logo} alt="" />
{/* 登录表单 */}
<Form>
<Form.Item>
<Input size="large" placeholder="请输入手机号" />
</Form.Item>
<Form.Item>
<Input size="large" placeholder="请输入验证码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Login

src/pages/Login/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.login {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: url("../../assets/login.png") no-repeat;
.login-logo {
width: 200px;
height: 60px;
display: block;
margin: 0 auto 20px;
}

.login-container {
width: 440px;
height: 440px;
position: absolute;
left: 75%;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 50px rgb(0 0 0 / 10%);
}

.login-checkbox-label {
color: #1890ff;
}
}

image-20240825153144453

表单校验

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

image-20240825153626037

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{/* 登录表单 */}
{/*validateTrigger: 触发条件——失焦*/}
<Form validateTrigger="onBlur">
{/*rules:校验规则*/}
<Form.Item name="mobile"
rules={[
{
pattern: /^1[3-9]\d{9}$/,
message: '手机号格式有误',
},
{
required: true,
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" size="large" block>
登录
</Button>
</Form.Item>
</Form>

获取表单数据

给Form组件绑定onFinish回调函数,通过回调函数的参数获取用户输入的内容。

1
2
3
4
5
6
7
8
// 获取表单数据
const onFinish = (value) => {
console.log("=>(index.js:9) value", value);
}
{/* 登录表单 */}
{/*validateTrigger: 触发条件——失焦;onFinish: 获取输入框的内容 */}
<Form validateTrigger="onBlur" onFinish={onFinish}>
</Form>

封装request请求模块

在整个项目中会发送许多网络请求,使用axios三方库做好统一封装,方便统一管理和复用。

原因:

  1. 几乎所有接口都是语言的接口域名
  2. 几乎所有的接口都需要设置语言的超时时间
  3. 几乎所有的接口都需要做Token权限处理

image-20240825160754970

1
npm i axios -D

src/utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import axios from 'axios'

// 1. 设置请求的根路径
// 2. 设置请求响应的超时时间
// 3. 添加请求拦截器和响应拦截器
const request = axios.create({
baseURL: 'http://geek.itheima.net/v1_0',
timeout: 5000
})

// 添加请求拦截器
request.interceptors.request.use((config)=> {
return config
}, (error)=> {
return Promise.reject(error)
})

// 添加响应拦截器
request.interceptors.response.use((response)=> {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data
}, (error)=> {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})

export { request }

使用Redux管理token

token作为一个同行的标识数据,需要在很多个模块中共享,Redux可以方便的解决状态共享问题.

image-20240825161812285

  1. Redux中编写获取Token的异步获取和同步修改
  2. Login组件负责提交action并且把表单数据传递过来
1
npm i react-redux @reduxjs/toolkit -D

src/sotre/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {createSlice} from "@reduxjs/toolkit";

const userStore = createSlice({
// 仓库名
name: 'user',
// 初始化状态数据
initialState: {
token: ''
},
// 同步修改状态数据
reducers: {
setToken(state, action){
state.token = action.payload
}
}
})



// 异步修改状态数据
const {setToken} = userStore.actions
// 异步修改状态数据
function fetchGetToken(value){
return async (dispatch) => {
// 发送axios请求获取token
const res = await request.post('/authorizations', value)
console.log("=>(user.js:27) res", res);
// 异步修改token数据
dispatch(setToken(res.data.token))
}
}

// 导出reducers
const userReducer = userStore.reducer

export {setToken,fetchGetToken}
export default userReducer

src/sotre/index.js

1
2
3
4
5
6
7
8
9
10
11
//组合子模块 统一导出store

import {configureStore} from "@reduxjs/toolkit";
import userReducer from "@/sotre/modules/user";

const store = configureStore({
reducer: {
user: userReducer
}
})
export default store

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.scss';
import {RouterProvider} from "react-router-dom";
import router from "@/router";
import {Provider} from "react-redux";
import store from "@/sotre";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<RouterProvider router={router}/>
</Provider>
</React.StrictMode>
);

src/pages/Login/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const dispatch = useDispatch();
// 获取表单数据
const [spinning, setSpinning] = useState(false)
// 获取表单数据
const onFinish =async (value) => {
try {
// 显示loading
setSpinning(true)
await dispatch(fetchGetToken(value))
// 关闭loading
setSpinning(false)
navigate('/')
message.success("登录成功")
} catch (e) {
console.error(e)
}
}
// loading
<Spin spinning={spinning} tip={"加载中..."} fullscreen />

image-20240825165913128

token持久化

Redux存入token之后如果刷新浏览器,token会丢失(持久化就是刷新时丢失token)

原因:Redux时基于浏览器内存的存储方式,刷新时状态恢复为初始值。

解决:

image-20240825171810237

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const userStore = createSlice({
// 仓库名
name: 'user',
// 初始化状态数据
initialState: {
++ token: localStorage.getItem('token_key') || ''
},
// 同步修改状态数据
reducers: {
setToken(state, action) {
state.token = action.payload
++ localStorage.setItem("token_key", action.payload)
}
}
})

封装Token的存取删除方法

对于Token的各类操作在项目多个模块中都有用到,为了共享复用可以封装成工具函数。

image-20240825172354665

src/utils/token.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const TOKEN_KEY = 'token_key'
// 存Token
function setToken(token){
localStorage.setItem(TOKEN_KEY, token)
}

// 取Token
function getToken(){
return localStorage.getItem(TOKEN_KEY);
}

// 删除Token
function removeToken(){
localStorage.removeItem(TOKEN_KEY)
}

export {setToken, getToken, removeToken}

axios请求拦截器注入Token

Token作为用户的一个标识数据,后端很多接口都会以它作为接口权限判断的语句;请求拦截器注入Token之后,所有用到Axios实例的接口请求都自动携带了Token

image-20240825180437312

1
2
3
4
5
6
7
8
9
10
11
12
// 添加请求拦截器
request.interceptors.request.use((config)=> {
//获取token
const token = getToken()
if (token){
// 将token放到请求的的请求头中
config.headers.Authorization = `Bearer ${token}`
}
return config
}, (error)=> {
return Promise.reject(error)
})

使用token做路由权限控制

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

image-20240825181456168

src/components/AutoRoute.js

1
2
3
4
5
6
7
8
9
10
11
12
import {getToken} from "@/utils";
import {Navigate} from "react-router-dom";
// 有Token则正常访问,无Token则返回登录页
export function AutoRoute({children}){
const token = getToken();
if (token){
return <>{children}</>
}else {
return <Navigate to={'/login'} replace></Navigate>
}

}

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {createBrowserRouter} from "react-router-dom";
import Login from "@/pages/Login";
import Layout from "@/pages/Layout";
import {AutoRoute} from "@/components/AutoRoute";
import NotFound from "@/components/NotFound";

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

export default router;

Layout

静态页面搭建

src/pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { Layout, Menu, Popconfirm } from 'antd'
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
{
label: '首页',
key: '1',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '2',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '3',
icon: <EditOutlined />,
},
]

const GeekLayout = () => {
return (
<Layout>
<Header className="header">
<div className="logo" />
<div className="user-info">
<span className="user-name">柴柴老师</span>
<span className="user-logout">
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Layout>
<Sider width={200} className="site-layout-background">
<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
style={{ height: '100%', borderRight: 0 }}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
内容
</Layout>
</Layout>
</Layout>
)
}
export default GeekLayout

src/pages/Layout/index.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
.ant-layout {
height: 100%;
}

.header {
padding: 0;
}

.logo {
width: 200px;
height: 60px;
background: url('~@/assets/logo.png') no-repeat center / 160px auto;
}

.layout-content {
overflow-y: auto;
}

.user-info {
position: absolute;
right: 0;
top: 0;
padding-right: 20px;
color: #fff;

.user-name {
margin-right: 20px;
}

.user-logout {
display: inline-block;
cursor: pointer;
}
}
.ant-layout-header {
padding: 0 !important;
}

消除浏览器默认样式

1
npm install normalize.css

在scr/index.js中导入import "normalize.css"

src/index.scss设置布局占满这个屏幕

1
2
3
4
5
6
7
8
9
html,
body {
margin: 0;
height: 100%;
}

#root {
height: 100%;
}

image-20240825190140700

二级路由配置

image-20240825190800782

组件创建

image-20240825191519454

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import {createBrowserRouter} from "react-router-dom";
import Login from "@/pages/Login";
import Layout from "@/pages/Layout";
import {AutoRoute} from "@/components/AutoRoute";
import NotFound from "@/components/NotFound";
import Home from "@/pages/Home";
import Article from "@/pages/Article";
import Publish from "@/pages/Publish";

const router = createBrowserRouter([
{
path: '/',
element: <AutoRoute><Layout/></AutoRoute>,
children: [
{
index: true,
element: <Home/>
},
{
path: 'article',
element: <Article/>
},
{
path: 'publish',
element: <Publish/>
}
]
},
{
path: '/login',
element: <Login/>
},
{
path: "*",
element: <NotFound/>
}
])

export default router;

在Layout组件中添加<Outlet/>.

菜单点击跳转路由

实现效果:点击左侧菜单可以跳转到对应的目标路由

思路:

  1. 左侧菜单要和路由形成一一对应的关系(知道点了谁)
  2. 点击时拿到路由路径调用路由方法跳转(跳转到对应的路由下面)

image-20240825204830876

src/pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const items = [
{
label: '首页',
key: '',
icon: <HomeOutlined />,
},
{
label: '文章管理',
key: '/article',
icon: <DiffOutlined />,
},
{
label: '创建文章',
key: '/publish',
icon: <EditOutlined />,
},
]

const navigate = useNavigate();
// 导航路由跳转
const onChangeMenu = (route) => {
navigate(route.key)
}


<Menu
mode="inline"
theme="dark"
defaultSelectedKeys={['1']}
items={items}
++ onClick={onChangeMenu}
style={{ height: '100%', borderRight: 0 }}></Menu>

高亮当前路径的菜单项

实现效果:页面在刷新时可以根据当前的路由路径让对应的左侧菜单高亮显示

步骤:

  1. 获取当前url上的路由路径
  2. 找到菜单组件负责高亮的属性,绑定当前的路由路径
1
2
3
4
5
6
7
8
9
10
11
12
// 高亮当前的导航项useLocation-获取当前路径
const location = useLocation();
const pathKey = location.pathname


<Menu
mode="inline"
theme="light"
++ selectedKeys={pathKey}
items={items}
onClick={onChangeMenu}
style={{ height: '100%', borderRight: 0 }}></Menu>

image-20240825212054426

展示个人信息

和Token令牌类似,用户的信息通常很有可能在多个组件中都需要共享使用,所以同样应该放到Redux中维护

image-20240825212826195

src/sotre/modules/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const userStore = createSlice({
// 仓库名
name: 'user',
// 初始化状态数据
initialState: {
token: getToken() || '',
userInfo: {}
},
// 同步修改状态数据
reducers: {
setUserInfo(state, action) {
state.userInfo = action.payload
}
}
})

// 异步修改状态数据
const {setToken, setUserInfo} = userStore.actions

// 异步获取用户信息,并保存到Redux中
function fetchGetUserInfo() {
return async (dispatch) => {
// 发送axios请求获取userInfo
const res = await request.get('/user/profile');
console.log("=>(user.js:43) res", res);
// 异步修改token数据
dispatch(setUserInfo(res.data))
}
}

// 导出reducers
const userReducer = userStore.reducer

export { fetchGetToken,fetchGetUserInfo}
export default userReducer

src/pages/Layout/index.js

1
2
3
4
5
6
// 获取用户信息
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchGetUserInfo())
}, [dispatch]);
const userName = useSelector(state => state.user.userInfo.name);

image-20240825214055597

退出登录

  1. 提示用户是否确认要退出(危险操作,二次确认)
  2. 用户确认之后清除用户信息(Token以及其他个人信息)
  3. 跳转到登录页(为下次登录做准备)

src/sotre/modules/user.js

1
2
3
4
5
//     退出清除Token和用户信息
removeUserInfo(state){
state.userInfo = {}
removeToken()
}

src/pages/Layout/index.js

1
2
3
4
5
6
// 退出登录
const onLogout = async () => {
await dispatch(removeUserInfo())
navigate('/login')
message.success('退出成功')
}

处理Token失效

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

通常在Token失效之后再去请求接口,后端会返回401状态码,前端可以监控这个状态做后续操作。

image-20240825220341127

src/utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 添加响应拦截器
request.interceptors.response.use((response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response.data
}, (error) => {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
if (error.response.status === 401) {
removeUserInfo()
router.navigate(getToken())
window.location.reload()
}
return Promise.reject(error)
})

Home

Eacharts

官网:https://echarts.apache.org/handbook/zh/get-started/

image-20240825233558687

1
npm install echarts

Echarts组件封装

组件封装做啊解决了复用的问题。

image-20240825234318793

src/pages/Home/components/BarChart.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import * as echarts from 'echarts';
import {useEffect, useRef} from "react";
import './index.scss'
import {Card} from "antd";

function BarChart({ title }) {
const chart = useRef();

useEffect(() => {
const myChart = echarts.init(chart.current);
const option = {
title: {
text: title,
textStyle: {
color: '#333' // 设置标题颜色
}
},
tooltip: {}, // 显示默认的tooltip
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed'],
axisLine: { lineStyle: { color: '#666' } }, // 设置x轴线条颜色
axisLabel: { interval: 0, rotate: 45 }, // 设置x轴标签倾斜显示
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: ['#eee'] } }, // 设置y轴分割线颜色
axisLine: { lineStyle: { color: '#666' } }, // 设置y轴线条颜色
axisLabel: { margin: 10 }, // 设置y轴标签与轴的距离
},
series: [
{
name: 'Sales',
type: 'bar',
smooth: true, // 平滑处理
barWidth: '60%', // 设置柱子宽度
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#ead0a7' }
], false),
barBorderRadius: 5 // 设置柱子圆角
}
},
label: {
show: true, // 显示标签
position: 'top',
formatter: '{c}' // 显示实际值
},
animationDuration: 2000, // 动画持续时间
data: [120, 200, 150, 80, 70, 110, 130]
},
],
toolbox: {
show: true,
feature: {
dataView: { readOnly: false },
saveAsImage: {}
}
}
};

option && myChart.setOption(option);

// 自适应窗口大小变化
window.addEventListener('resize', () => {
myChart.resize();
});

// 清理事件监听器
return () => {
window.removeEventListener('resize', () => {
myChart.resize();
});
};
}, [title]);

return (
<Card className="cart-chart-container">
<div ref={chart} style={{ width: "300px", height: "300px" }}></div>
</Card>
);
}

export default BarChart;

image-20240826095029001

API模块封装

当前接口请求放到了功能实现的位置,没有在规定的模块内维护,后期查找维护困难。

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

image-20240826095530352

src/api/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import {request} from "@/utils";

/**
* 登录
* @param data - 手机号,验证码
* @returns {Promise<AxiosResponse<any>> | *}
*/
function loginAPI(data){
return request({
url: '/authorizations',
method: 'post',
data
})
}

/**
* 获取用户信息
*/
function getProfileAPI(){
return request({
url: '/user/profile',
method: 'get'
})
}
export {loginAPI,getProfileAPI}

文章发布

基础页面搭建

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import {
Card,
Breadcrumb,
Form,
Button,
Radio,
Input,
Upload,
Space,
Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'

const { Option } = Select

const Publish = () => {
return (
<div className="publish">
<Card
title={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '发布文章' },
]}
/>
}
>
<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
initialValues={{ type: 1 }}
>
<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 placeholder="请选择文章频道" style={{ width: 400 }}>
<Option value={0}>推荐</Option>
</Select>
</Form.Item>
<Form.Item
label="内容"
name="content"
rules={[{ required: true, message: '请输入文章内容' }]}
></Form.Item>

<Form.Item wrapperCol={{ offset: 4 }}>
<Space>
<Button size="large" type="primary" htmlType="submit">
发布文章
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}

export default Publish

pages/Publish/index.scss

1
2
3
4
5
6
7
8
9
10
11
.publish {
position: relative;
}

.ant-upload-list {
.ant-upload-list-picture-card-container,
.ant-upload-select {
width: 146px;
height: 146px;
}
}

image-20240826102653053

富文本编译器

实现步骤

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

代码落地
1-安装 react-quill

1
npm i react-quill@2.0.0-beta.2 --legacy-peer-deps

2-导入资源渲染组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
2
3
4
5
.publish-quill {
.ql-editor {
min-height: 300px;
}
}

image-20240826103553399

频道数据获取渲染

  1. 根据接口文档的API模块中封装接口函数
  2. 使用useState维护数据
  3. useEffect中调用接口获取数据并存入state
  4. 绑定数据到下拉框组件

src/api/article.js

1
2
3
4
5
6
7
import {request} from "@/utils";
/**
* 获取文章频道
*/
export const getArticleChannelsAPI = () => request({
url: '/channels'
})

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// 记录频道数据
const [channelList, setChannelList] = useState([])
useEffect(() => {
getArticleChannelsAPI().then(res => {
setChannelList(res.data.channels)
})
}, [])


<Select placeholder="请选择文章频道" style={{width: 400}}>
{/* 渲染频道数据*/}
{channelList.map(item => <Option key={item.id} value={item.id}>{item.name}</Option>)}
</Select>

收集表单数据提交表单

image-20240826105349523

src/api/article.js

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 发布文章
*/

export const postAddArticleAPI = (formDate) => {
return request({
method: "POST",
url: '/mp/articles?draft=false',
data: formDate
})
}

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 发布文章
const onHandleSubmit = async (formData) =>{
const {title, content, channel_id} = formData
const data = {
title,
content,
channel_id,
cover: {
type:0,
images: []
}
}
try {
setSpinning(true)
const res = await postAddArticleAPI(data)
if (res.message === 'OK') {
message.success("发布成功")
} else {
message.error(res.message)
}
} catch (e) {
console.error(e)
} finally {
setSpinning(false)
}

}


{/*上传的表单*/}
<Form
labelCol={{span: 4}}
wrapperCol={{span: 16}}
initialValues={{type: 1}}
++onFinish={onHandleSubmit}
>

上传文章封面基础功能

准备上传结构

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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>

实现基础上传

实现步骤

  1. 为 Upload 组件添加 action 属性,配置封面图片上传接口地址
  2. 为 Upload组件添加 name属性, 接口要求的字段名
  3. 为 Upload 添加 onChange 属性,在事件中拿到当前图片数据,并存储到React状态中

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useState } from 'react'

const Publish = () => {
// 上传图片
const [imageList, setImageList] = useState([])
const onUploadChange = (info) => {
setImageList(info.fileList)
}
return (
<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
name="image"
listType="picture-card"
showUploadList
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>
</Form.Item>
)
}

切换封面类型

只有当前模式为单图或三图模式是才显示上传组件

image-20240826120832593

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
   // 封面状态切换
const [imageType, setImageType] = useState(1)
const handleChangeType = (e) => {
setImageType(e.target.value)
}

{/*封面上传*/}
<Form.Item label="封面">
<Form.Item name="type">
<Radio.Group onChange={handleChangeType} >
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0} >无图</Radio>

</Radio.Group>
</Form.Item>
{imageType > 0 && <Upload
listType="picture-card"
showUploadList
name="image"
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
>
<div style={{ marginTop: 8 }}>
<PlusOutlined />
</div>
</Upload>}

控制上传图片的数量

  1. 单图模式时,最多能上传一张图片
  2. 三图模式时,最多能上传三张图片
1
2
3
4
5
6
7
8
9
10
11
12
{imageType > 0 && <Upload
listType="picture-card"
showUploadList
name="image"
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
++ maxCount={imageType}
>
<div style={{marginTop: 8}}>
<PlusOutlined/>
</div>
</Upload>}

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import {
Card,
Breadcrumb,
Form,
Button,
Radio,
Input,
Upload,
Space,
Select, message, Spin,Image
} from 'antd'
import {PlusOutlined} from '@ant-design/icons'
import {Link} from 'react-router-dom'
import './index.scss'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
import {getArticleChannelsAPI, postAddArticleAPI} from "@/api/article";
import {useEffect, useState} from "react";

const {Option} = Select

const getBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});

const Publish = () => {
// loading
const [spinning, setSpinning] = useState(false)
// 记录频道数据
const [channelList, setChannelList] = useState([])
useEffect(() => {
getArticleChannelsAPI().then(res => {
setChannelList(res.data.channels)
})
}, [])


// 发布文章
const onHandleSubmit = async (formData) => {
try {
const {title, content, channel_id} = formData
const data = {
title,
content,
channel_id,
cover: {
type: imageType,
images: images.map(item => item.response.data.url)
}
}
// 图片数量不匹配
if (images.length !== imageType) return message.info("图片类型或者数量不匹配哦")
setSpinning(true)
const res = await postAddArticleAPI(data)
if (res.message === 'OK') {
message.success("发布成功")
} else {
message.error(res.message)
}
} catch (e) {
console.error(e)
message.error("图片类型或者数量不匹配哦")
} finally {
setSpinning(false)
}

}
// 文章封面上传
const [images, setImages] = useState([])
const onUploadChange = (data) => {
try {
setImages(data.fileList)
if (data.fileList[0].response.message !== 'OK'){
message.error(data.fileList[0].response.message)
}
} catch (e) {
}
}

// 封面状态切换
const [imageType, setImageType] = useState(0)
const handleChangeType = (e) => {
setImageType(e.target.value)
}


// 图片预览
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj);
}
setPreviewImage(file.url || file.preview);
setPreviewOpen(true);
};
return (
<div className="publish">
{/*卡片头部*/}
<Card
title={
<Breadcrumb items={[
{title: <Link to={'/'}>首页</Link>},
{title: '发布文章'},
]}
/>
}
>
{/*上传的表单*/}
<Form
labelCol={{span: 4}}
wrapperCol={{span: 16}}
initialValues={{type: 0}}
onFinish={onHandleSubmit}
>
<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 placeholder="请选择文章频道" style={{width: 400}}>
{/* 渲染频道数据*/}
{channelList.map(item => <Option key={item.id} value={item.id}>{item.name}</Option>)}
</Select>
</Form.Item>
{/*封面上传*/}
<Form.Item label="封面">
<Form.Item name="type">
<Radio.Group onChange={handleChangeType} >
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>

</Radio.Group>
</Form.Item>
{imageType > 0 &&
<Upload
listType="picture-card"
showUploadList
name="image"
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
maxCount={imageType}
onPreview={handlePreview}
>
<div style={{marginTop: 8}}>
<PlusOutlined/>
</div>
</Upload>}
{previewImage && (
<Image
wrapperStyle={{
display: 'none',
}}
preview={{
visible: previewOpen,
onVisibleChange: (visible) => setPreviewOpen(visible),
afterOpenChange: (visible) => !visible && setPreviewImage(''),
}}
src={previewImage}
/>
)}
</Form.Item>
<Form.Item
label="内容"
name="content"
rules={[{required: true, message: '请输入文章内容'}]}
>
{/*富文本编译器*/}
<ReactQuill
className="publish-quill"
theme="snow"
placeholder="请输入文章内容"
/>

</Form.Item>

<Form.Item wrapperCol={{offset: 4}}>
<Space>
<Button size="large" type="primary" htmlType="submit">
发布文章
</Button>
</Space>
</Form.Item>
</Form>
<Spin spinning={spinning} tip={"加载中..."} fullscreen/>
</Card>
</div>
)
}

export default Publish

image-20240826134646095

Article

页面基础搭建

image-20240826135330897

src/pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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'
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'
const { Option } = Select
const { RangePicker } = DatePicker




const Article = () => {

// 准备列数据
const columns = [
{
title: '封面',
dataIndex: 'cover',
width: 120,
render: cover => {
return <img src={cover.images[0] || img404} width={80} height={60} alt="" />
}
},
{
title: '标题',
dataIndex: 'title',
width: 220
},
{
title: '状态',
dataIndex: 'status',
render: data => <Tag color="green">审核通过</Tag>
},
{
title: '发布时间',
dataIndex: 'pubdate'
},
{
title: '阅读数',
dataIndex: 'read_count'
},
{
title: '评论数',
dataIndex: 'comment_count'
},
{
title: '点赞数',
dataIndex: 'like_count'
},
{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined />} />
<Button
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={
<Breadcrumb items={[
{ title: <Link to={'/'}>首页</Link> },
{ title: '文章列表' },
]} />
}
style={{ marginBottom: 20 }}
>
<Form 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="请选择文章频道"
defaultValue="lucy"
style={{ width: 120 }}
>
<Option value="jack">Jack</Option>
<Option value="lucy">Lucy</Option>
</Select>
</Form.Item>

<Form.Item label="日期" name="date">
{/* 传入locale属性 控制中文显示*/}
<RangePicker locale={locale}></RangePicker>
</Form.Item>

<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginLeft: 40 }}>
筛选
</Button>
</Form.Item>
</Form>
</Card>
{/*表格区*/}
<Card title={`根据筛选条件共查询到 count 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={data} />
</Card>
</div>
)
}

export default Article

渲染频道数据

自定义业务hook

  1. 创建一个use打头的函数
  2. 在函数中封装业务逻辑,并return出组件中药用到的状态数据
  3. 组件中导入函数执行并结果状态数据使用

src/hooks/useChannels.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {useEffect, useState} from "react";
import {getArticleChannelsAPI} from "@/api/article";

function UseChannels(){
// 记录频道数据
const [channelList, setChannelList] = useState([])
useEffect(() => {
getArticleChannelsAPI().then(res => {
setChannelList(res.data.channels)
})
}, [])
return {
channelList
}
}

export default UseChannels;

在需要的地方,引用即可

获取文章列表

src/api/article.js

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取文章列表
*/

export const getArticleListAPI = (params) => {
return request({
method: 'GET',
url: '/mp/articles',
params
})
}

src/pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 // 获取频道列表
const {channelList} = useChannels()
// 获取文章列表
const [articleList, setArticleList] = useState([])
// 获取文章总条数
const [count, setCount] = useState(0)
useEffect(() => {
getArticleListAPI().then(res => {
setArticleList(res.data.results)
setCount(res.data.total_count)
})
}, [])


{/*表格区*/}
<Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={articleList}/>
</Card>

image-20240826154339944

适配文章状态

根据文章的不同状态在状态列显示同Tag

思路:

  1. 如果要适配在状态只有两个——三元运算符渲染
  2. 如果要适配的状态有多个——枚举渲染

通过枚举方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const columns = [ 
...
{
title: '状态',
dataIndex: 'status',
render: data => statusMap[data]
},
}
// 文章tag适配
const statusMap = {
0: <Tag color="processing">'草稿'</Tag>,
1: <Tag color="lime">'待审核'</Tag>,
2: <Tag color="success">'审核通过'</Tag>,
3: <Tag color="error">'审核失败'</Tag>
}

image-20240826155535370

根据搜索条件查询

筛选功能的本质:给请求列表接口传递不同的参数和后端要不同的数据

步骤:

  1. 准备完整的请求参数对象
  2. 获取用户选择的表单数据
  3. 把表单数据放置到接口对应的字段中
  4. 重新调用文章列表接口渲染table列表。

当params变化就会发送一次请求

src/pages/Article/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 条件搜索
// 封装搜索参数
const [params, setParams] = useState({
status: '',
channel_id: '',
begin_pubdate: '',
end_pubdate: '',
page: 1,
per_page: 5
})
// 获取文章总条数
const [count, setCount] = useState(0)
useEffect(() => {
getArticleListAPI(params).then(res => {
setArticleList(res.data.results)
setCount(res.data.total_count)
})
}, [params])


// 条件搜索
const onFinish = values => {
console.log(values)
setParams(
{
...params,
status: values.status,
channel_id: values.channel_id,
begin_pubdate: values.date[0].format('YYYY-MM-DD'),
end_pubdate: values.date[1].format('YYYY-MM-DD')
}
)
}

<Form initialValues={{status: ''}} onFinish={onFinish}></Form>

image-20240826161440517

分页

步骤:

  1. 实现分页展示(页数 = 总数/每页条数)

  2. 点击分页拿到当前点击的页数

  3. 使用页数作为请求参数重新获取文章列表渲染

1
2
3
4
5
6
7
8
9
10
<Table rowKey="id" columns={columns} dataSource={articleList} pagination={
{
current: params.page,
pageSize: params.per_page,
total: count,
onChange: (page) => {
setParams({...params, page})
}
}
}/>

删除文章

步骤:

  1. 点击删除弹出确认框
  2. 得到文章id,使用id调用删除接口
  3. 更新文章列表
1
2
3
4
5
6
7
8
9
10
/**
* 删除文章
*/

export const deleteArticleByIdAPI = (id) => {
return request({
method: "DELETE",
url: `mp/articles/${id}`
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
title: '操作',
render: data => {
return (
<Space size="middle">
<Button type="primary" shape="circle" icon={<EditOutlined/>}/>
<Popconfirm
title="删除当前文章"
description="您确定要删除此文章吗?"
onConfirm={() => confirm(data)}
okText="Yes"
cancelText="No"
>
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined/>}
/>
</Popconfirm>
</Space>
)
}
}


// 删除文章
const confirm = async (data) => {
const res = await deleteArticleByIdAPI(data.id)
if (res.message !== 'OK'){
return
}
message.success("删除成功")
setParams({
...params
})
}

回填基础数据

步骤:

  1. 通过文章id获取文章详情数据
  2. 调用Form组件实例方法setFieldsValue回显数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 根据id获取文章详情
const [searchParams] = useSearchParams();
const articleId = searchParams.get("id")
// 获取表单实例
const [ form ] = Form.useForm();
useEffect( () => {
const getArticleById = async () => {

try {
const res = await getArticleByIdAPI(articleId);
console.log("=>(index.js:104) res", res);
// 回填数据
form.setFieldsValue(res.data)
} catch (e) {

}
}
getArticleById()
},[articleId,form])


<Form
labelCol={{span: 4}}
wrapperCol={{span: 16}}
initialValues={{type: 0}}
onFinish={onHandleSubmit}
++ form={form}
>
</From>

image-20240826192950632

回填封面信息

  1. 使用cover中的type字段回填封面类型
  2. 使用cover中iamages字段回填封面图片

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 根据id获取文章详情
const [searchParams] = useSearchParams();
const articleId = searchParams.get("id")
// 获取表单实例
const [ form ] = Form.useForm();
useEffect( () => {
const getArticleById = async () => {

try {
const res = await getArticleByIdAPI(articleId);
console.log("=>(index.js:104) res", res);
// 回填图片类型
form.setFieldsValue({
...res.data,
type: res.data.cover.type
})
// 回填图片
setImageType(res.data.cover.type)
setImages(res.data.cover.images.map(url =>{
return {url}
}))
} catch (e) {
}
}
getArticleById()
},[articleId,form])


<Upload
listType="picture-card"
showUploadList
name="image"
action={'http://geek.itheima.net/v1_0/upload'}
onChange={onUploadChange}
maxCount={imageType}
onPreview={handlePreview}
++ fileList={images}
>
</Upload>

根据id适配状态

实现效果:发布文章是显示发布文章,编辑文章状态下显示编辑文章。

image-20240826213112381

src/pages/Publish/index.js

1
2
3
4
5
6
7
8
9
<Card
title={
<Breadcrumb items={[
{title: <Link to={'/'}>首页</Link>},
++ {title: `${articleId ? '编辑': '发布'}文章`},
]}
/>
}
></Card>

更新文章

步骤:

更新文章和新增文章相比,大部分的逻辑都是一致的,稍作参数适配调用不同接口即可

  1. 适配url参数

image-20240826214057975

  1. 调用文章更新接口
1
2
3
4
5
6
7
8
9
10
11
/**
* 编辑文章
*/
export const putUpdateArticleAPI = (formDate) => {
console.log("=>(article.js:33) formDate", formDate);
return request({
method: "PUT",
url: `/mp/articles/${formDate.id}?draft=false`,
data: formDate
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 发布文章/更新文章
const onHandleSubmit = async (formData) => {
try {
const {title, content, channel_id} = formData
const data = {
title,
content,
channel_id,
cover: {
type: imageType,
// 新增和修改的格式不同,需要适配
images: images.map(item =>{
if (item.response){
return item.response.data.url
}else {
return item.url
}
})
}
}
// 图片数量不匹配
if (images.length !== imageType) return message.info("图片类型或者数量不匹配哦")
setSpinning(true)

if (articleId){
await putUpdateArticleAPI({
...data,
id: articleId
})
navigate(-1)
}else {
await postAddArticleAPI(data)
}

if (articleId){
message.success('更新成功')
}else {
message.success("发布成功")
}

} catch (e) {
console.error(e)
message.error("图片类型或者数量不匹配哦")
} finally {
setSpinning(false)
}

}

项目打包优化

1
npm run build

image.png

项目本地预览

实现步骤

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

image.png

优化-路由懒加载

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

image-20240827113056021

使用步骤

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

代码实现
router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createBrowserRouter } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import Login from '@/pages/Login'
import Layout from '@/pages/Layout'

import AuthRoute from '@/components/Auth'
// lazy函数对组件进行导入
const Publish = lazy(() => import('@/pages/Publish'))
const Article = lazy(() => import('@/pages/Article'))
const Home = lazy(() => import('@/pages/Article'))


const router = createBrowserRouter([
{
path: '/',
element: (
<AuthRoute>
<Layout />
</AuthRoute>
),
children: [
{
index: true,
element: (
<Suspense fallback={'加载中'}>
<Home />
</Suspense>
)
},
{
path: 'article',
element: (
<Suspense fallback={'加载中'}>
<Article />
</Suspense>
)
},
{
path: 'publish',
element: (
<Suspense fallback={'加载中'}>
<Publish />
</Suspense>
)
},
],
},
{
path: '/login',
element: <Login />,
},
])

export default router

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

打包-打包体积分析

业务背景
通过分析打包体积,才能知道项目中的哪部分内容体积过大,方便知道哪些包如何来优化
使用步骤

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

核心代码

1
2
3
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
}

image.png

优化-配置CDN

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

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

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

项目中使用:

  1. 把需要做CDN缓存的文件排除在打包之外(react,react-dom)
  2. 以CDN的方式重新引入资源(react,react-dom)

分析说明:通过 craco 来修改 webpack 配置,从而实现 CDN 优化
核心代码
craco.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 添加自定义对于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.htmlk

1
2
3
4
5
6
7
<body>
<div id="root"></div>
<!-- 加载第三发包的 CDN 链接 -->
<% htmlWebpackPlugin.options.files.js.forEach(cdnURL => { %>
<script src="<%= cdnURL %>"></script>
<% }) %>
</body>

极客园移动端-TypeScript

项目搭建

基于Vitec创建开发环境

Vite是一个框架无关的前端工具链,可以快速的生成一个React + TS的开发环境,并且可以提供快速的开发体验。

1
npm create vite@latest react-jike-mobile -- --template react-ts

说明:

  1. npm create vite@latest 固定写法(使用最新版本vite初始化项目)
  2. react-jike-mobile 项目名称(自定义)
  3. – –template react-ts 指定项目模板位react-ts

安装Ant Design Mobile

1
npm install --save antd-mobile

实例:

直接引入组件即可,antd-mobile 会自动为你加载 css 样式文件:

1
import { Button } from 'antd-mobile'

初始化路由

Reactd 路由初始化,采用react-router-dom进行配置

image-20240828120843605

1
npm i --save react-router-dom

src/router/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {createBrowserRouter} from "react-router-dom";
import Home from "../pages/Home";
import Detail from "../pages/Detail";

const router = createBrowserRouter([
{
path: '/',
element: <Home/>
},
{
path: '/detail',
element: <Detail/>
}
])

export default router

src/main.tsx

1
2
3
4
5
6
7
8
9
import { createRoot } from 'react-dom/client'
import './index.css'
import {RouterProvider} from "react-router-dom";
import router from "./router";

createRoot(document.getElementById('root')!).render(
<RouterProvider router={router}></RouterProvider>

)

image-20240828122251705

配置路径别名

  1. 让Vite做路径解析,把@/解析为src/
  2. 路径联想配置(VsCode),VsCode在输入@/时,自动联想出来对应的src/下的子级目录

修改vite配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

安装node类型包

1
npm i @types/node -D

修改tsconfig.app.json文件

1
2
3
4
5
6
7
8
{
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
}

image-20240828123840260

axios安装配置

安装axios

1
npm i axios

简易封装

src/utils/request.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import axios from 'axios'

const requestInstance = axios.create({
baseURL: 'http://geek.itheima.net/v1_0',
timeout: 5000,
})

requestInstance.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)

requestInstance.interceptors.response.use(
(response) => {
return response
},
(error) => {
return Promise.reject(error)
}
)

export default requestInstance

src/utils/index.ts

1
2
3
4
// 组合utils目录下的工具类统一导出
import requestInstance from './http'

export { requestInstance as http }

axios和ts的配合使用

场景:Axios提供了request泛型方法,方便我们传入类型的参数推导处接口返回值的类型。

image-20240828153909915

说明:泛型参数Type的类型决定了res.data的类型

步骤:

  1. 根据接口文档创建一个通用的泛型接口类型(多个接口返回值的结果是相似的)
  2. 根据接口文档创建特有的的接口数据类型(每个接口有自己特殊的数据格式)
  3. 组合1和2的类型,得到最终传给request泛型的参数类型

API模块测试使用

image-20240828154349649

无标题-2023-11-04-1803.png

封装泛型

获取-所有频道列表 - 黑马前端

1
2
3
4
export type ResType<T> = {
message: string
data: T
}

封装请求函数

1
2
3
4
5
6
7
8
9
10
11
12
import { http } from '@/utils'
import type { ResType } from './shared'

type ChannelRes = {
channels: { id: number; name: string }[]
}

export function fetchChannelAPI() {
return http.request<ResType<ChannelRes>>({
url: '/channels',
})
}

测试API函数

1
2
3
fetchChannelAPI().then((res) => {
console.log(res.data.data.channels)
})

Home

整体组件嵌套设计

image-20240828161611235

tabs区域

image-20240828161807064

步骤:

  1. 使用ant-mobile组件库中的Tabs组件进行页面结构创建
  2. 使用真实接口数据进行渲染
  3. 有优化的点进行优化处理

自定义hook函数优化

场景:当前状态数据的各种操作逻辑和组件渲染时写在一起的,可以采用自定义hook封装的方式让逻辑和渲染向分离

步骤:

  1. 把和Tabs相关的响应式数据状态以及操作数据的方法放到hook函数中
  2. 组件中调用hook函数,消费其返回的状态和方法

image-20240828163457538

src/pages/Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "./index.css"
import {Tabs} from "antd-mobile";
import {useTabs} from "@/pages/Home/useTabs.ts";
const Home = () => {
const {channelList} = useTabs();

return(
<div className={"tabContainer"}>
<Tabs>
{
channelList.map(item =>
<Tabs.Tab title={item.name} key={item.id}>
</Tabs.Tab>
)
}
</Tabs>
</div>
)
}

export default Home

src/pages/Home/useTabs.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {useEffect, useState} from "react";
import {ChannelItem, getChannelListAPI} from "@/apis/list.ts";

const useTabs = () => {
const [channelList, setChannelList] = useState<ChannelItem[]>([])
// 获取频道列表
async function getChannelList(){
try {
const res = await getChannelListAPI()
setChannelList(res.data.data.channels)
} catch (e) {
throw new Error(e)
}
}
useEffect(() => {
getChannelList()
}, []);
return {
channelList
}
}

export {useTabs}

src/pages/Home/index.css

1
2
3
4
5
6
7
8
9
10
11
12
13
.tabContainer {
position: fixed;
height: 50px;
top: 0;
width: 100%;
}

.listContainer {
position: fixed;
top: 50px;
bottom: 0px;
width: 100%;
}

image-20240828164005428

List组件

步骤:

  1. 搭建基础结构,并获取基础数据
  2. 为组件设计ChannelId参数,点击tab是传入不同的参数
  3. 实现上拉加载功能

image-20240828164200657

src/apis/list.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 获取文章列表
*/

export type ListParams = {
channel_id: string
timestamp: string
}

type ListItem = {
art_id: string
title: string
aut_id: string
comm_count: number
pubdate: string
aut_name: string
is_top: 0 | 1
cover: {
type: 0 | 1 | 3
images: string[]
}
}

export type ListRes = {
results: ListItem[]
pre_timestamp: string
}

export function fetchListAPI(params: ListParams) {
return request.request<ResType<ListRes>>({
method: "get",
url: '/articles',
params,
})
}

src/pages/Home/HomeList/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import {Image, List} from 'antd-mobile'
// mock数据
import {useEffect, useState} from "react";
import {fetchListAPI, ListRes} from "@/apis/list.ts";

type Props = {
channelId: string
}
const HomeList = (props: Props) => {
const {channelId,} = props
const [articleList, setArticleList] = useState<ListRes>({
results: [],
pre_timestamp: '' + new Date().getTime()
})

useEffect(() => {
async function getChannelList(){
const res = await fetchListAPI({channel_id: channelId, timestamp: "" + new Date().getTime()})
setArticleList(res.data.data)
}
getChannelList()
}, [channelId]);
return (
<>
<List>
{articleList.results.map((item) => (
<List.Item
key={item.art_id}
prefix={
<Image
src={item.cover.images?.[0]}
style={{borderRadius: 20}}
fit="cover"
width={40}
height={40}
/>
}
description={item.pubdate}
>
{item.title}
</List.Item>
))}
</List>
</>
)
}

export default HomeList

image-20240828184600504

List列表无限滚动

交换要求:List列表在滑动到底部时,自动加载下一页列表数据

思路:

  1. 滑动到底部触发加载下一页动作<InfiniteScroll/>
  2. 加载下一页数据: pre_tiemstamp接口参数
  3. 把老数据和新数据做拼接处理:[...oldList,...newList]
  4. 停止监听边界值:hasMore
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import {Image, InfiniteScroll, List} from 'antd-mobile'
// mock数据
import {useEffect, useState} from "react";
import {fetchListAPI, ListRes} from "@/apis/list.ts";

type Props = {
channelId: string
}
const HomeList = (props: Props) => {
const {channelId,} = props
const [articleList, setArticleList] = useState<ListRes>({
results: [],
pre_timestamp: '' + new Date().getTime()
})
// 获取文章列表
useEffect(() => {
async function getChannelList() {
const res = await fetchListAPI({channel_id: channelId, timestamp: "" + new Date().getTime()})
setArticleList(res.data.data)
}

getChannelList()
}, [channelId]);
// 下拉加载数据
// hasMore 下拉加载的开关,默认为true
const [hasMore, setHasMore] = useState(true)
// 当下拉到一定程度触发该事件,加载新数据
const loadMore = async () => {
console.log("触发加载数据...")
const res = await fetchListAPI({channel_id: channelId, timestamp: articleList.pre_timestamp})
// 没有数据立刻停止
if (res.data.data.results.length === 0) {
setHasMore(false)
}

setArticleList({
// 拼接新老列表数据
results: [...articleList.results, ...res.data.data.results],
// 重置时间参数 为下一次请求做准备
pre_timestamp: res.data.data.pre_timestamp
}
)
}
return (
<>
<List>
{articleList.results.map((item) => (
<List.Item
key={item.art_id}
prefix={
<Image
src={item.cover.images?.[0]}
style={{borderRadius: 20}}
fit="cover"
width={40}
height={40}
/>
}
description={item.pubdate}
>
{item.title}
</List.Item>
))}
</List>
{/*无限滚动*/}
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} threshold={50}/>
</>
)
}

export default HomeList

Detail

需求:点击列表中的某一项跳转到详情路由并显示当前文章

  1. 通过路由跳转方法进行跳转,并传递参数
  2. 在详情路由下获取参数,并请求数据
  3. 渲染数据到页面中

image-20240828192059828

获取响应数据ts类型

image-20240828202656275

image-20240828202717634

路由跳转传参

1
2
3
4
5
6
7
8
9
10
11
const navigateToDetail = (id: string) => {
navigate(`/detail?id=${id}`)
}

<List.Item
key={item.art_id}
onClick={() => navigateToDetail(item.art_id)}
arrow={false}
>
{item.title}
</List.Item>

获取详情数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { http } from '@/utils'
import { ResType } from './shared'

export type DetailRes = {
art_id: string
title: string
pubdate: string
content: string
}

export function fetchDetailAPI(article_id: string) {
return http.request<ResType<DetailRes>>({
url: `/articles/${article_id}`,
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { NavBar } from 'antd-mobile'
import { useEffect, useState } from 'react'
import { DetailRes, fetchDetailAPI } from '@/apis/detail'
import { useNavigate, useSearchParams } from 'react-router-dom'
const Detail = () => {
const [detail, setDetail] = useState<DetailRes | null>(null)
const [params] = useSearchParams()
const id = params.get('id')
useEffect(() => {
async function getDetail() {
try {
const res = await fetchDetailAPI(id!)
setDetail(res.data.data)
} catch (error) {
throw new Error('fetch detail error')
}
}
if (id) {
getDetail()
}
}, [id])
const navigate = useNavigate()
const back = () => navigate(-1)

if (!detail) {
return <div>this is loading</div>
}
return (
<div>
<NavBar onBack={back}>{detail.title}</NavBar>
<div dangerouslySetInnerHTML={{ __html: detail.content }}></div>
</div>
)
}

export default Detail