React基础
一、React介绍
React由Meta公司开发,是一个用于 构建Web和原生交互界面的库 
1. React的优势
相较于传统基于DOM开发的优势
- 组件化的开发方式
- 不错的性能
相较于其它前端框架的优势
- 丰富的生态
- 跨平台支持
2. React的市场情况
全球最流行,大厂必备 
二、开发环境创建
1. 创建React项目
create-react-app是一个快速创建React开发环境的工具,底层由Webpack构件,封装了配置细节,开箱即用 执行命令:
npx create-react-app react-basic- npx - Node.js工具命令,查找并执行后续的包命令
- create-react-app - 核心包(固定写法),用于创建React项目
- react-basic React项目的名称(可以自定义)
创建React项目的更多方式 https://zh-hans.react.dev/learn/start-a-new-react-project
也可以使用 vite 进行创建
pnpm create vite my-react-app --template react pnpm create vite my-react-app --template react-ts
2. 项目入口文件
src/index.js
// 项目入口文件,从这里开始运行
// React必要的两个核心包
import React from "react";
import ReactDOM from "react-dom/client";
// 导入项目的根组件
import App from "./App";
// 把App根组件渲染到id为root的DOM节点下
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);Tips:使用不同的构建工具
vite、webpack创建的项目入口文件不同
3. 项目根组件
src/App.jsx
// 项目根组件
// App -> index.jsx -> public/index.html(root)
function App() {
return <div className="App">this is App</div>;
}
export default App;组件:首字母大写的函数
三、JSX基础
1. 什么是JSX
概念:JSX是JavaScript和XMl(HTML)的缩写,表示在JS代码中编写HTML模版结构,它是React中构建UI的方式
const message = 'this is message'
function App(){
return (
<div>
<h1>this is title</h1>
{message} // 模板中使用变量
</div>
)
}优势:
- HTML的声明式模版写法
- JavaScript的可编程能力
2. JSX的本质
JSX并不是标准的JS语法,它是 JS的语法扩展,浏览器本身不能识别,需要通过解析工具做解析之后才能在浏览器中使用

3. JSX高频场景-JS表达式
在JSX中可以通过 大括号语法{} 识别JavaScript中的表达式,比如常见的变量、函数调用、方法调用等等
- 使用引号传递字符串
- 使用JS变量
- 函数调用和方法调用
- 使用JavaScript对象
注意:if语句、switch语句、变量声明不属于表达式,不能出现在{}中
const count = 100;
function getName() {
return "Tom";
}
function App() {
return (
<div className="App">
this is App
{/*使用引号传递字符串 */}
{"this is message"}
<br />
{/* 识别JS变量 */}
{count}
<br />
{/* 函数调用 */}
{getName()}
{/* 方法调用 */}
{new Date().getDate()}
{/* 使用JS对象 */}
<div style={{ color: "red" }}>this is div</div>
</div>
);
}4. JSX高频场景-列表渲染

在JSX中可以使用原生js中的map方法 实现列表渲染
// 要渲染的列表数据
const list = [
{ id: 1001, name: "Vue" },
{ id: 1002, name: "React" },
{ id: 1003, name: "Angular" },
];
function App() {
return (
<div className="App">
{/* 渲染列表
map 循环哪个结构 return结构
注意: 为循环出的元素添加独一无二的 key 属性(string/number)
key的作用: dom复用提升性能
*/}
<ul>
{list.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Tips:可以将渲染的 html 结构使用括号 () 进行包裹
5. JSX高频场景-条件渲染

在React中,可以通过逻辑与运算符&&、三元表达式(?😃 实现基础的条件渲染
let isLogin = true;
function App() {
return (
<div className="App">
{/* 逻辑&& */}
{isLogin && <span>Jack</span>}
{/* 三元运算 */}
{isLogin ? <div>Jack</div> : <div>请登录!</div>}
</div>
);
}Tips:条件渲染实现的是组件 挂载 <==> 卸载
6. JSX高频场景-复杂条件渲染
需求:列表中需要根据文章的状态适配

解决方案:自定义函数 + 判断语句(if,else if,....,else)返回要渲染的内容
// 定义文章类型
let articleType = 1; // 0,1,3
// 定义核心函数(根据文章类型返回不同的JSX模板)
function getArticleTem() {
if (articleType === 0) return <div>我是无图文章</div>;
else if (articleType === 1) return <div>我是单图文章</div>;
else return <div>我是多图文章</div>;
}
function App() {
return (
<div className="App">
{/* 调用函数渲染不同的模板 */}
{getArticleTem()}
</div>
);
}四、React的事件绑定
1. 基础实现
React中的事件绑定,通过语法 on + 事件名称 = { 事件处理程序 },整体上遵循驼峰命名法
function App() {
const handleClick = () => {
alert("你点击了我!!!");
};
return (
<div className="App">
<button onClick={handleClick}>点我给你好看!!!</button>
</div>
);
}2. 使用事件参数e
在事件回调函数中设置声明接收 e 即可
function App(){
const clickHandler = (e)=>{
console.log('button按钮点击了', e)
}
return (
<button onClick={clickHandler}>click me</button>
)
}3. 传递自定义参数
语法:事件绑定的位置改造成箭头函数的写法 return事件回调函数并传递实参
function App(){
const clickHandler = (name)=>{
console.log('button按钮点击了', name)
}
return (
<button onClick={()=>clickHandler('jack')}>click me</button>
)
}注意:不能直接写函数调用,这里事件绑定需要一个函数引用
4. 同时传递事件对象e和自定义参数
语法:在事件绑定的位置传递事件参数 e 和自定义参数,clickHandler中声明接收,注意顺序对应
function App(){
const clickHandler = (name,e)=>{
console.log('button按钮点击了', name,e)
}
return (
<button onClick={(e)=>clickHandler('jack',e)}>click me</button>
)
}五、React组件基础使用
1. 组件是什么
概念:一个组件就是一个用户界面的一部分,它可以有自己的逻辑和外观,组件之间可以互相嵌套,也可以复用多次 
2. 组件基础使用
在React中,一个组件就是首字母大写的函数,内部存放了组件的逻辑和视图UI,渲染组件只需要把组件当成标签书写即可
// Button组件
// 1.定义组件(使用普通函数定义组件)
function Button() {
// 业务逻辑/组件逻辑
return <button>我是button组件</button>;
}
// 使用箭头函数定义组件
const Test = () => {
return <div>我是测试组件</div>;
};
// App 根组件
function App() {
return (
<div className="App">
{/* 2.使用组件 */}
{/* 自闭和 */}
<Button />
{/* 成对标签 */}
<Button></Button>
<Test></Test>
</div>
);
}
// 暴露App组件
export default App;注意:定义组件可以使用箭头函数/普通函数,函数名首字母必须大写
六、组件状态管理-useState
1. 基础使用
useState 是一个 React Hook(函数),它允许我们向组件添加一个状态变量,从而控制影响组件的渲染结果
本质:和普通JS变量不同的是,状态变量一旦发生变化组件的视图UI也会跟着变化(数据驱动视图更新)

// useState实现一个计数器按钮
import { useState } from "react";
// App 根组件
function App() {
// 1.调用useState添加一个状态变量
// 返回一个数组,count:状态变量 setCount:修改状态变量的方法
const [count, setCount] = useState(0);
// 2.点击按钮事件回调
const handleClick = () => {
// 调用setCount修改状态变量,驱动视图更新
setCount(count + 1);
};
return (
<div className="App">
<button onClick={handleClick}>点我加一</button>
<button onClick={() => setCount(count - 1)}>点我减一</button>
<span>{count}</span>
</div>
);
}
const [count, setCount] = useState(0)
useState是一个函数,返回值是一个数组- 数组中的第一个参数是状态变量(只可读不可进行修改),第二个参数是
set函数用来修改变量(只可整体替换)useState的参数将作为count的初始值(当初始值需要复杂运算时,可以写成一个函数返回结果)
Tips:
当
useState的初始值需要经过计算得到时,可以传递一个回调函数,在函数内部进行计算jsconst [currentDate, setCurrentDate] = useState(()=>{ return new Date() })
2. 状态的修改规则
状态不可变:
在React中状态被认为是只读的,我们应该始终替换它而不是改变现有对象,直接修改状态不能引发视图更新
注意:状态变量不可直接修改,必须调用
set方法进行替换,才能触发视图的更新
3. 修改对象状态
对于对象类型的状态变量,应该始终给set方法一个全新的对象 来进行替换

Tips:修改嵌套对象,数组的方法
七、组件的基础样式处理
React组件基础的样式控制有两种方式,行内样式 和 class类名控制
- 行内样式(不推荐)
// 将行内样式进行抽离
const style = {
color: "orange",
fontSize: "80px", // font-size -> fontSize
};
function App() {
return (
<div className="App">
{/* 行内样式控制 */}
<div style={{ color: "aqua", fontSize: "50px" }}>组件基础样式处理</div>
<div style={style}>组件基础样式处理</div>
</div>
);
}- class 类名控制 (className)
index.css
.foo {
color: pink;
font-size: 100px;
}App.jsx
// 导入样式
import "./index.css";
function App() {
return (
<div className="App">
{/* 通过class类名控制 */}
<div className="foo">组件基础样式处理</div>
</div>
);
}八、B站评论案例-数据渲染
1. 需求分析

- 渲染评论列表
- 删除评论实现
- 渲染导航Tab和高亮实现
- 评论列表排序功能实现
2. 渲染评论列表
实现步骤:
- 使用
useState维护评论列表 - 使用
map方法对列表数据进行遍历渲染
3. 评论删除
需求:
- 只有自己的评论才显示删除按钮
- 点击删除按钮,删除当前评论,列表中不再显示
实现步骤:
- 删除显示 - 条件渲染
- 删除功能 - 拿到当前项 id 以 id 为条件对评论列表做 filter 过滤
const App = () => {
// 2.删除评论按钮的事件回调
const onDeleteComm = (rId) => {
// 更新commentList中的数据 filter方法返回一个新数组
setCommentList(commentList.filter((item) => item.rpid !== rId));
};
return(
.......
{/* 条件 user.uid === item.user.uid */}
{user.uid === item.user.uid && (
<span
onClick={() => onDeleteComm(item.rpid)}
className="delete-btn"
>
删除
</span>
)}
)
}4.渲染Tab+点击高亮
需求:
- 点击哪个tab项,哪个做高亮处理
实现步骤:
- 点击谁就把谁的 type(独一无二的标识)记录下来,然后和遍历时的每一项 type 做匹配,谁匹配到就设置高亮类名(动态类名)
const App = () => {
// 当前激活的tab的type(用于动态控制类名)
const [activeTabType, setActiveTabType] = useState(tabs[0].type)
return(
<li className="nav-sort">
{/* 遍历生成tab */}
{/* 高亮类名: active */}
{tabs.map((item) => (
<span
className={
activeTabType === item.type ? "active nav-item" : "nav-item"
}
key={item.type}
onClick={() => setActiveTabType(item.type)} // 更新当前激活tab的type
>
{item.text}
</span>
)))
</li>
}
}5. 排序功能
需求:
- 点击最新,评论列表按照创建时间倒序排列(新的在前),点击最热按照点赞排序(多的在前)
实现步骤:
- 当点击 tab 时把评论列表状态数据进行不同的排序处理,当成新值传给 set 函数重新渲染页面(数据驱动视图更新)
// 导入lodash
import _ from "lodash";
const App = ()=>{
// 使用 useState 维护数据(初始时对其进行排序)
const [commentList, setCommentList] = useState(
_.orderBy(list, "like", "desc")
);
// 点击切换tab的事件回调
const onChangeTab = (type) => {
// 更新当前激活tab的type值
setActiveTabType(type);
// 对评论列表数据进行排序 lodash
if (type === "hot") {
// 使用sort函数排序
// setCommentList(commentList.sort((a, b) => b.like - a.like));
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["like"], ["desc"])); // 对like属性进行降序排列
} else {
// 使用sort函数排序
/* setCommentList(
commentList.sort((a, b) => new Date(b.ctime) - new Date(a.ctime))
); */
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["ctime"], ["desc"]));
}
};
return(
<li className="nav-sort">
{/* 遍历生成tab */}
{/* 高亮类名: active */}
{tabs.map((item) => (
<span
className={
activeTabType === item.type ? "active nav-item" : "nav-item"
}
key={item.type}
onClick={() => onChangeTab(item.type)}
>
{item.text}
</span>
)))
</li>
}
}6. classnames优化类名控制
classnames 是一个简单的 JS 库,可以非常方便的通过条件动态控制 class 类名的显示
原本写法:不够直观容易出错
className={ activeTabType === item.type ? "active nav-item" : "nav-item"}使用 classnames写法:
安装 classnames:npm i classnames
// 导入 classNames
import classNames from "classnames";
className={classNames('nav-item', {active: activeTabType === item.type})}- nav-item: 静态类名
- active: 动态类名
- type === item.type: 条件判断,结果为 true 添加该类名
九、React表单控制
1. 受控绑定
概念:使用React组件的状态(useState)控制表单的状态

实现步骤:
- 准备一个
React状态值 - 通过
value属性绑定状态,通过onChange事件更新状态值驱动视图更新
function App(){
const [value, setValue] = useState('')
return (
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)} // 修改state中的数据
/>
)
}Tips:通过事件对象
e.target.value获取input输入框的值,再通过set方法更新state的值(实现数据的双向绑定)
2. 非受控绑定(React中获取DOM)
概念:通过获取DOM的方式获取表单的输入数据
React中获取DOM:
在React组件中获取/操作 DOM,需要使用 useRef 钩子函数,分为两步:
使用
useRef创建ref对象,并与JSX绑定jsconst inputRef = useRef(null)html<input type="text" ref={inputRef}></input>在DOM可用时,通过
inputRef.current拿到 DOM 对象jsconsole.dir(inputRef.current)
function App(){
const inputRef = useRef(null)
const onChange = ()=>{
console.log(inputRef.current.value)
}
return (
<input
type="text"
ref={inputRef}
onChange={onChange}
/>
)
}Tips:DOM可用指的是组件渲染完毕后,才可以获取到
十、B站评论案例-发布评论

核心功能实现:
- 获取评论内容
- 点击发布按钮发布评论
评论id和时间格式化处理:
清空内容并重新聚焦:
- 把控制
input框的value状态设置为空串 - 使用
useRef获取输入框组件实例对象调用focus方法
// 导入 uuid 用于生成唯一的id
import { v4 as uuidv4 } from "uuid";
// 导入 dayjs 用于格式化日期
import dayjs from "dayjs";
const App = () => {
// 使用 useState 维护数据(初始时对其进行排序)
const [commentList, setCommentList] = useState(
_.orderBy(list, "like", "desc")
);
// 当前激活的tab的type
const [activeTabType, setActiveTabType] = useState(tabs[0].type);
+ // 评论内容
+ const [content, setContent] = useState("");
+ // 评论框的ref
+ const textAreaRef = useRef(null);
// 删除评论按钮的事件回调
const onDeleteComm = (rId) => {
// 更新commentList中的数据 filter方法返回一个新数组
setCommentList(commentList.filter((item) => item.rpid !== rId));
};
// 点击切换tab的事件回调
const onChangeTab = (type) => {
// 更新当前激活tab的type值
setActiveTabType(type);
// 对评论列表数据进行排序 lodash
if (type === "hot") {
// 使用sort函数排序
// setCommentList(commentList.sort((a, b) => b.like - a.like));
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["like"], ["desc"]));
} else {
// 使用sort函数排序
/* setCommentList(
commentList.sort((a, b) => new Date(b.ctime) - new Date(a.ctime))
); */
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["ctime"], ["desc"]));
}
};
+ // 点击发表评论按钮的事件回调
+ const onPublishComment = () => {
+ // 将 textarea中的内容添加到评论列表中
+ setCommentList([
+ ...commentList,
+ {
+ rpid: uuidv4(), // 使用uuid第三方库
+ user,
+ content: content,
+ ctime: dayjs(new Date()).format("YY-MM-DD hh:mm"), // 使用 dayjs格式化日期
+ like: 0,
+ },
+ ]);
+ // 清空输入框中的数据
+ setContent("");
+ // 获取焦点
+ textAreaRef.current.focus();
+ };
return (
......
<div className="reply-box-wrap">
{/* 评论框 */}
<textarea
className="reply-box-textarea"
placeholder="发一条友善的评论"
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ ref={textAreaRef}
/>
{/* 发布按钮 */}
<div className="reply-box-send">
+ <div className="send-text" onClick={onPublishComment}>
发布
</div>
</div>
</div>
</div>
....十一、React组件通信
概念:组件通信就是组件之间的数据传递, 根据组件嵌套关系的不同,有不同的通信手段和方法
- A-B 父子通信
- B-C 兄弟通信
- A-E 跨层通信
1. 父子通信-父传子

1.1 props
**实现步骤 **
- 父组件传递数据 - 在子组件标签上绑定属性
- 子组件接收数据 - 子组件通过props参数接收数据
function Son(props) {
// props: 对象里面包含了父组件传递过来的全部数据
console.log(props);
return (
<>
<div>this is son</div>
{/* 使用父组件传递的数据 */}
<div>{props.name}</div>
</>
);
}
function App() {
const [name, setName] = useState("this is app");
return (
<>
<Son name={name} />
</>
);
}注意:
JSX表达式必须具有一个根元素,可以使用<></>作为根元素,且不会被渲染jsxfunction Son(props) { return ( <> <div>this is son</div> <div>{props.name}</div> </> ); }
1.2 props说明
props可以传递任意的合法数据,比如数字、字符串、布尔值、数组、对象、函数、JSX 
注意:
props是只读对象,不可进行修改(单项数据流)子组件只能读取
props中的数据,不能直接进行修改, 父组件的数据只能由父组件修改
1.3 特殊的prop.chilren
场景:当我们把内容嵌套在组件的标签内部时,组件会自动在 prop.children 接收该内容



function Son(props) {
console.log(props); // children: Array(2)
return (
<>
{props.children} {props.children[0]}
</>
);
}
function App() {
return (
<>
<Son>
<span>this is span</span>
<div>this is div</div>
</Son>
</>
);
}2. 父子通信-子传父
核心思路:在子组件中调用父组件中的函数并传递参数(父组件使用 props 传递一个函数给子组件)

function Son({ onGetSonMsg }) { // 2.从props中解构获取传递的方法
// 子组件中的数据
const sonMsg = "我是子组件的数据";
return (
<>
this is Son
{/* 3.调用父组件的方法并传递实参 */}
<button onClick={() => onGetSonMsg(sonMsg)}>点我向父组件传递数据</button>
</>
);
}
function App() {
// 子组件的数据
const [msg, setMsg] = useState("");
// 用于获取子组件传递数据的函数
const getMsg = (val) => {
// 4.存储子组件传递的数据并驱动视图更新
setMsg(val);
};
return (
<>
{/* 1.将父组件中的方法传递给子组件 */}
{/* 注意:如果传递的为函数以 on开头+函数名 */}
<Son onGetSonMsg={getMsg}>this is App</Son>
<div>子组件传递的数据:{msg}</div>
</>
);
}Tips:使用
props向子组件传递函数的命名规范 **on开头+函数名 **大驼峰写法
3. 兄弟组件通信

实现思路: 借助 状态提升 机制,通过共同的父组件进行兄弟之间的数据传递
- A组件先通过子传父的方式把数据传递给父组件App
- App拿到数据之后通过父传子的方式再传递给B组件
// 兄弟组件通信 A->B
// 1.通过子传父 A->App
// 2.通过父传子 App->B
function A({ onSetName }) {
const name = "this is A name";
return (
<>
<div>this is A component</div>
<button onClick={() => onSetName(name)}>send name to B</button>
</>
);
}
function B({ name }) {
return (
<>
<div>this is B component</div>
<div>A组件传递的数据: {name}</div>
</>
);
}
function App() {
// 使用状态变量管理数据,当数据发生变化时会重新渲染模板
const [name, setName] = useState("");
// 获取A组件传递的数据
const getName = (val) => {
setName(val);
};
return (
<>
this is App component
<A onSetName={getName}></A>
<B name={name}></B>
</>
);
}4. 跨层组件通信
**实现步骤:** - 使用
createContext方法创建一个上下文对象Ctx - 在顶层组件(App)中通过
Ctx.Provider组件提供数据 - 在底层组件(B)中通过
useContext钩子函数获取消费数据
// 爷孙组件通信 App -> A -> B
// 1.使用createContext方法创建一个上下文对象
const MsgContext = createContext();
function App() {
const msg = "this is app msg";
return (
<>
{/* 2.在提供数据组件 通过Provider组件提供数据 */}
<MsgContext.Provider value={msg}>
<div>this is App component</div>
<A />
</MsgContext.Provider>
</>
);
}
function A() {
return (
<>
<div>this is A component</div>
<B />
</>
);
}
function B() {
// 3.在接收数据组件 通过useContext钩子函数使用数据
const msg = useContext(MsgContext);
return (
<>
<div>this is B component,{msg}</div>
</>
);
}十二、useEffect(模拟生命周期)
1. 概念理解
useEffect是一个React Hook函数,用于在React组件中创建不是由事件引起而是由渲染本身引起的操作(副作用),比如发送AJAX请求,更改DOM等等 ,代替生命周期和数据监视的行为
函数组件:本身没有生命周期方法,但可以通过useEffectHook来模拟生命周期行为和通过传入特定的依赖项模拟数据监视行为

注意:
上面的组件中没有发生任何的用户事件,组件渲染完毕之后就需要获取服务器数据,整个过程属于“只由渲染引起的操作”
2. 基础使用
需求:在组件渲染完毕之后,立刻从服务端获取频道列表数据并显示到页面中
说明:
- 参数1是一个函数,可以把它叫做副作用函数,在函数内部可以放置要执行的操作
- 参数2是一个数组(可选参),在数组里放置依赖项,不同依赖项会影响第一个参数函数的执行,当是一个空数组的时候,副作用函数只会在组件挂载到页面后执行一次
接口地址:http://geek.itheima.net/v1_0/channels
// 需求:在组件渲染完毕后获取频道数据并渲染
const URL = "http://geek.itheima.net/v1_0/channels";
function App() {
// 频道列表
const [list, setList] = useState([]);
useEffect(() => {
// 额外的操作 获取频道列表
async function getList() {
const res = await fetch(URL);
const jsonRes = await res.json();
// 存储频道列表
setList(jsonRes.data.channels);
}
getList();
}, []);
return (
<>
<div>this is App component</div>
{/* 渲染频道列表 */}
<ul>
{list.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}注意:
如果在
main.jsx中使用了<StrictMode>标签,意为开启了严格模式可能会造成一些钩子函数执行两次(包括useEffect钩子函数)
3. useEffect依赖项参数
useEffect副作用函数的执行时机存在多种情况,根据传入**依赖项(uesEffect函数第二个参数)**的不同,会有不同的执行表现
| 依赖项 | 副作用功函数的执行时机 |
|---|---|
| 没有依赖项 | 组件初始渲染 + 组件更新时执行(更新) |
| 空数组依赖 | 只在初始渲染时执行一次(渲染完毕) |
| 添加特定依赖项 | 组件初始渲染 + 依赖项变化时执行(监视) |
function APP() {
// 状态数据
let [number, setNumber] = useState(1);
// 1.没有依赖项 副作用函数执行时机:初始化 + 组件/状态更新
useEffect(() => {
console.log("副作用函数执行了");
});
// 2.依赖项为空数组 副作用函数执行时机:初始化
useEffect(() => {
console.log("副作用函数执行了");
}, []);
// 3.传入特定的依赖项 副作用函数执行时机: 初始化 + 依赖项变化时执行
useEffect(() => {
console.log("副作用函数执行了");
}, [number]);
return (
<>
<div>number:{number}</div>
<button onClick={() => setNumber(number + 1)}>点击number加一</button>
</>
);
}Tips:通过传入不同的依赖性项来控制副作用函数的执行时机,达到模拟生命周期函数和数据监视的效果
4. 清除副作用(组件卸载时执行)
概念:在useEffect中编写的由渲染本身引起的对接组件外部的操作,社区也经常把它叫做副作用操作,比如在useEffect中开启了一个定时器,我们想在组件卸载时把这个定时器再清理掉,这个过程就是清理副作用
在副作用函数内会返回一个在组件卸载前执行的清理函数(模拟组件卸载时的生命周期函数)

Tips:清除副作用的函数最常见的执行时机是在组件卸载时自动执行
function Son() {
useEffect(() => {
// 组件挂载时开启定时器
console.log("Son组件被挂载了");
let timer = setInterval(() => {
console.log("Son组件定时器执行了");
}, 1000);
// 组件卸载时关闭定时器
return () => {
clearInterval(timer);
console.log("Son组件被卸载了");
};
}, []);
return <div>this is son</div>;
}
function App() {
// 通过条件渲染模拟组件卸载
const [show, setShow] = useState(true);
return (
<>
{show && <Son />}
{/* 控制子组件的挂载与卸载 */}
<button onClick={() => setShow(!show)}>
{show ? "卸载" : "挂载"}Son组件
</button>
</>
);
}十三、自定义Hook实现
概念:自定义Hook是以 use打头的函数,通过自定义Hook函数可以用来实现逻辑的封装和复用

// 封装自定义Hook
// 问题: 布尔切换的逻辑 和当前组件耦合在一起 不方便复用
// 解决思路: 自定义Hook
// 自定义Hook命名以use开头
function useToggle() {
// 可复用的逻辑代码
const [show, setShow] = useState(true);
const toggle = () => {
setShow(!show);
};
// 将需要在其他组件中使用的状态和回调函数return
return {
show,
toggle,
};
}
function A() {
// 在A组件中复用切换逻辑
// 获取Hook中返回的状态和方法
const { show, toggle } = useToggle();
return (
<>
{show && <div>this is A</div>}
<button onClick={toggle}>toggle</button>
</>
);
}
function App() {
const { show, toggle } = useToggle();
return (
<>
{show && <div>this is App</div>}
<button onClick={toggle}>toggle</button>
<A />
</>
);
}Tips:可以将在多个组件中可复用的逻辑封装成一个 Hook 在不同的组件中复用
十四、React Hooks使用规则
- 只能在组件中或者其他自定义Hook函数中调用
- 只能在组件的顶层调用,不能嵌套在if、for、其它的函数中

十五、案例-优化B站评论案例

需求:
- 使用请求接口的方式获取评论列表并渲染
- 使用自定义Hook函数封装数据请求的逻辑
- 把评论中的每一项抽象成一个独立的组件实现渲染
1. 通过接口获取评论列表
- 使用 json-server 工具模拟接口服务,通过 axios 发送请求
安装:
pnpm i json-server -D初始化数据:在
src同级目录创建db.json并添加数据配置命令:
package.jsonjson"scripts": { ....... "serve": "json-server --watch db.json --port 3001" // 服务运行在 3001端口 },
Tips:
json-server遵循REST API风格
- 使用
useEffect在组件渲染完毕后调用接口获取数据
const App = () => {
+ // 评论列表
+ const [commentList, setCommentList] = useState([]);
+ // 组件渲染完毕获取评论列表数据
+ useEffect(() => {
+ // 发请求获取评论列表数据
+ const getList = async () => {
+ const result = await axios.get("http://localhost:3001/list");
+ setCommentList(result.data); // 更新state数据
+ };
+ getList();
+ }, []); // 副作用函数只在组件渲染完毕执行一次
......
};Tips:副作用函数规范写法:将逻辑写在一个函数中,并调用该函数
2. 自定义Hook函数封装数据请求
- 定义一个
use打头的函数 - 函数内部编写封装的逻辑
retrun出去组件中使用的状态和方法- 在组件中调用函数解构赋值使用
// 自定义 Hook(获取评论列表数据)
function useGetList() {
// 评论列表
const [commentList, setCommentList] = useState([]);
// 组件渲染完毕获取评论列表数据
useEffect(() => {
// 发请求获取评论列表数据
const getList = async () => {
const result = await axios.get("http://localhost:3001/list");
setCommentList(result.data);
};
getList();
}, []);
return {
commentList,
setCommentList,
};
}
const App = () => {
// 获取Hook中的状态和方法
const { commentList, setCommentList } = useGetList();
....
}3. 封装评论项 Item 组件
将评论每一项封装为一个组件,并通过 props 的方式获取父组件的数据和方法
import { useEffect, useRef, useState } from "react";
import "./App.scss";
import avatar from "./images/bozai.png";
// 导入lodash
import _ from "lodash";
// 导入 classNames
import classNames from "classnames";
// 导入 uuid 用于生成唯一的id
import { v4 as uuidv4 } from "uuid";
// 导入 dayjs 用于格式化日期
import dayjs from "dayjs";
// 导入 axios
import axios from "axios";
// 当前登录用户信息
const user = {
// 用户id
uid: "30009257",
// 用户头像
avatar,
// 用户昵称
uname: "黑马前端",
};
// 导航 Tab 数组
const tabs = [
{ type: "hot", text: "最热" },
{ type: "time", text: "最新" },
];
// 自定义 Hook(获取评论列表数据)
function useGetList() {
// 评论列表
const [commentList, setCommentList] = useState([]);
// 组件渲染完毕获取评论列表数据
useEffect(() => {
// 发请求获取评论列表数据
const getList = async () => {
const result = await axios.get("http://localhost:3001/list");
setCommentList(_.orderBy(result.data, "like", "desc"));
};
getList();
}, []);
return {
commentList,
setCommentList,
};
}
// 封装 Item 组件
+function Item({ item, onDeleteComm }) {
+ return (
+ <>
<div className="reply-item">
{/* 头像 */}
<div className="root-reply-avatar">
<div className="bili-avatar">
<img src={item.user.avatar} className="bili-avatar-img" alt="" />
</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-like">点赞数:{item.like}</span>
{/* 条件 user.uid === item.user.uid */}
{user.uid === item.user.uid && (
<span
+ onClick={() => onDeleteComm(item.rpid)}
className="delete-btn"
>
删除
</span>
)}
</div>
</div>
</div>
</div>
</>
);
}
const App = () => {
// 获取Hook中的状态
const { commentList, setCommentList } = useGetList();
// 当前激活的tab的type
const [activeTabType, setActiveTabType] = useState(tabs[0].type);
// 评论内容
const [content, setContent] = useState("");
// 评论框的ref
const textAreaRef = useRef(null);
// 删除评论按钮的事件回调
const onDeleteComm = (rId) => {
// 更新commentList中的数据 filter方法返回一个新数组
setCommentList(commentList.filter((item) => item.rpid !== rId));
};
// 点击切换tab的事件回调
const onChangeTab = (type) => {
// 更新当前激活tab的type值
setActiveTabType(type);
// 对评论列表数据进行排序 lodash
if (type === "hot") {
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["like"], ["desc"]));
} else {
// 使用 lodash提供的orderBy方法进行排序
setCommentList(_.orderBy(commentList, ["ctime"], ["desc"]));
}
};
// 点击发表评论按钮的事件回调
const onPublishComment = () => {
// 将 textarea中的内容添加到评论列表中
setCommentList([
...commentList,
{
rpid: uuidv4(), // 使用uuid第三方库
user,
content: content,
ctime: dayjs(new Date()).format("YY-MM-DD hh:mm"), // 使用 dayjs格式化日期
like: 0,
},
]);
// 清空输入框中的数据
setContent("");
// 获取焦点
textAreaRef.current.focus();
};
return (
<div className="app">
{/* 导航 Tab */}
<div className="reply-navigation">
<ul className="nav-bar">
<li className="nav-title">
<span className="nav-title-text">评论</span>
{/* 评论数量 */}
<span className="total-reply">{10}</span>
</li>
<li className="nav-sort">
{/* 遍历生成tab */}
{/* 高亮类名: active */}
{tabs.map((item) => (
<span
className={classNames("nav-item", {
active: activeTabType === item.type,
})}
key={item.type}
onClick={() => onChangeTab(item.type)}
>
{item.text}
</span>
))}
</li>
</ul>
</div>
<div className="reply-wrap">
{/* 发表评论 */}
<div className="box-normal">
{/* 当前用户头像 */}
<div className="reply-box-avatar">
<div className="bili-avatar">
<img className="bili-avatar-img" src={avatar} alt="用户头像" />
</div>
</div>
<div className="reply-box-wrap">
{/* 评论框 */}
<textarea
className="reply-box-textarea"
placeholder="发一条友善的评论"
value={content}
onChange={(e) => setContent(e.target.value)}
ref={textAreaRef}
/>
{/* 发布按钮 */}
<div className="reply-box-send">
<div className="send-text" onClick={onPublishComment}>
发布
</div>
</div>
</div>
</div>
{/* 评论列表 */}
<div className="reply-list">
{/* 评论项 */}
+ {commentList.map((item) => (
+ <Item item={item} key={item.rpid} onDeleteComm={onDeleteComm} />
+ ))}
</div>
</div>
</div>
);
};Redux
一、Redux介绍
Redux 是React最常用的集中状态管理工具,类似于Vue中的Pinia(Vuex),可以独立于框架运行
作用:通过集中管理的方式管理应用的状态
**为什么要使用Redux?** - 独立于组件,无视组件之间的层级关系,简化通信问题
- 单向数据流清晰,易于定位bug
- 调试工具配套良好,方便调试
二、Redux快速体验
1. 实现计数器
需求:不和任何框架绑定,不使用任何构建工具,使用纯Redux实现计数器
使用步骤: - 定义一个 reducer 函数 (根据当前想要做的修改返回一个新的状态)
- 使用createStore方法传入 reducer函数 生成一个store实例对象
- 使用store实例的 subscribe方法 订阅数据的变化(数据一旦变化,可以得到通知)
- 使用store实例的 dispatch方法提交action对象 触发数据变化(告诉reducer你想怎么改数据)
- 使用store实例的 getState方法 获取最新的状态数据更新到视图中
代码实现:
<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>
// 定义reducer函数
// 内部主要的工作是根据不同的action 返回不同的state
function counterReducer (state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
default:
return state
}
}
// 使用reducer函数生成store实例
const store = Redux.createStore(counterReducer)
// 订阅数据变化
store.subscribe(() => {
console.log(store.getState())
document.getElementById('count').innerText = store.getState().count
})
// 增
const inBtn = document.getElementById('increment')
inBtn.addEventListener('click', () => {
store.dispatch({
type: 'INCREMENT'
})
})
// 减
const dBtn = document.getElementById('decrement')
dBtn.addEventListener('click', () => {
store.dispatch({
type: 'DECREMENT'
})
})
</script>2. Redux数据流架构
Redux的难点是理解它对于数据修改的规则, 下图动态展示了在整个数据的修改中,数据的流向
为了职责清晰,`Redux`代码被分为三个核心的概念,我们学`redux`,其实就是学这三个核心概念之间的配合,三个概念分别是: state: 一个对象 存放着我们管理的数据action: 一个对象 用来描述你想怎么改数据reducer: 一个函数 根据action的描述更新state
三、Redux与React - 环境准备
Redux 虽然是一个框架无关可以独立运行的插件,但是社区通常还是把它与 React 绑定在一起使用,以一个计数器案例体验一下Redux + React 的基础使用
1. 配套工具
在React中使用 redux,官方要求安装两个其他插件: Redux Toolkit 和 react-redux
Redux Toolkit(RTK)-:官方推荐编写Redux逻辑的方式,是一套工具的集合集,简化书写方式- 简化
store的配置方式 - 内置
immer支持可变式状态修改 - 内置
thunk更好的异步创建
- 简化
react-redux: 用来 **连接Redux和React**组件的中间件

2. 配置基础环境
- 使用
CRA(Create React App) 或Vite快速创建 React 项目
npx create-react-app react-redux-pro (CRA)
pnpm create vite react-redux-pro --template react (Vite)- 安装配套工具
npm i @reduxjs/toolkit react-redux (CRA)
pnpm i @reduxjs/toolkit react-redux (Vite)- 启动项目
npm run start (CRA)
pnpm run dev (Vite)3. store目录结构设计

通常集中状态管理的部分都会单独创建一个单独的
store目录应用通常会有很多个子
store模块,所以创建一个modules目录,在内部编写业务分类的子storestore中的入口文件index.js的作用是组合modules中所有的子模块,并导出store
四、Redux与React - 实现counter
1. 整体路径熟悉

2. 使用React Toolkit 创建 counterStore
store/modules/counterStore.js
import { createSlice } from "@reduxjs/toolkit";
// 创建一个计算相关的子模块(小仓库)
const counterStore = createSlice({
// 模块名(唯一)
name: "counter",
// 初始化状态
initialState: {
count: 0,
},
// 修改状态数据的方法(同步方法) 直接更新(修改)数据
reducers: {
// 增 state: 状态数据
increment(state) {
state.count++; // 直接更新(修改)数据
},
// 减
decrement(state) {
state.count--;
},
},
});
// 解构出来actionCreater函数(即用于修改state中数据的reducer函数)
const { increment, decrement } = counterStore.actions;
// 获取reducer
const reducer = counterStore.reducer;
// 以按需导出的方式导出 actionCreater函数
export { increment, decrement };
// 以默认导出的方式导出 reducer
export default reducer;store/index.js
import { configureStore } from "@reduxjs/toolkit";
// 导入子模块reducer
import countReducer from "./modules/counterStore";
const store = configureStore({
reducer: {
// 注册子模块(小仓库)
counter: countReducer,
},
});
// 将store(大仓库)暴露出去
export default store;3. 为React注入store
react-redux负责把Redux和React 连接起来,内置 Provider组件通过 store 参数把创建好的store实例注入到应用中,链接正式建立
src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
// 导入根store(大仓库)
import store from "./store/index.js";
// 导入 react-redux 提供的provider组件对仓库进行注入
import { Provider } from "react-redux";
createRoot(document.getElementById("root")).render(
// 开启严格模式
<StrictMode>
{/* 将仓库注入到应用中 */}
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);Tips:
<Provider store={store}>将仓库注入到应用中
4. React组件使用store中的数据
在React组件中使用store中的数据,需要用到一个钩子函数 useSelector,它的作用是把store中的数据映射到组件中,使用样例如下:
获取仓库中的 counter 模块中的 count 数据

5. React组件修改store中的数据
React组件中修改store中的数据需要借助另外一个hook函数:useDispatch,它的作用是生成提交action对象的dispatch函数,使用样例如下:
import { useDispatch, useSelector } from "react-redux";
import { decrement, increment } from "./store/modules/counterStore";
function App() {
// 获取store(大仓库)中的counter模块中的数据
const { count } = useSelector((state) => state.counter);
// 获取dispatch方法用于修改store中的数据
const dispatch = useDispatch();
return (
<>
{/* 调用dispatch提交action对象修改数据 */}
<button onClick={() => dispatch(increment())}>+</button>
<span>{count}</span>
<button onClick={() => dispatch(decrement())}>-</button>
</>
);
}
组件中使用哪个
hook函数获取store中的数据?
useSelector组件中使用哪个
hook函数获取dispatch方法?
useDispatch如何得到要提交
action对象?执行
store模块中导出的actionCreater方法
五、Redux与React - 提交action传参
需求:组件中有俩个按钮 add to 10 和 add to 20 可以直接把count值修改到对应的数字,目标count值是在组件中传递过去的,需要在提交action的时候传递参数
实现方式:在`reducers`的同步修改方法中添加`action`对象参数,在调用`actionCreater`的时候传递参数,参数会被传递到`action`对象`payload`属性上 
六、Redux与React - 异步action处理
需求理解
在仓库中发请求获取数据并进行存储
实现步骤
创建store的写法保持不变,配置好同步修改状态的方法
单独封装一个函数,在函数内部
return一个新函数,在新函数中- 封装异步请求获取数据
- 调用同步
actionCreater传入异步数据生成一个action对象,并使用dispatch提交
组件中dispatch的写法保持不变
代码实现
src\store\modules\channelStore.js
import { createSlice } from "@reduxjs/toolkit";
import axios from "axios";
// 创建频道的store模块
const channelStore = createSlice({
// store名称
name: "channel",
// 初始状态数据
initialState: {
channelList: [],
},
// 同步修改state的方法
reducers: {
// state:状态数据 action:dispatch提交action传递的参数
setChannelList(state, action) {
// 修改state中的数据
state.channelList = action.payload;
}
},
});
// 解构出actionCreater函数(reducers中的函数同步修改state中的数据)
const { setChannelList } = channelStore.actions;
// 异步修改state的方法(封装一个函数,在函数内部return一个新的异步函数)
const getChannelList = () => {
// return 的函数有一个固定的形参 dispatch可以调用同步actions函数
return async (dispatch) => {
// 封装异步请求获取数据
const result = await axios.get("http://geek.itheima.net/v1_0/channels");
// 同步修改数据
dispatch(setChannelList(result.data.data.channels));
};
};
// 导出异步修改state的方法
export { getChannelList };
// 导出仓库的reducer
const reducer = channelStore.reducer;
export default reducer;src\store\index.js
import { configureStore } from "@reduxjs/toolkit";
// 导入子模块reducer
import countReducer from "./modules/counterStore";
+import channelReducer from "./modules/channelStore";
const store = configureStore({
reducer: {
// 注册子模块(小仓库)
counter: countReducer,
+ channel: channelReducer,
},
});
// 将store(大仓库)暴露出去
export default store;src\App.jsx
import { useDispatch, useSelector } from "react-redux";
import { getChannelList } from "./store/modules/channelStore";
import { useEffect } from "react";
function App() {
+ // 获取store(大仓库)中的channel模块中的数据
+ const { channelList } = useSelector(state => state.channel);
+ // 获取dispatch方法用于修改store中的数据
+ const dispatch = useDispatch();
// 组件加载完毕时频道列表获取数据
useEffect(() => {
+ dispatch(getChannelList());
}, []);
return (
<>
{/* 展示频道列表 */}
<ul>
{channelList.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}
export default App;七、Redux调试 - devtools
Redux官方提供了针对于Redux的调试工具,支持实时state信息展示,action提交信息查看等

八、美团小案例
1. 案例演示

基本开发思路:使用 RTK(Redux Toolkit)来管理应用状态,组件负责 数据渲染 和 dispatch action
2. 准备并熟悉环境
- 克隆项目到本地(内置了基础静态组件和模版)
git clone http://git.itcast.cn/heimaqianduan/redux-meituan.git- 安装所有依赖
npm i- 启动mock服务(内置了json-server)
npm run serve- 启动前端服务
npm run start3. 分类和商品列表渲染
实现步骤:
- 启动项目(mock服务 + 前端服务)
- 使用 RTK 编写
store(使用异步action获取数据) - 组件触发
action并渲染数据
创建商品数据仓库
redux-meituan\src\store\modules\takeaway.js
import { createSlice } from "@reduxjs/toolkit";
import axios from "axios";
// 商品仓库
const foodsStore = createSlice({
// 仓库名称(唯一)
name: "foods",
// 初始商品列表数据
initialState: {
foodsList: [],
},
// 同步修改状态的reducer方法
reducers: {
setFoodsList(state, action) {
// action.payload: 调用函数传递的数据
state.foodsList = action.payload;
},
},
});
// 解构出同步修改状态的方法
const { setFoodsList } = foodsStore.actions;
// 异步获取数据并修改状态方法
const getFoodsList = () => {
// 返回一个异步函数,函数接收默认参数dispatch用于调用同步修改状态的方法
return async (dispatch) => {
// 发请求获取数据
const res = await axios.get("http://localhost:3004/takeaway");
// 使用dispatch调用同步方法修改状态
dispatch(setFoodsList(res.data));
};
};
// 将异步修改仓库状态的方法进行暴露
export { getFoodsList };
// 将商品仓库暴露出去
const reducer = foodsStore.reducer;
export default reducer;创建大仓库并暴露
redux-meituan\src\store\index.js
import { configureStore } from "@reduxjs/toolkit";
// 导入商品列表仓库reducer
import foodsReducer from "./modules/takeaway";
// 创建大仓库并暴露
const store = configureStore({
reducer: {
foods: foodsReducer,
},
});
// 将大仓库进行暴露
export default store;在组件中调用仓库中到的数据并获取仓库中的数据渲染
redux-meituan\src\App.jsx
const App = () => {
+ // 组件渲染完毕后调用store中异步方法更新商品列表数据
+ const dispatch = useDispatch();
+ useEffect(() => {
+ // 使用dispatch调用异步方法更新商品列表数据
+ dispatch(getFoodsList());
+ }, []);
+ // 获取商品仓库中的商品列表数据
+ const { foodsList } = useSelector((state) => state.foods);
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) => (
+ <FoodsCategory
+ key={item.tag}
+ // 列表标题
+ name={item.name}
+ // 列表商品
+ foods={item.foods}
+ />
);
)}
</div>
</div>
</div>
</div>
{/* 购物车 */}
<Cart />
</div>
);
};4. 点击分类激活交互实现
编写store逻辑 `redux-meituan\src\store\modules\takeaway.js` import { createSlice } from "@reduxjs/toolkit";
import axios from "axios";
// 商品仓库
const foodsStore = createSlice({
// 仓库名称(唯一)
name: "foods",
// 初始化数据
initialState: {
// 商品列表数据
foodsList: [],
+ // 当前菜单激活的下标
+ activeIndex: 0,
},
// 同步修改状态的reducer方法
reducers: {
// 修改商品列表数据
setFoodsList(state, action) {
// action.payload: 调用函数传递的数据
state.foodsList = action.payload;
},
+ // 修改当前激活的菜单下标
+ setActiveIndex(state, action) {
+ state.activeIndex = action.payload;
},
},
});
// 解构出同步修改状态的方法
+const { setFoodsList, setActiveIndex } = foodsStore.actions;
// 异步获取数据并修改状态方法
const getFoodsList = () => {
// 返回一个异步函数,函数接收默认参数dispatch用于调用同步修改状态的方法
return async (dispatch) => {
// 发请求获取数据
const res = await axios.get("http://localhost:3004/takeaway");
// 使用dispatch调用同步方法修改状态
dispatch(setFoodsList(res.data));
};
};
// 将修改仓库状态的方法进行暴露
+export { getFoodsList, setActiveIndex };
// 将商品仓库暴露出去
const reducer = foodsStore.reducer;
export default reducer;编写组件逻辑 redux-meituan\src\components\Menu\index.jsx
import classNames from "classnames";
import "./index.scss";
import { useDispatch, useSelector } from "react-redux";
import { setActiveIndex } from "../../store/modules/takeaway";
// 菜单组件
const Menu = (props) => {
// 整理菜单数据
const menus = props.foodsList.map((item) => ({
tag: item.tag,
name: item.name,
}));
+ // 从仓库中获取当前激活菜单下标
+ const { activeIndex } = useSelector((state) => state.foods);
+ // 获取dispatch用于调用action方法
+ const dispatch = useDispatch();
return (
<nav className="list-menu">
{/* 添加active类名会变成激活状态 */}
{menus.map((item, index) => {
return (
<div
key={item.tag}
+ className={classNames("list-menu-item", {
+ active: activeIndex === index,
+ })}
+ onClick={() => dispatch(setActiveIndex(index))}
>
{item.name}
</div>
);
})}
</nav>
);
};
export default Menu;5. 商品列表切换显示

遍历生成商品列表,根据当前激活的菜单下标,展示对应的商品列表
.........
const App = () => {
// 组件渲染完毕后调用store中异步方法更新商品列表数据
const dispatch = useDispatch();
useEffect(() => {
// 使用dispatch调用异步方法更新商品列表数据
dispatch(getFoodsList());
}, []);
// 获取商品仓库中的商品列表数据和当前激活的菜单激活的下标
+ const { foodsList, activeIndex } = useSelector((state) => state.foods);
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, index) => {
return (
+ // 展示当前激活项商品列表
+ activeIndex === index && (
<FoodsCategory
key={item.tag}
// 列表标题
name={item.name}
// 列表商品
foods={item.foods}
/>
)
);
})}
</div>
</div>
</div>
</div>
{/* 购物车 */}
<Cart />
</div>
);
};
export default App;6. 添加购物车实现
编写store逻辑 redux-meituan\src\store\modules\takeaway.js
....
// 商品仓库
const foodsStore = createSlice({
// 仓库名称(唯一)
name: "foods",
// 初始化数据
initialState: {
// 商品列表数据
foodsList: [],
// 当前菜单激活的下标
activeIndex: 0,
// 购物车列表
+ cartList: [],
},
// 同步修改状态的reducer方法
reducers: {
....
+ // 添加到购物车
+ addCart(state, action) {
+ // 判断购物车中是否已经有当前商品
+ // find()方法返回数组中满足提供的测试函数的第一个元素的值,否则返回 undefined
+ const item = state.cartList.find((item) => item.id === action.payload.id);
+ // 有当前商品数量加1,没有当前商品添加到购物车
+ item
+ ? item.count++
+ : state.cartList.push({ ...action.payload, count: 1 });
+ },
+ },
});
// 解构出同步修改状态的方法
+ const { setFoodsList, setActiveIndex, addCart } = foodsStore.actions;
.....
// 将修改仓库状态的方法进行暴露
+ export { getFoodsList, setActiveIndex, addCart };
// 将商品仓库暴露出去
const reducer = foodsStore.reducer;
export default reducer;编写组件逻辑 redux-meituan\src\components\Count\index.jsx
import { useDispatch } from "react-redux";
import { addCart } from "../../store/modules/takeaway";
const Count = ({ food }) => {
+ const dispatch = useDispatch();
return (
<div className="goods-count">
<span className="minus">-</span>
<span className="count">{food.count}</span>
{/* 触发仓库中的修改购物车列表的方法并传递当前商品信息 */}
+ <span className="plus" onClick={() => dispatch(addCart(food))}>
+
</span>
</div>
);
};
export default Count;7. 统计区域实现

实现思路
- 使用
useEffect监视store中的cartList属性,当其发生变化时重新计算,当前购物车中商品总数量和总金额 - 根据商品总数量是否为0动态展示底部购物车状态栏高亮颜色
// 获取仓库中购物车列表的数据
const { cartList } = useSelector((state) => state.foods);
// 购物车中商品数量
const [count, setCount] = useState(0);
// 购物车中商品总价
const [totalPrice, setTotalPrice] = useState(0.0);
// 当购物车中商品发生变化时计算当前购物车中商品数量和总价
useEffect(() => {
setCount(cartList.reduce((pre, cur) => pre + cur.count, 0));
setTotalPrice(
cartList.reduce((pre, cur) => pre + cur.price * cur.count, 0.0)
);
}, [cartList]);8. 购物车列表功能实现

实现步骤:
- 使用
cartList遍历渲染列表 - 在
foods仓库的reducers中增加减少和清空购物车的方法
控制列表渲染 redux-meituan\src\components\Cart\index.jsx
.....
const Cart = () => {
const dispatch = useDispatch();
// 获取仓库中购物车列表的数据
const { cartList } = useSelector((state) => state.foods);
// 购物车中商品数量
const [count, setCount] = useState(0);
// 购物车中商品总价
const [totalPrice, setTotalPrice] = useState(0.0);
// 当购物车中商品发生变化时计算当前购物车中商品数量和总价
useEffect(() => {
setCount(cartList.reduce((pre, cur) => pre + cur.count, 0));
setTotalPrice(
cartList.reduce((pre, cur) => pre + cur.price * cur.count, 0.0)
);
}, [cartList]);
// 控制购物车面板的显示和隐藏
const [showPanel, setShowPanel] = useState(false);
return (
<div className="cartContainer">
{/* 遮罩层 添加visible类名可以显示出来 */}
<div
className={classNames("cartOverlay", { visible: showPanel })}
onClick={() => setShowPanel(false)}
/>
<div className="cart" onClick={() => setShowPanel(true)}>
{/* fill 添加fill类名可以切换购物车状态*/}
{/* 购物车数量 */}
<div className={classNames("icon", { fill: count })}>
{count > 0 && <div className="cartCornerMark">{count}</div>}
</div>
{/* 购物车价格 */}
<div className="main">
<div className="price">
<span className="payableAmount">
<span className="payableAmountUnit">¥</span>
{totalPrice.toFixed(2)}
</span>
</div>
<span className="text">预估另需配送费 ¥5</span>
</div>
{/* 结算 or 起送 */}
{count ? (
<div className="goToPreview">去结算</div>
) : (
<div className="minFee">¥20起送</div>
)}
</div>
{/* 添加visible类名 div会显示出来 */}
<div className={classNames("cartPanel", { visible: showPanel })}>
<div className="header">
<span className="text">购物车</span>
<span className="clearCart" onClick={() => dispatch(clearCart())}>
清空购物车
</span>
</div>
{/* 购物车列表 */}
<div className="scrollArea">
{cartList.map((item) => {
return (
<div className="cartItem" key={item.id}>
<img className="shopPic" src={item.picture} alt="" />
<div className="main">
<div className="skuInfo">
<div className="name">{item.name}</div>
</div>
<div className="payableAmount">
<span className="yuan">¥</span>
<span className="price">{item.price}</span>
</div>
</div>
<div className="skuBtnWrapper btnGroup">
<Count food={item} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
export default Cart;购物车增减逻辑实现 redux-meituan\src\store\modules\takeaway.js
// 添加到购物车
addCart(state, action) {
// 判断购物车中是否已经有当前商品
// find()方法返回数组中满足提供的测试函数的第一个元素的值,否则返回 undefined
const item = state.cartList.find((item) => item.id === action.payload.id);
// 有当前商品数量加1,没有当前商品添加到购物车
item
? item.count++
: state.cartList.push({ ...action.payload, count: 1 });
},
// 从购物车中移除商品
removeCart(state, action) {
// 获取当前当前商品
const item = state.cartList.find((item) => item.id === action.payload.id);
// 商品数量减1,如果商品数量为0,则从购物车列表中移除
item.count--;
if (item.count === 0) {
state.cartList = state.cartList.filter(
(item) => item.id !== action.payload.id
);
}
},
// 清空购物车中的商品
clearCart(state) {
state.cartList = [];
},
},计数器组件 redux-meituan\src\components\Count\index.jsx
import { useDispatch } from "react-redux";
import "./index.scss";
import { addCart, removeCart } from "../../store/modules/takeaway";
const Count = ({ food }) => {
const dispatch = useDispatch();
return (
<div className="goods-count">
<span className="minus" onClick={() => dispatch(removeCart(food))}>
-
</span>
<span className="count">{food.count || 0}</span>
{/* 触发仓库中的修改购物车列表的方法并传递当前商品信息 */}
<span className="plus" onClick={() => dispatch(addCart(food))}>
+
</span>
</div>
);
};
export default Count;注意:
调用仓库中的
action方法必须使用dispatch()
9. 控制购物车显示和隐藏

+// 控制购物车面板的显示和隐藏
+ const [showPanel, setShowPanel] = useState(false);
return (
<div className="cartContainer">
{/* 遮罩层 添加visible类名可以显示出来 */}
<div
+ className={classNames("cartOverlay", { visible: showPanel })}
+ onClick={() => setShowPanel(false)}
/>
{/* 底部购物车导航栏 */}
+ <div className="cart"
+ onClick={() => cartList.length > 0 && setShowPanel(true)}>
.....
</div>
{/* 添加visible类名 显示购物车面板 */}
+ <div className={classNames("cartPanel", { visible: showPanel })}>
.....
</div>
</div>
);ReactRouter
一、路由快速上手
1. 什么是前端路由
一个路径 path 对应一个组件 component 当我们在浏览器中访问一个 path 的时候,path 对应的组件会在页面中进行渲染 
2. 创建路由开发环境
# 使用CRA创建项目
npx create-react-app react-router-pro
#使用vite创建项目
pnpm create vite react-router-pro --template react
# 安装依赖
npm i
pnpm i
# 安装最新的ReactRouter包
npm i react-router-dom
pnpm i react-router-dom
# 启动项目
npm run start
pnpm run dev3. 快速开始
需求:创建一个可以切换登录页和文章页的路由系统

import { createBrowserRouter } from "react-router-dom";
import { RouterProvider } from "react-router-dom";
// 1.创建router实例对象,配置路由规则
const router = createBrowserRouter([
{
path: "/login",
// element:可以写jsx代码或组件标签
element: <div>我是登录页</div>,
},
{
path: "/article",
element: <div>我是文章页</div>,
},
]);
createRoot(document.getElementById("root")).render(
<StrictMode>
{/* 2.路由绑定,将路由规则应用到应用中 */}
<RouterProvider router={router}></RouterProvider>
</StrictMode>
);Tisp:
elemet可以为组件标签,也可以是jsx代码
RouterProvider组件:路由绑定,将路由规则应用到应用中
二、抽象路由模块

- 将路由组件放到
pages或views中- 将路由配置抽离到
router/index.jsx中- 在
mian.jsx/App.jsx中渲染RouterProvider组件并注入router实例
配置路由规则:react-router-pro\src\router\index.jsx
import Login from "../pages/Login";
import Article from "../pages/Article";
import { createBrowserRouter } from "react-router-dom";
// 配置路由规则
const router = createBrowserRouter([
// 一级路由
{
path: "/login",
element: <Login />,
},
{
path: "/article",
element: <Article />,
},
]);
export default router;绑定路由:react-router-pro\src\main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
+// 导入路由规则router
+import router from "./router/index";
createRoot(document.getElementById("root")).render(
<StrictMode>
{/* 路由绑定,将路由应用到应用中 */}
+ <RouterProvider router={router}></RouterProvider>
</StrictMode>
);三、路由导航
1. 什么是路由导航
路由系统中的多个路由之间需要进行路由跳转,并且在跳转的同时有可能需要传递参数进行通信 
2. 声明式导航
声明式导航是指通过在模版中通过 <Link/> 组件描述出要跳转到哪里去,比如后台管理系统的左侧菜单通常使用这种方式进行
import { Link } from "react-router-dom";
function Login() {
return (
<>
<div>Login组件</div>
{/* 声明式路由导航 */}
<Link to="/article">跳转到文章页</Link>
</>
);
}
export default Login;语法说明:通过给组件的
to属性指定要跳转到路由path,组件会被渲染为浏览器支持的a链接,如果需要传参直接通过字符串拼接的方式拼接参数即可tips:
<Link to="/xx" replace>替换历史路由
3. 编程式导航
编程式导航是指通过 useNavigate 钩子得到导航方法,然后通过调用方法以命令式的形式进行路由跳转,比如想在登录请求完毕之后跳转就可以选择这种方式,更加灵活
import { useNavigate } from "react-router-dom";
const Article = () => {
// 调用useNavigate钩子函数获取导航方法
const navigate = useNavigate();
return (
<>
<div>Article组件</div>
{/* 编程式路由导航 */}
<button onClick={() => navigate("/login")}>返回登录页</button>
</>
);
};
export default Article;语法说明:通过调用
navigate方法传入地址path实现跳转Tips:
navigate(-1)返回上一级路由
四、导航传参

传递
searchParams/query参数在跳转路径后使用
?分割&拼接传递的参数jsx<button onClick={() => navigate("/login?id=123&name=张三")}> 返回登录页并传递searchParams参数 </button>在目标组件中使用
useSearchParams获取传递的searchParams参数,并调用get()方法获取指定参数值jsx// 声明接收searchParams路由参数 const [searchParams] = useSearchParams(); let id = searchParams.get("id"); let name = searchParams.get("name");
传递
params参数在跳转路径
/后传递参数jsx<button onClick={() => navigate("/login/001/张三")}> 返回登录页并传递params参数 </button>在路由规则中在路径后使用
/:id占位获取params参数jsxconst router = createBrowserRouter([ { path: "/login/:id/:name", element: <Login />, }, ....在目标组件中使用
useParams获取params对象,使用params.id获取传递的参数jsx// 声明接收params路由参数 const params = useParams(); let id = params.id; let name = params.name;
五、嵌套路由配置
1. 什么是嵌套路由
在一级路由中又内嵌了其他路由,这种关系就叫做嵌套路由,嵌套至一级路由内的路由又称作二级路由,例如: 
2. 嵌套路由配置
实现步骤
- 使用
children属性配置路由嵌套关系 - 使用
<Outlet/>组件配置二级路由渲染位置

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


注意:
配置默认二级路由后该路由缺少了
path属性,想要访问该默认路由,则path应为其上一级路径
Tips:
在
Vue中设置默认二级路由是将其path设置为""
4. 404路由配置
场景:当浏览器输入url的路径在整个路由配置中都找不到对应的 path,为了用户体验,可以使用 404 兜底组件进行渲染
实现步骤:
- 准备一个
NotFound组件 - 在路由表数组的末尾,以
*号作为路由path配置路由

*通配符,上面path全部不匹配时,匹配此路由
5. 两种路由模式
各个主流框架的路由常用的路由模式有俩种,history 模式和 hash 模式,ReactRouter分别由 createBrowerRouter 和 createHashRouter 函数负责创建
| 路由模式 | 创建函数 | url表现 | 底层原理 | 是否需要后端支持 |
|---|---|---|---|---|
history | createBrowerRouter | url/login | history对象 + pushState事件 | 需要(会出现刷新404) |
hash | createHashRouter | url/#/login | 监听hashChange事件 | 不需要 |
记账本案例
一、环境搭建
使用CRA / vite(推荐) 创建项目,并安装必要依赖,包括下列基础包
- Redux状态管理 - @reduxjs/toolkit 、 react-redux
- 路由 - react-router-dom
- 时间处理 - dayjs
- class类名处理 - classnames
- 移动端组件库 - antd-mobile
- 请求插件 - axios
- js工具库 - lodash
二、配置别名路径
1. 背景知识
- 路径解析配置(webpack / vite),把 @/ 解析为 src/
- 路径联想配置(VsCode),VsCode 在输入 @/ 时,自动联想出来对应的 src/下的子级目录

2. 路径解析配置
2.1 CRA创建的项目(webpack)
配置步骤:
- 安装craco npm i -D @craco/craco
- 项目根目录下创建配置文件 craco.config.js
- 配置文件中添加路径解析配置
- 包文件中配置启动和打包命令

2.2 vite 创建的项目
修改 vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ // 相对路径别名配置,使用 @ 代替 src
+ resolve: {
+ alias: {
+ "@": path.resolve("./src"), // @变为绝对路径 /src/
+ },
+ },
});注意: 修改
vite.config.json后需要重启项目
3. 联想路径配置
配置步骤:
- 根目录下新增配置文件
jsconfig.json - 添加路径提示配置
{
// 编译选项
"compilerOptions": {
"baseUrl": "./",
// 用于@联想路径
"paths": {
"@/*": ["src/*"]
}
}
}4. 使用路径别名 @
import App from "./App"; // 等同于下面一行代码
import App from "@/App"; ==> import App from "/src/App";三、数据Mock实现
在前后端分离的开发模式下,前端可以在没有实际后端接口的支持下先进行接口数据的模拟,进行正常的业务功能开发
1. 常见的Mock方式

2. json-server实现Mock
实现步骤:
项目中安装 json-server
npm i -D json-server准备一个
json文件 (素材里获取)添加启动命令 (配置模拟数据的
json文件和服务运行的端口)json"scripts": { + "server": "json-server --watch ./server/db.json --port 3001", },
运行
pnpm run server开启mock服务 监视./server/db.json当其变化时重启服务,服务端口为3001
访问接口进行测试
访问
http://localhost:3001/ka看是否有返回数据(遵循 RESTful风格的API)
四、整体路由设计

一级路由组件:Layout ,New,NotFound,
二级路由组件:Month,Year
Layout
Month
Year
New
NotFound
react-bill-test\src\router\index.jsx
import Layout from "@/pages/Layout";
import Month from "@/pages/Layout/Month";
import Year from "@/pages/Layout/Year";
import New from "@/pages/New";
import NotFound from "@/pages/NotFount";
import { createBrowserRouter } from "react-router-dom";
// 配置路由规则
const router = createBrowserRouter([
// 一级路由 根路由
{
path: "/",
element: <Layout />,
// 二级路由
children: [
{
// path: "month",
// 设置为默认路由
index: true,
element: <Month />,
},
{
path: "year",
element: <Year />,
},
],
},
// 添加账单
{
path: "/new",
element: <New />,
},
// 任意路由
{
path: "*",
element: <NotFound />,
},
]);
export default router;配置一级路由组件展示位置:react-bill-test\src\App.jsx
import { RouterProvider } from "react-router-dom";
import router from "./router";
function App() {
return (
<>
{/* 应用路由 */}
<RouterProvider router={router} />
</>
);
}
export default App;配置二级路由组件展示位置:react-bill-test\src\pages\Layout\index.jsx
import { Outlet } from "react-router-dom";
function layout() {
return (
<>
<div>Layout</div>
{/* 展示二级路由组件 */}
<Outlet />
</>
);
}
export default layout;五、antD主题定制
1. 定制方案

2. 实现方式
- 全局定制

- 局部定制

3. 记账本主题色
react-bill-test\src\them.css
:root:root {
--adm-color-primary: rgb(105, 174, 120);
}六、Redux管理账目列表

- 创建账单相关的仓库模块
// react-bill-test\src\store\modules\billStore.js
import { createSlice } from "@reduxjs/toolkit";
import axios from "axios";
// 账单列表相关的store
const billStore = createSlice({
name: "billStore",
// 初始化状态数据
initialState: {
billList: [],
},
// 修改状态数据的同步方法
reducers: {
setBillList(state, action) {
state.billList = action.payload;
},
},
});
// 解构出actionCreater函数
const { setBillList } = billStore.actions;
// 获取数据的异步方法
const getBillList = () => {
// 函数返回一个异步函数 接收dispatch作为形参可以调用action方法
return async (dispatch) => {
const result = await axios.get("http://localhost:3001/ka");
// 成功获取数据,触发同步reducer存储数据
result.status === 200 && dispatch(setBillList(result.data));
};
};
// 导出action方法
export { getBillList, setBillList };
// 导出reducer
const reducer = billStore.reducer;
export default reducer;- 组合子模块暴露大仓库
// react-bill-test\src\store\index.js
import { configureStore } from "@reduxjs/toolkit";
import billReducer from "./modules/billStore";
// 组合子模块暴露出大仓库
const store = configureStore({
reducer: {
bill: billReducer,
},
});
export default store;- 将仓库
store注入到应用中
// react-bill-test\src\main.jsx
import { createRoot } from "react-dom/client";
import App from "@/App";
// 导入主题定制css文件
import "./them.css";
import { Provider } from "react-redux";
import store from "./store";
createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);- 在组件中触发仓库中的异步方法获取数据,并获取仓库中的数据进行渲染
// react-bill-test\src\pages\Layout\index.jsx
function layout() {
// 获取dispatch方法 用于调用仓库中的action方法
const dispatch = useDispatch();
// 组件渲染完调用仓库中的方法获取账单数据并存储
useEffect(() => {
dispatch(getBillList());
}, []);
return ....
}七、TabBar功能实现

1. 静态布局实现
使用 antDesignMobile 的 TabBar 标签栏 组件
// react-bill-test\src\pages\Layout\index.jsx
import { getBillList } from "@/store/modules/billStore";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { Outlet } from "react-router-dom";
import {
AddCircleOutline,
BillOutline,
CalculatorOutline,
} from "antd-mobile-icons";
import { TabBar } from "antd-mobile";
import "./index.css";
function layout() {
// 获取dispatch方法 用于调用仓库中的action方法
const dispatch = useDispatch();
// 底部tabBar数据
const tabs = [
{
key: "/",
title: "月度账单",
icon: <BillOutline />,
},
{
key: "/new",
title: "记账",
icon: <AddCircleOutline />,
},
{
key: "/year",
title: "年度账单",
icon: <CalculatorOutline />,
},
];
// 组件渲染完调用仓库中的方法获取账单数据并存储
useEffect(() => {
dispatch(getBillList());
}, []);
return (
<>
<div className="layout">
<div className="container">
{" "}
{/* 展示二级路由组件 */}
<Outlet />
</div>
<div className="footer">
{/* 底部导航tabBar */}
<TabBar safeArea> // safeArea 安全适配屏幕
{tabs.map((item) => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
</div>
</div>
</>
);
}
export default layout;scss样式
安装依赖:pnpm i scss sass-embedded -D
.layout {
width: 100%;
.container {
width: 100%;
position: fixed;
top: 0;
bottom: 50px;
}
.footer {
position: fixed;
bottom: 0;
width: 100%;
}
}2. 切换路由实现
TabBar 组件提供onChange事件在切换面板时触发,会在事件回调函数中会入注入当前点击标签的 key,再通过编程式路由导航进行跳转
// 切换菜单跳转路由
const navigate = useNavigate()
const swithRoute = (path) => {
navigate(path)
}
return (
<div className="layout">
<div className="footer">
<TabBar onChange={swithRoute}>
{/* 省略... */}
</TabBar>
</div>
</div>
)八、月度账单-统计区域

1. 准备静态结构
// react-bill-test\src\pages\Layout\Month\index.jsx
import { NavBar } from "antd-mobile";
import "./index.scss";
import { DatePicker } from "antd-mobile";
import { useState } from "react";
import { useCallback } from "react";
function Month() {
// 控制时间选择器的显示与隐藏
const [visible, setVisible] = useState(true);
// 获取当前时间作为选择器的默认值
const now = new Date();
const [value, setValue] = useState(() => now);
// 自定义渲染每列展示的内容
const labelRenderer = useCallback((type, data) => {
switch (type) {
case "year":
return data + "年";
case "month":
return data + "月";
}
}, []);
return (
<>
{/* 页面标题 */}
<NavBar className="nav" back={null}>
月度收入
</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>
</div>
{/* 时间选择器 */}
<DatePicker
title="记账日期" // 标题
visible={visible} // 是否显示
value={value} // 当前选择值
precision="month" // 精确到
mouseWheel={true} // 允许通过鼠标滚轮进行选择
max={now} // 最大值
renderLabel={labelRenderer} // 自定义渲染每列展示的内容
onClose={() => {
setVisible(false);
}}
onConfirm={(val) => {
setValue(val);
}}
/>
</div>
</>
);
}
export default Month;// react-bill-test\src\pages\Layout\Month\index.scss
.nav {
font-size: 22px;
color: rgb(27, 28, 27);
}
.content {
width: 320px;
margin: 10px auto;
padding: 0 10px;
background-color: #ffe081;
border-radius: 10px;
.header {
padding: 20px 20px 0px 18.5px;
.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;
.item {
display: flex;
flex-direction: column;
margin-bottom: 20px;
.money {
height: 24px;
line-height: 24px;
margin-bottom: 5px;
font-size: 18px;
}
.type {
height: 14px;
line-height: 14px;
font-size: 12px;
}
}
}
}2. 点击切换时间选择框
实现思路:
- 准备一个状态数据控制时间选择框的显示与隐藏
- 点击箭头切换状态
- 根据状态控制弹框打开关闭以及箭头样式
function Month() {
+ // 控制时间选择器的显示与隐藏
+ const [visible, setVisible] = useState(false);
+ // 获取当前时间作为选择器的默认值
+ const now = new Date();
+ const [value, setValue] = useState(() => now);
+ // 自定义渲染每列展示的内容
+ const labelRenderer = useCallback((type, data) => {
+ switch (type) {
+ case "year":
+ return data + "年";
+ case "month":
+ return data + "月";
+ }
+ }, []);
return (
<>
{/* 页面标题 */}
<NavBar className="nav" back={null}>
月度收入
</NavBar>
<div className="content">
<div className="header">
{/* 时间切换区域 */}
<div className="date">
<span className="text">2023 | 3月账单</span>
<span
+ className={classNames("arrow", { expand: visible })}
+ onClick={() => {
+ setVisible(true);
+ }}
></span>
</div>
{/* 统计区域 */}
<div className="twoLineOverview">
....
</div>
</div>
{/* 时间选择器 */}
+ <DatePicker
+ title="记账日期" // 标题
+ visible={visible} // 是否显示
+ value={value} // 当前选择值
+ precision="month" // 精确到
+ mouseWheel={true} // 允许通过鼠标滚轮进行选择
+ max={now} // 最大值
+ renderLabel={labelRenderer} // 自定义渲染每列展示的内容
+ onClose={() => {
+ setVisible(false);
+ }}
+ onConfirm={(val) => {
+ setValue(val);
+ }}
+ />
</div>
</>
);
}3. 切换时间显示

实现思路:
- 以当前时间作为默认值
- 在时间切换时完成时间修改
import dayjs from "dayjs"
// 获取当前时间作为选择器的默认值
const now = new Date();
const [currentData, setCurrentData] = useState(now);
return (
<>
.....
<span className="text">
{dayjs(currentData).format("YYYY 年 MM")} 月账单
</span>4. 统计功能实现
实现思路:
- 从
Redux中获取到账单列表数据 - 数据二次处理
useMemo - 按月分组逻辑实现
lodash - 使用
useMemo根据当月的账单数组计算支出、收入、总计
// 获取仓库中的账单数据
const { billList } = useSelector((state) => state.bill);
// 按年月对账单列表进行分组 返回一个对象key为日期 YYYY-MM 格式
const monthGroup = useMemo(() => {
// useMemo 计算函数 billList当其发生变化时重新计算
return _.groupBy(billList, (item) => dayjs(item.date).format("YYYY-MM"));
}, [billList]);
// 当选择的日期和账单列表改变时重新计算当前月的支出、收入和结余
const currMonthBill = useMemo(() => {
// 当前月的账单列表
const currMonthBillList =
monthGroup[dayjs(currentData).format("YYYY-MM")] || [];
// 支出
let outcome = currMonthBillList.reduce((a, c) => {
return c.type === "pay" ? a + c.money : a;
}, 0);
// 收入
let income = currMonthBillList.reduce((a, c) => {
return c.type === "income" ? a + c.money : a;
}, 0);
// 结余
let balance = income + outcome;
return {
outcome,
income,
balance,
};
}, [currentData, monthGroup]);计算函数
useMemo可以作为计算属性使用,根据已有的响应式状态计算出新的响应式状态jsconst sttr = useMemo(()=>{ return 计算结果},[依赖计算的响应式数据])执行时机:组件渲染时执行一次,当依赖计算的响应式数据发生变化时会重新计算
九、月度账单-单日统计列表实现

1. 准备组件和配套样式
// react-bill-test\src\pages\Layout\Month\DayBill\index.jsx
import "./index.scss";
import classNames from "classnames";
function DayBill() {
return (
<>
<div className="dayBill">
<div className="top">
<div className="date">2024-09-20</div>
<div className={classNames("arrow", "expand")}></div>
</div>
<div className="billContent">
<div className="outcome">
<div className="title">支出</div>
<div className="money">0.00</div>
</div>
<div className="income">
<div className="title">收入</div>
<div className="money">10000.00</div>
</div>
<div className="balance">
<div className="money">10000.00</div>
<div className="title">结余</div>
</div>
</div>
</div>
</>
);
}
export default DayBill;配套样式
// react-bill-test\src\pages\Layout\Month\DayBill\index.scss
.dayBill {
border-radius: 10px;
background-color: #fff;
margin: 10px 20px;
padding: 15px;
.top {
display: flex;
justify-content: space-between;
.date {
font-size: 16px;
line-height: 26px;
}
.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);
}
}
.billContent {
display: flex;
justify-content: space-between;
padding-top: 5px;
.outcome {
display: flex;
font-size: 14px;
align-items: baseline;
.title {
color: pink;
margin-right: 5px;
}
.money {
color: rgb(83, 81, 81);
}
}
.income {
display: flex;
font-size: 14px;
align-items: baseline;
.title {
color: green;
margin-right: 5px;
}
.money {
color: rgb(83, 81, 81);
}
}
.balance {
display: flex;
font-size: 14px;
align-content: baseline;
.title {
color: rgb(83, 81, 81);
}
.money {
font-size: 20px;
color: black;
margin-right: 5px;
}
}
}
}2. 按日分组账单数据

+ // 当前选择月份账单按日分组
+ const [dayGroup, setDayGroup] = useState([]);
// 当选择的日期改变和更新仓库数据时重新计算当前月的支出、收入和结余
const currMonthResult = useMemo(() => {
// 当前月的账单列表
const currMonthBillList =
monthGroup[dayjs(currentData).format("YYYY-MM")] || [];
+ // 修改当前月按日的分组
+ setDayGroup(
+ Object.entries( // 将对象变为[key,value]组成的数组
+ _.groupBy(currMonthBillList, (item) => { // 返回一个对象 key为分组变量的值
+ return dayjs(item.date).format("YYYY-MM-DD");
+ })
+ )
+ );
......
}, [currentData, monthGroup]);3. 遍历日账单组件并传入参数
{dayGroup.map((item, index) => {
// 向子组件传递当前格式化后的日期和今日全部的账单数据列表
return <DayBill key={index} date={item[0]} dayBill={item[1]} />;
})}4. 接收数据计算统计渲染页面
// 组件函数的第一个参数为 props(父组件传递的数据)
function DayBill({ dayBill, date }) {
// 计算今日支出、收入、结余
const dayResult = useMemo(() => {
// 支出
let outcome = dayBill.reduce((total, curr) => {
return curr.type === "pay" ? total + curr.money : total;
}, 0);
// 支出
let income = dayBill.reduce((total, curr) => {
return curr.type === "income" ? total + curr.money : total;
}, 0);
// 结余
let balance = income + outcome;
// 返回计算结果
return {
outcome,
income,
balance,
};
}, [dayBill]);
return (
<>
<div className="dayBill">
<div className="top">
<div className="date">{date}</div>
<div className={classNames("arrow", "expand")}></div>
</div>
<div className="billContent">
<div className="outcome">
<div className="title">支出</div>
<div className="money">{dayResult.outcome.toFixed(2)}</div>
</div>
<div className="income">
<div className="title">收入</div>
<div className="money">{dayResult.income.toFixed(2)}</div>
</div>
<div className="balance">
<div className="money">{dayResult.balance.toFixed(2)}</div>
<div className="title">结余</div>
</div>
</div>
</div>
</>
);
}
export default DayBill;十、月度账单-单日账单列表展示

1. 渲染基础列表
// react-bill-test\src\pages\Layout\Month\DayBill\index.jsx
<div className="billDetail">
{dayBill.map((item) => {
return (
<div className="billItem" key={item.id}>
<div className="type">{item.useFor}</div>
<div
className={classNames(
item.money > 0 ? "income" : "outcome" // 收入和支出的颜色不同
)}
>
{item.money.toFixed(2)}
</div>
</div>
);
})}
</div>2. 适配Type
1-准备静态数据
export const billListData = {
pay: [
{
type: 'foods',
name: '餐饮',
list: [
{ type: 'food', name: '餐费' },
{ type: 'drinks', name: '酒水饮料' },
{ type: 'dessert', name: '甜品零食' },
],
},
{
type: 'taxi',
name: '出行交通',
list: [
{ type: 'taxi', name: '打车租车' },
{ type: 'longdistance', name: '旅行票费' },
],
},
{
type: 'recreation',
name: '休闲娱乐',
list: [
{ type: 'bodybuilding', name: '运动健身' },
{ type: 'game', name: '休闲玩乐' },
{ type: 'audio', name: '媒体影音' },
{ type: 'travel', name: '旅游度假' },
],
},
{
type: 'daily',
name: '日常支出',
list: [
{ type: 'clothes', name: '衣服裤子' },
{ type: 'bag', name: '鞋帽包包' },
{ type: 'book', name: '知识学习' },
{ type: 'promote', name: '能力提升' },
{ type: 'home', name: '家装布置' },
],
},
{
type: 'other',
name: '其他支出',
list: [{ type: 'community', name: '社区缴费' }],
},
],
income: [
{
type: 'professional',
name: '其他支出',
list: [
{ type: 'salary', name: '工资' },
{ type: 'overtimepay', name: '加班' },
{ type: 'bonus', name: '奖金' },
],
},
{
type: 'other',
name: '其他收入',
list: [
{ type: 'financial', name: '理财收入' },
{ type: 'cashgift', name: '礼金收入' },
],
},
],
}
export const billTypeToName = Object.keys(billListData).reduce((prev, key) => {
billListData[key].forEach(bill => {
bill.list.forEach(item => {
prev[item.type] = item.name
})
})
return prev
}, {})2-适配type
<div className="billType">{billTypeToName[item.useFor]}</div>十一、月度账单-切换打开关闭


// 声明状态
const [visible, setVisible] = useState(false)
// 控制箭头
<span
className={classNames('arrow', !visible && 'expand')}
onClick={() => setVisible(!visible)}></span>
// 控制列表显示
<div className="billList" style={{ display: ? "block" : "none" }}></div>十二、月度账单-Icon组件封装

1. 准备公共组件
// react-bill-test\src\components\Icon\index.jsx
const Icon = () => {
return (
<img
src={`https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/food.svg`}
alt="icon"
style={{
width: 20,
height: 20,
}}
/>
)
}
export default Icon2. 设计参数
// react-bill-test\src\components\Icon\index.jsx
function Icon({ icon, width, height }) {
// icon:svg图片名称 width:宽度 height:高度
return (
<>
<img
// src={`/src/assets/icons/${icon}.svg`}
src={`https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/${icon}.svg`}
alt="icon"
style={{
width: width || 20,
height: height || 20,
}}
/>
</>
);
}
export default Icon;3. 使用组件
// react-bill-test\src\pages\Layout\Month\DayBill\index.jsx
<div className="billItem" key={item.id}>
<div className="type">
+ <Icon icon={item.useFor} width={16} height={16} />{" "}
{billTypeToName[item.useFor]}
</div>十三、记账功能
1. 记账 - 结构渲染
// react-bill-test\src\pages\New\index.jsx
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配套样式
// react-bill-test\src\pages\New\index.scss
.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;
}
}
}
}
}
.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);
}
}
}2. 记账 - 支出和收入切换
需求分析:
- 点击支出显示支出分类列表
- 点击收入显示收入分类列表
实现步骤:
- 使用状态变量保存当前账单的状态
pay/income - 点击 "支出/收入" 更新当前账单状态,并根据账单状态动态显示激活状态
- 通过
billListData["pay/income"]获取当前账单状态列表并渲染
const New = () => {
// 获取navigate进行路由跳转
const navigate = useNavigate();
+ // 当前账单状态 收入/支出
+ const [billType, setBillType] = useState("pay");
return (
<div className="keepAccounts">
{/* 标题 */}
<NavBar className="nav" onBack={() => navigate(-1)}>
记一笔
</NavBar>
<div className="header">
<div className="kaType">
+ {/* 点击切换账单状态 */}
<Button
shape="rounded"
+ onClick={() => setBillType("pay")}
+ className={classNames(billType === "pay" && "selected")}
>
支出
</Button>
<Button
shape="rounded"
+ onClick={() => setBillType("income")}
+ className={classNames(billType === "income" && "selected")}
>
收入
</Button>
</div>
......
<div className="kaTypeList">
{/* 遍历展示不同账单状态列表 */}
+ {billListData[billType].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 icon={item.type} />
</div>
<div className="text">{item.name}</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
........3. 记账 - 新增一笔
需求分析:
当用户选择 "收入/支出" 填写了金额,并选择了收入/支出的原因,点击保存按钮将所填的信息存储到 db.json中
实现步骤:
- 收集数据(账单类型:type,账单金额:money,记账时间:date,账单类型:useFor)
- 金额 和 支出原因非空校验
- 在组件中编写上传数据的方法,点击保存时整理收集到的数据并调用上传数据的方法
- 提示上传成功跳转到首页
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";
import { useState } from "react";
import dayjs from "dayjs";
import axios from "axios";
import { Toast } from "antd-mobile";
const New = () => {
// 获取navigate进行路由跳转
const navigate = useNavigate();
// 当前账单状态 收入pay/支出income
const [billType, setBillType] = useState("pay");
// 账单金额
const [amount, setAmount] = useState("0.00");
// 账单日期
const [date, setDate] = useState(new Date());
// 使用原因
const [useFor, setUseFor] = useState("");
// 控制datePicker选择器的显示与隐藏
const [showDatePicker, setShowDatePicker] = useState(false);
// 上传当前账单数据的接口方法
const updateBillList = async (billDate) => {
// 发请求更新账单列表数据
const result = await axios.post("http://localhost:3001/ka", billDate);
if (result.status === 201) {
Toast.show({
icon: "success",
content: "保存成功",
});
}
};
// 点击确定按钮的回调
const save = async () => {
// 表单验证
if (+amount === 0)
return Toast.show({
content: "金额不能为0",
});
if (useFor === "")
return Toast.show({
content: "使用原因不能为空",
});
// 整理收集的参数
const billData = {
type: billType,
money: billType === "pay" ? -amount : +amount,
date: date,
useFor: useFor,
};
// 发请求更新数据
await updateBillList(billData);
// 返回首页
navigate("/");
};
return (
<div className="keepAccounts">
{/* 标题 */}
<NavBar className="nav" onBack={() => navigate(-1)}>
记一笔
</NavBar>
<div className="header">
<div className="kaType">
{/* 点击切换账单状态 */}
<Button
shape="rounded"
onClick={() => setBillType("pay")}
className={classNames(billType === "pay" && "selected")}
>
支出
</Button>
<Button
shape="rounded"
onClick={() => setBillType("income")}
className={classNames(billType === "income" && "selected")}
>
收入
</Button>
</div>
<div className="kaFormWrapper">
<div className="kaForm">
<div className="date">
<Icon icon="calendar" className="icon" />
{/* 当前选择的日期 */}
<span className="text" onClick={() => setShowDatePicker(true)}>
{dayjs(date).format("YYYY-MM-DD")}
</span>
{/* 日期选择器 */}
<DatePicker
className="kaDate"
placeholder="0.00"
title="记账日期"
visible={showDatePicker}
value={date}
max={new Date()}
onClose={() => {
setShowDatePicker(false);
}}
onConfirm={(val) => {
setDate(val);
}}
/>
</div>
<div className="kaInput">
<Input
className="input"
type="number"
value={amount}
onChange={(val) => setAmount(val)}
/>
<span className="iconYuan">¥</span>
</div>
</div>
</div>
</div>
<div className="kaTypeList">
{/* 遍历展示不同账单状态列表 */}
{billListData[billType].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",
item.type === useFor ? "selected" : ""
)}
onClick={() => setUseFor(item.type)}
key={item.type}
>
<div className="icon">
<Icon icon={item.type} />
</div>
<div className="text">{item.name}</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
<div className="btns">
<Button className="btn save" onClick={save}>
保 存
</Button>
</div>
</div>
);
};
export default New;