react
react
Huang_Chun开发环境搭建
使用
create-react-app
快速搭建开发环境,创建react开发环境的工具,底层由webpack
构建,封装了配置细节,开箱即用:执行命令:
npx create-react-app react-basic
- npx
node.js
工具命令,查找并执行后续命令create-react-app
核心包(固定写法),用于创建React项目react-basic
React项目的名称(可以自定义)
index.js
1 | // 导入React库,用于构建用户界面 |
APP.js
1 | // 定义一个常量,用于在组件中显示欢迎信息 |
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的语法扩展,浏览器本身不能识别,需要通过解析工具做解析之后才能在浏览器中运行。
JSX中使用JS表达式
在JSX中可以通过
大括号语法{}
识别JavaScript中的表达式,比如常见的变量,函数调用,方法调用等。
- 使用引号传递字符串
- 使用JavaScript变量
- 函数调用和方法调用
- 使用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;
列表渲染
语法:在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;
条件渲染
语法:在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;
复杂条件渲染
需求:列表中需要根据文章状态适配三种情况,单图,三图和无图三种模式
解决方案:
自定义函数 + 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;
事件绑定
基本使用
语法:
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;
组件
介绍
概念:一个组件就是用户界面的一部分,它可以有自己的逻辑和外观,组件之间可以
相互嵌套
,也可以复用多次
。组件化开发可以让开发者向搭积木一样构建一个完整的庞大的应用。
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;
useState
useState是一个React Hook(函数),它允许我们想组件添加一个
状态变量
,从而控制影响组件的渲染结果
本质:和普通JS变量不同的是,状态变量一旦发生组件的视图UI也会跟着变化(数据驱动视图)
1 const [count,setCount] = useState(0);
- useState是一个函数,返回值是一个数组
- 数组中的第一个参数是
状态变量
,第二个参数是set函数用来修改状态变量
- 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>
{count}
<button onClick={handleAdd}>+</button>
</div>);
}
// 将App组件导出为默认导出,使得可以在其他文件中作为默认导入使用
export default App;
修改状态规则
状态不可变
在React中,状态被认为说只读的,我们一个始终
替换它而不是修改它
,直接修改状态不能引发视图更新。修改对象状态
规则:对于对象类型的状态变量,应该始终传给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;
组件基础样式
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;
classNames优化类名控制
classNames
是一个简单的JS库,可以方便的通过条件动态控制class类名的显示。缺点:字符串的拼接方式不够直观,容易出错。
优化:
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)控制表单的状态
- 准备一个React状态值
1 const [value, setValue] = useState('')
- 通过value属性绑定状态,通过onChange属性绑定状态同步的函数。
1 <input type='text' value={value} onChange={(e) => setValue(e.target.value)}></input>
获取DOM元素
在react组件中获取、操作DOM,需要使用
useRef
钩子函数,分为两步。
- 使用useRef创建ref对象,并与JSX绑定
1
2 const inputRef = useRef(null)
<input type="text" ref={inputRef} />
- 在DOM使用时,通过
inputRef.current
拿到DOM对象
1
2 console.log(inputRef.current)
console.dir(inputRef.current)
组件通信
介绍
概念:组件通信就是组件之间的数据传递,根据之间嵌套关系的不同,有不同的通信方法。
父传子
实现步骤:
- 父组件传递数据-在子组件标签上
绑定属性
- 子组件接收数据——子组件通过
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说明
- props可传递任意的数据:
数字,字符串,布尔值,数组,对象,函数,JSX
- props是只读对象:子组件
只能读取props中的数据
,不能直接进行修改,父组件的数据只能有父组件修改。特殊的prop children
场景:当我们把内嵌套在子组件标签中是,父组件会自动在名为
children
的prop属性中接收该内容
子传父
思路:在子组件中调用父组件中的函数并传递参数
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;
兄弟通信
使用
状态提升
实现兄弟组件通信思路:借助”状态提升“机制,通过父组件进行兄弟组件之间的数据传递
- A组件先通过
子传父
的方式吧数据传给父组件APP- 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;
Context机制夸层级组件通信
实现步骤:
- 使用
createContext
方法创建一个上下文对象Ctx。- 在顶层组件(APP)中通过
Ctx.provider
组件提供数据- 在底层组件(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;
useEffect
介绍
useEffect是一个React Hook函数,用于在React组件中创建不是由事件引起而是由
渲染本身引起的操作
,比如发送Ajax请求,更改DOM等等。(类似vue中onMounted生命周期的钩子函数)说明:上面的组件中没有发生任何的用户事件,
组件渲染完毕之后
就需要和服务器请求数据,整个过程属于由渲染本身引起的操作
基础使用
需求:在组件渲染完毕之后,立刻从服务器获取频道列表数据并显示到页面中
语法:
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;
useEffect依赖项参数说明
useEffect副作用函数的执行时机存在多种情况,根据
传入依赖项的不同
,会有不同的执行表现。
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;
清除副作用
在useEffect中编写的
由渲染本身引起的对接组件外部的操作
,社区也经常把它叫做副作用操作,比如在useEffect中开启了一个定时器,我们项在组件卸载时把这个定时器再清理掉,这个过程就是清理副作用。说明:清除副作用的函数最常见的执行时机是在组件
卸载时自动执行
需求:在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函数可以用来实现逻辑的封装和复用
。
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;
使用规则
- 只能在组件中或者其他自定义Hook函数中调用
- 只能在组件的顶层调用,不能嵌套在if,for,其他函数中
Redux
介绍
Redux是React最常用的
集中状态管理工具
,类似与vue中的pinia(Vuex),可以独立于框架运行
。作用:通过集中管理的方式管理应用的状态。
redux管理数据流程梳理
为了职责清晰,数据流项明确,redux把整个数据修改的流程分成三个核心概念,分别是:state,action,reducer
- state:一个对象,存放我们管理的数据状态
- action:一个对象,用来描述你想怎么改数据
- 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 Toolkit
和react-redux
;
- Redux Toolkit(RTK):官方推荐编写redux逻辑的方式,是一套工具的集合,简化书写。
- 简化store的配置方式
- 内置immer支持可变式状态修改
- 内置thunk更好的异步创建
- react-redux:用来链接redux和react组件的中间件
配置基础环境
- 使用CRA快速创建React项目
1 npx create-react-app 项目名
- 安装配套工具
1 npm i @reduxjs/toolkit react-redux
- 启动项目
1 npm run startstore目录结构设计
- 通常集中状态管理的部分都会单独创建一个单独的”store”目录
- 应用通常会有很多个子store模块,所有创建一个”modules”目录,在内部编写分类的子store
- store中入口文件index.js的作用是组合modules中所有的子模块,并导出store
实现counter
使用React Toolkit创建counterStore
: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)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中数据映射到组件中,使用样例如下: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函数,使用后样例如下: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>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
export default App;
提交action传参
:需求:
组件中有两个按钮”add to 10”和”add to 20”可以直接把count值修改到对应的数字,目标count值是在组件中传递过去的,需要
在提交action的时候传递参数
实现步骤:
在reducers的同步修改方法中添加action对象参数,在调用
actionCreater
的时候传递参数,参数会被传递到action对象payload属性上。异步状态操作
- 创建store的写法保存不变,配置好同步修改状态的方法
- 单独封装一个函数,在函数内部return一个新函数,在新函数中
- 封装异步请求获取数据
- 调用同步
actionCreater
传入异步数据生成一个action对象,并使用dispatch提交- 组件中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 storesrc/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>
<span>{count}</span>
<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对应的中间会在页面中进行渲染。
环境准备
- 创建项目并安装所有依赖
1
2 npx create-react-app (react-router-pro)项目名
npm i
- 安装最新的ReactRouter包
1 npm i react-router-dom
- 启动项目
1 npm run start快速开始
需求:创建一个可以切换登录页和文章页的路由系统
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();
抽象路由模块
实际项目配置
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();
路由导航
概念:路由系统中的多个路由组件需要进行
路由跳转
,并且在跳转的同时有可能需要传递参数进行通信
。声明式导航
声明式导航是指通过在模板中通过
‘<Link/>’组件描述要跳转到哪里
去,比如后台管理系统的左侧菜单通常使用这种方式进行。说明:通过给组件的
to属性指定要跳转到路由path
,组件会被渲染为浏览器支持的a链接,如果需要传参
直接通过字符串拼接
的方式拼接参数即可.编程式导航
编程式导航是指同
useNavigate
钩子函数得到导航方法,然后通过调用方法以命令式的形式进行路由跳转
,比如向在登录请求完毕之后跳转就可以选择这种方式,更加灵活。说明:通过调用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
路由导航传参
searchParams传参
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 Articleparams传参
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
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
嵌套路由
概念:在一级路由中又内嵌了其他路由,这种关系叫做嵌套路由,嵌套值一级路由内的路由又称为二级路由。
步骤:
- 使用
children属性
配置路由嵌套关系- 使用
<Outlet/>
组件配置二级路由渲染位置。
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;默认二级路由
当访问的是以及路由是,默认的二级路由组件可以得到渲染,只需要在二级路由的位置
去掉path
,设置index属性为true
404路由配置
场景:当浏览器输入url的路径在整个路由配置中都找不到对应的path,为了用户体验,可以使用404兜底组件进行渲染。
步骤:
- 准备一个NotFound组件
- 在路由表数组的末尾,以*号作为路由path配置路由
两种路由模式
history模式
和hash模式
,ReactRouter
分别由createBrowerRouter
和createHashRouter
函数负责创建
钩子函数
useReducer
作用:和useState作用类似,用来管理
相对复杂
的状态数据。基础用法:
- 定义一个reducer函数(根据不同的action返回不同的新状态)
- 在组件中调用useReducer,并传入reducer函数和和状态初始值。
- 事件发生事,通过dispatch函数分派一个action对象(通知reducer要返回哪个新状态并渲染UI)
分派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 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
作用:在组件每次重新渲染的时候
缓存计算的结果
需求:
基础语法:
说明:使用useMemo做缓存之后可以保证只有count1依赖项发生事才会重新计算。
React.memo
作用:允许组件在Props没有改变的情况下跳过渲染
React组件默认的渲染机制:只要父组件重新渲染子组件就会重新渲染。
语法:
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只更新引用是否变化。
- 传递一个
简单类型
的prop时, 当prop变化时,子组件重新渲染- 传递一个
引用类型
的prop, 当父组件重新渲染时,prop本身没有变化,但是prop变成了新的引用,子组件重新渲染- 保证引用稳定->使用useMemo缓存该引用类型
1
2
3 const list = useMemo(()=> {
return [1,2,3]
},[])useCallback
作用:在组件多次重新渲染时缓存函数。
说明:使用
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
场景说明:
语法实现:
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
将子组件内部的函数暴露给父组件使用
场景说明:
语法:
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中的类来组织组件的代码
- 通过类属性state定义状态数据
- 通过
setState
方法来修改状态数据- 通过
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;
类组件的说明周期函数
概念:组件从创建到销毁的各个阶段自动执行的函数就是生命周期函数
- componetDidMount:组件挂载完毕自动执行——
异步数据获取
- 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编写的组件在组件通信的思想完全一致
- 父传子:通过prop绑定数据
- 子传父:通过prop绑定绑定父组件中的函数,子组件调用
- 兄弟通信:状态提升,通过父组件做桥接
zustand
一个简单状态管理工具
快速上手
官网:https://zustand-demo.pmnd.rs/
实例:
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注意事项:
- 函数参数必须返回一个
对象
,对象内部编写状态数据和方法- 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;切片模式
场景:当单个store比较大的时候,可以采用切片模式进行模块拆分组合,类似于模块化。
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;结构化模块
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项目目录结构
useState
自动推导
通常React会根据传入
useState的默认值
来自动推导类型,不需要显示标注类型。场景:适合明确的初始值说明:
- value:类型为boolean
- 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本身是一个泛型函数,可以传入具体的自定义类型。
说明:
- 限制useState函数参数的初始值必须满足类型User | () => User
- 限制setUser函数的参数必须满足类型为User | () => User | undefined
- 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来做显示式注解
说明:
限制useState函数参数的初始值可以是User|null
限制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接口
来做注解说明: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类型
来做注解说明:注解之后,children可以是多种类型,包括:React.ReactElement,string,number,React.ReactFragment,React.ReactPortal,boolean,null,undefined
为事件prop添加类型
组件经常执行类型为函数的prop实现子传父,子类prop重点在于函数参数类型的注解
说明:
- 在组件内部调用时需要遵守类型的约束,参数传递需要满足要求
- 绑定prop是如果绑定内联函数直接可以推断处参数类型,否则需要单独注解匹配的参数类型
useRef
获取dom
获取dom场景,可以直接把要获取的dom元素的类型当成泛型参数传递给useRef,可以推导出.current属性的类型。
引用稳定的存储器
把useRef当成引用稳定的存储器使用的场景可以通过泛型传入联合类型来做,比如定时器的场景。
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 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'));
}
}
- 评论功能
- 获取评论内容
- 点击发布按钮发布评论
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>
- id处理和时间处理
- rpid要求一个唯一的随机值id ——
UUID
- 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}
/>
优化需求
- 通过接口获取评论列表
- 使用
json-server
工具模拟接口服务,通过axios
发送接口请求
json-server
是一个快速以.json
文件作为数据源模拟接口服务的工具。axios
是一个广泛使用的前端请求库- 使用
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组件
抽象原则: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
开发思路:使用RTK(Redux Toolkit)来管理应用状态,组件负责数据渲染和dispatch action
环境准备
- 克隆项目到本地(内置了进程金泰组件和模板)
1 git clone http://git.itcast.cn/heimaqianduan/redux-meituan.git
- 安装所有依赖
1 npm i
- 启动mock服务(内置了json-server)
1 npm run serve
- 启动前端服务
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导航样式动态激活
思路:状态管理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>添加购物车
步骤:
- 使用RTK管理新状态cartList
- 如果添加过,只更新数量count,没有添加过,直接push进去
- 组件中点击时手机数据提交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统计区域
实现步骤:
- 基于store中的cartList的length渲染数量
- 基于store的cartList累加price*count
- 购物车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>
)}购物车列表
步骤:
- 使用cartList遍历渲染列表
- RTK中增加
增减reducer
,组件中提交action- 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>控制购物车显隐
步骤:
- 使用useState声明控制显隐
- 点击统计区域设置状态为true
- 点击蒙层区域设置为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')}
/>
记账本-router
环境搭建
功能演示
搭建
使用CRA创建项目,安装必要依赖,包括下列基础包。
- Redux状态管理——
@reduxjs/toolkit,react-redux
- 路由——
react-router-dom
- 时间处理——
dayjs
- class类名处理——
classnames
- 移动端组件库——
antd-mobile
- 请求插件——
axios
1 npm i @reduxjs/toolkit react-redux react-router-dom dayjs classnames antd-mobile axios配置别名路径@
- 路径解析配置(webpack),把@/解析为src/
- 路径联想配置(VsCode),VsCode在输入@/时,自动联想出来对应的src/下的子级目录
CRA本身把webpack配置包装到了黑盒里无法直接修改,需要借助一个插件——
craco
步骤:
- 安装craco
1 npm i -D @craco/craco
- 项目
根目录
下创建配置文件“craco.config.js
”- 配置文件中添加路径解析配置
- 包文件中配置启动和打包命令
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会自动读取配置帮助我们自动联想提示。
配置步骤:
- 根目录下新增文件——jsconfig.json
- 添加路径提示配置
1
2
3
4
5
6
7
8
9
10 {
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
}
}
数据Mock实现
在前后端分离开发模式下,前端可以在没有在实际后端接口的支持下先进行接口数据的模拟,进行正常的业务功能开发。
市场常见的Mock方式
使用
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文件
整体路由设计
两个一级路由:/(Layout页), /new(新建页)
两个二级路由: /mouth(月收支账单),/year(年收支账单)
当前目录结构
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;
antD-mobile主题定制
定制方案
- 全局定制:整个应用范围内组件都生效
- 局部定制:只在某些元素内部的中间生效
实现方式
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;全局定制的优先级大于局部定制。局部定制只能在局部使用,全局定制可以在全局使用。
Redux管理账目列表
步骤:
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;
TabBar功能
需求:使用antD的
TabBar标签栏组件
进行布局以及路由的切换。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 Layoutsrc/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) }}
/>点击确定切换时间显示
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>账单数据按月分组
当前后端返回的数据时简单的平铺,表示按月份划分好的,而是要做的功能是
以月为单位的统计
。
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)计算选择月份的统计数据
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])月度初始化时渲染统计数据
需求:打开月度账单是,把当前月份的统计数据渲染到页面中
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])单日统计列表实现
需求:把
当前月
的账单数据以单日为单位
进行统计显示.基础组件:
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>}账单类型图标组件封装
需求:封装一个图标组件,可以根据不同的账单类型显示不同的图标。
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>}
新增账单
基础页面搭建
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);
}
}
}支出和收入切换功能实现
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 (...)})
}新增账单
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}
极客-综合实践
搭建
初始化环境
- 使用CRA创建项目
1 npx create-react-app react-jike
- 按照业务规范整理项目目录
(重点src目录)
安装Scss
概念:Scss是一种后缀名为.scss的
预编译CSS语言
,支持一些原生CSS不支持的高级用法,比如变量
使用,嵌套语法
等,使用Scss可以让样式代码更加高效灵活
。CRA项目接入scss:
1 npm i sass -D测试.scss文件是否可用(嵌套语法)
安装 Ant Design
介绍:Ant Design是有蚂蚁金服产品的社区使用最广的React
PC端组件库
,内置了常用的现成组件
,可以帮助我们快速开发PC管理后台项目。官网:https://ant-design.antgroup.com/docs/react/introduce-cn
项目中引用:
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
- 安装路由包
react-router-dom
- 准备两个基础路由组件
Layout和Login
- 在router/index.js文件中任意组件进行路由配置,
导出router实例
- 在入口文件中渲染
<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>
);配置别名路径@
- 路径解析配置(webpack),把@/解析为src/
- 路径联想配置(VsCode),VsCode在输入@/时,自动联想出来对应的src/下的子级目录
CRA本身把webpack配置包装到了黑盒里无法直接修改,需要借助一个插件——
craco
步骤:
- 安装craco
1 npm i -D @craco/craco
- 项目
根目录
下创建配置文件“craco.config.js
”- 配置文件中添加路径解析配置
- 包文件中配置启动和打包命令
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会自动读取配置帮助我们自动联想提示。
配置步骤:
- 根目录下新增文件——jsconfig.json
- 添加路径提示配置
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;
}
}表单校验
表单校验可以在提交登录之前
校验用户的输入是否符合预期
,如果不符合就组织提交,显示错误信息。
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三方库做好统一封装,方便统一管理和复用。
原因:
- 几乎所有接口都是语言的接口域名
- 几乎所有的接口都需要设置语言的超时时间
- 几乎所有的接口都需要做Token权限处理
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可以方便的解决状态共享问题.
- Redux中编写获取Token的异步获取和同步修改
- 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 />token持久化
Redux存入token之后如果
刷新浏览器
,token会丢失(持久化就是刷新时丢失token)原因:Redux时基于浏览器内存的存储方式,刷新时状态恢复为初始值。
解决:
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的各类操作在项目多个模块中都有用到,为了共享复用可以封装成工具函数。
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
。
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的有无控制当前路由是否可以跳转
就是路由的权限控制
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 ;
}
消除浏览器默认样式
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%;
}二级路由配置
组件创建
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/>
.菜单点击跳转路由
实现效果:点击左侧菜单可以跳转到对应的目标路由
思路:
- 左侧菜单要和路由形成
一一对应的关系
(知道点了谁)- 点击时拿到路由
路径
调用路由方法跳转
(跳转到对应的路由下面)
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>高亮当前路径的菜单项
实现效果:页面在
刷新时
可以根据当前的路由路径让对应的左侧菜单高亮显示步骤:
- 获取当前url上的路由路径
- 找到菜单组件负责高亮的属性,绑定当前的路由路径
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>展示个人信息
和Token令牌类似,用户的信息通常很有可能在多个组件中都需要
共享使用
,所以同样应该放到Redux中维护
。
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);退出登录
- 提示用户是否确认要退出(危险操作,二次确认)
- 用户确认之后清除用户信息(Token以及其他个人信息)
- 跳转到登录页(为下次登录做准备)
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状态码
,前端可以监控这个状态做后续操作。
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/
1 npm install echartsEcharts组件封装
组件封装做啊解决了
复用
的问题。
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;API模块封装
当前接口请求放到了功能实现的位置,没有在规定的模块内维护,后期查找维护困难。
把项目中所有接口按照业务模块一函数的形式统一封装到apis模块中
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;
}
}富文本编译器
实现步骤
- 安装富文本编辑器
- 导入富文本编辑器组件以及样式文件
- 渲染富文本编辑器组件
- 调整富文本编辑器的样式
代码落地
1-安装react-quill
1 npm i react-quill@2.0.0-beta.2 --legacy-peer-deps2-导入资源渲染组件
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;
}
}频道数据获取渲染
- 根据接口文档的API模块中封装接口函数
- 使用
useState
维护数据- 在
useEffect
中调用接口获取数据并存入state- 绑定数据到下拉框组件
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>收集表单数据提交表单
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}
>上传文章封面基础功能
准备上传结构
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>实现基础上传
实现步骤
- 为 Upload 组件添加
action 属性
,配置封面图片上传接口地址- 为 Upload组件添加
name属性
, 接口要求的字段名- 为 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>
)
}切换封面类型
只有当前模式为单图或三图模式是才显示上传组件
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
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
Article
页面基础搭建
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
- 创建一个use打头的函数
- 在函数中封装业务逻辑,并return出组件中药用到的状态数据
- 组件中导入函数执行并结果状态数据使用
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>适配文章状态
根据文章的不同状态在状态列显示同Tag
思路:
- 如果要适配在状态只有两个——三元运算符渲染
- 如果要适配的状态有多个——枚举渲染
通过枚举方式
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>
}根据搜索条件查询
筛选功能的本质:给请求列表接口传递不同的参数和后端要不同的数据
步骤:
- 准备完整的请求参数对象
- 获取用户选择的表单数据
- 把表单数据放置到接口对应的字段中
- 重新调用文章列表接口渲染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>分页
步骤:
实现分页展示(页数 = 总数/每页条数)
点击分页拿到当前点击的页数
使用页数作为请求参数重新获取文章列表渲染
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})
}
}
}/>删除文章
步骤:
- 点击删除弹出确认框
- 得到文章id,使用id调用删除接口
- 更新文章列表
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
})
}回填基础数据
步骤:
- 通过文章id获取文章详情数据
- 调用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>回填封面信息
- 使用cover中的type字段回填封面类型
- 使用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适配状态
实现效果:发布文章是显示发布文章,编辑文章状态下显示编辑文章。
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>更新文章
步骤:
更新文章和新增文章相比,大部分的逻辑都是一致的,稍作参数适配调用不同接口即可
- 适配url参数
- 调用文章更新接口
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项目本地预览
实现步骤
- 全局安装本地服务包
npm i -g serve
该包提供了serve命令,用来启动本地服务器- 在项目根目录中执行命令
serve -s ./build
在build目录中开启服务器- 在浏览器中访问:
http://localhost:3000/
预览项目优化-路由懒加载
介绍:路由懒加载时指路由的JS资源只有在被访问时才会动态获取,目的是为了
优化项目首次打开时间
使用步骤
- 使用
lazy 方法
导入路由组件- 使用内置的
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面板资源的请求情况,验证是否分隔成功打包-打包体积分析
业务背景
通过分析打包体积,才能知道项目中的哪部分内容体积过大,方便知道哪些包如何来优化
使用步骤
- 安装分析打包体积的包:
npm i source-map-explorer
- 在 package.json 中的 scripts 标签中,添加分析打包体积的命令
- 对项目打包:
npm run build
(如果已经打过包,可省略这一步)- 运行分析命令:
npm run analyze
- 通过浏览器打开的页面,分析图表中的包体积
核心代码:
1
2
3 "scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
}优化-配置CDN
介绍:CDN是一种内容分发网络服务,当用户请求网站内容是,由
离用户最近的服务器
将缓存的资源内容
传递给用户。哪些资源可以放到CDN服务器?
- 体积较大的非业务js文件,比如react,react-dom
- 体积较大,需要利用CDN文件在浏览器的缓存特性,加快加载时间
- 非业务js文件,不需要经常做变动,CDN不用频繁更新缓存
项目中使用:
- 把需要做CDN缓存的文件排除在打包之外(react,react-dom)
- 以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说明:
- npm create vite@latest 固定写法(使用最新版本vite初始化项目)
- react-jike-mobile 项目名称(自定义)
- – –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
进行配置
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>
)配置路径别名
- 让Vite做路径解析,把@/解析为src/
- 路径联想配置(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/*"
]
},
}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泛型方法,方便我们传入类型的参数推导处接口返回值的类型。
说明:泛型参数
Type的类型决定了res.data的类型
步骤:
- 根据接口文档创建一个
通用的泛型接口类型
(多个接口返回值的结果是相似的)- 根据接口文档创建
特有的的接口数据类型
(每个接口有自己特殊的数据格式)- 组合1和2的类型,得到最终传给request泛型的参数类型
API模块测试使用
封装泛型
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
整体组件嵌套设计
tabs区域
步骤:
- 使用ant-mobile组件库中的Tabs组件进行页面结构创建
- 使用真实接口数据进行渲染
- 有优化的点进行优化处理
自定义hook函数优化
场景:当前状态数据的各种操作逻辑和组件渲染时写在一起的,可以采用
自定义hook封装的方式让逻辑和渲染向分离
步骤:
- 把和Tabs相关的响应式数据状态以及操作数据的方法放到hook函数中
- 组件中调用hook函数,消费其返回的状态和方法
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%;
}List组件
步骤:
- 搭建基础结构,并获取基础数据
- 为组件设计
ChannelId
参数,点击tab是传入不同的参数- 实现上拉加载功能
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 HomeListList列表无限滚动
交换要求:List列表在滑动到底部时,自动加载下一页列表数据
思路:
- 滑动到底部触发加载下一页动作
<InfiniteScroll/>
- 加载下一页数据:
pre_tiemstamp接口参数
- 把老数据和新数据做拼接处理:
[...oldList,...newList]
- 停止监听边界值:
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
需求:点击列表中的某一项跳转到详情路由并显示当前文章
- 通过路由跳转方法进行跳转,并传递参数
- 在详情路由下获取参数,并请求数据
- 渲染数据到页面中
获取响应数据ts类型
路由跳转传参
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