Skip to content

React基础

一、React介绍

React由Meta公司开发,是一个用于 构建Web和原生交互界面的库 image.png

1. React的优势

相较于传统基于DOM开发的优势

  1. 组件化的开发方式
  2. 不错的性能

相较于其它前端框架的优势

  1. 丰富的生态
  2. 跨平台支持

2. React的市场情况

全球最流行,大厂必备 image.png

二、开发环境创建

1. 创建React项目

create-react-app是一个快速创建React开发环境的工具,底层由Webpack构件,封装了配置细节,开箱即用 执行命令:

bash
npx create-react-app react-basic
  1. npx - Node.js工具命令,查找并执行后续的包命令
  2. create-react-app - 核心包(固定写法),用于创建React项目
  3. 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

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:使用不同的构建工具 vitewebpack创建的项目入口文件不同

3. 项目根组件

src/App.jsx

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的方式

jsx
const message = 'this is message'

function App(){
  return (
    <div>
      <h1>this is title</h1>
      {message}   // 模板中使用变量
    </div>
  )
}

优势:

  1. HTML的声明式模版写法
  2. JavaScript的可编程能力

2. JSX的本质

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

image.png

3. JSX高频场景-JS表达式

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

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

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

jsx
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高频场景-列表渲染

image.png

在JSX中可以使用原生js中的map方法 实现列表渲染

jsx
// 要渲染的列表数据
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高频场景-条件渲染

image.png

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

jsx
let isLogin = true;

function App() {
  return (
    <div className="App">
      {/* 逻辑&& */}
      {isLogin && <span>Jack</span>}
      {/* 三元运算 */}
      {isLogin ? <div>Jack</div> : <div>请登录!</div>}
    </div>
  );
}

Tips:条件渲染实现的是组件 挂载 <==> 卸载

6. JSX高频场景-复杂条件渲染

需求:列表中需要根据文章的状态适配

image.png

解决方案:自定义函数 + 判断语句(if,else if,....,else)返回要渲染的内容

jsx
// 定义文章类型
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 + 事件名称 = { 事件处理程序 },整体上遵循驼峰命名法

jsx
function App() {
  const handleClick = () => {
    alert("你点击了我!!!");
  };
  return (
    <div className="App">
      <button onClick={handleClick}>点我给你好看!!!</button>
    </div>
  );
}

2. 使用事件参数e

在事件回调函数中设置声明接收 e 即可

jsx
function App(){
  const clickHandler = (e)=>{
    console.log('button按钮点击了', e)
  }
  return (
    <button onClick={clickHandler}>click me</button>
  )
}

3. 传递自定义参数

语法:事件绑定的位置改造成箭头函数的写法 return事件回调函数并传递实参

jsx
function App(){
  const clickHandler = (name)=>{
    console.log('button按钮点击了', name)
  }
  return (
    <button onClick={()=>clickHandler('jack')}>click me</button>
  )
}

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

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

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

jsx
function App(){
  const clickHandler = (name,e)=>{
    console.log('button按钮点击了', name,e)
  }
  return (
    <button onClick={(e)=>clickHandler('jack',e)}>click me</button>
  )
}

五、React组件基础使用

1. 组件是什么

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

2. 组件基础使用

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

jsx
// 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也会跟着变化(数据驱动视图更新

image.png
jsx
// 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)

  1. useState 是一个函数,返回值是一个数组
  2. 数组中的第一个参数是状态变量(只可读不可进行修改),第二个参数是 set函数用来修改变量(只可整体替换
  3. useState的参数将作为 count的初始值(当初始值需要复杂运算时,可以写成一个函数返回结果)

Tips:

useState的初始值需要经过计算得到时,可以传递一个回调函数,在函数内部进行计算

js
const [currentDate, setCurrentDate] = useState(()=>{	
	return new Date()
})

2. 状态的修改规则

状态不可变:

在React中状态被认为是只读的,我们应该始终替换它而不是改变现有对象,直接修改状态不能引发视图更新image.png

注意:状态变量不可直接修改,必须调用set方法进行替换,才能触发视图的更新

3. 修改对象状态

对于对象类型的状态变量,应该始终给set方法一个全新的对象 来进行替换

image.png

Tips:修改嵌套对象,数组的方法

七、组件的基础样式处理

React组件基础的样式控制有两种方式,行内样式class类名控制

  1. 行内样式(不推荐)
jsx
// 将行内样式进行抽离
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>
  );
}
  1. class 类名控制 (className)

index.css

css
.foo {
  color: pink;
  font-size: 100px;
}

App.jsx

jsx
// 导入样式
import "./index.css";

function App() {
  return (
    <div className="App">
      {/* 通过class类名控制 */}
      <div className="foo">组件基础样式处理</div>
    </div>
  );
}

八、B站评论案例-数据渲染

1. 需求分析

image.png
  1. 渲染评论列表
  2. 删除评论实现
  3. 渲染导航Tab和高亮实现
  4. 评论列表排序功能实现

2. 渲染评论列表

实现步骤:

  1. 使用 useState维护评论列表
  2. 使用 map方法对列表数据进行遍历渲染

3. 评论删除

需求:

  1. 只有自己的评论才显示删除按钮
  2. 点击删除按钮,删除当前评论,列表中不再显示

实现步骤:

  1. 删除显示 - 条件渲染
  2. 删除功能 - 拿到当前项 id 以 id 为条件对评论列表做 filter 过滤
jsx
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 做匹配,谁匹配到就设置高亮类名(动态类名)
jsx
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 函数重新渲染页面(数据驱动视图更新)
jsx
// 导入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>
  }
}

Tips:数组排序可以借助 lodash 工具库中的 orderBy

6. classnames优化类名控制

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

原本写法:不够直观容易出错

js
className={ activeTabType === item.type ? "active nav-item" : "nav-item"}

使用 classnames写法:

安装 classnamesnpm i classnames

js
// 导入 classNames
import classNames from "classnames";

className={classNames('nav-item', {active: activeTabType === item.type})}
  • nav-item: 静态类名
  • active: 动态类名
  • type === item.type: 条件判断,结果为 true 添加该类名

九、React表单控制

1. 受控绑定

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

image.png

实现步骤:

  1. 准备一个 React 状态值
  2. 通过 value 属性绑定状态,通过 onChange事件更新状态值驱动视图更新
jsx
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 钩子函数,分为两步:

  1. 使用 useRef 创建 ref 对象,并与 JSX 绑定

    js
    const inputRef = useRef(null)
    html
    <input type="text" ref={inputRef}></input>
  2. 在DOM可用时,通过 inputRef.current 拿到 DOM 对象

    js
    console.dir(inputRef.current)
jsx
function App(){
  const inputRef = useRef(null)

  const onChange = ()=>{
    console.log(inputRef.current.value)
  }
  
  return (
    <input 
      type="text" 
      ref={inputRef}
      onChange={onChange}
    />
  )
}

Tips:DOM可用指的是组件渲染完毕后,才可以获取到

十、B站评论案例-发布评论

image.png

核心功能实现:

  1. 获取评论内容
  2. 点击发布按钮发布评论

评论id和时间格式化处理:

  1. 使用 uuidnanoid
  2. 使用 day.jsmoment

清空内容并重新聚焦:

  1. 把控制 input 框的 value 状态设置为空串
  2. 使用 useRef 获取输入框组件实例对象调用 focus方法
jsx
// 导入 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组件通信

概念:组件通信就是组件之间的数据传递, 根据组件嵌套关系的不同,有不同的通信手段和方法image.png

  1. A-B 父子通信
  2. B-C 兄弟通信
  3. A-E 跨层通信

1. 父子通信-父传子

image.png

1.1 props

**实现步骤 **

  1. 父组件传递数据 - 在子组件标签上绑定属性
  2. 子组件接收数据 - 子组件通过props参数接收数据
jsx
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表达式必须具有一个根元素,可以使用 <></> 作为根元素,且不会被渲染

jsx
function Son(props) {
return (
 <>
   <div>this is son</div>
   <div>{props.name}</div>
 </>
);
}

1.2 props说明

props可以传递任意的合法数据,比如数字、字符串、布尔值、数组、对象、函数、JSX image.png

注意:props只读对象,不可进行修改(单项数据流)

子组件只能读取props中的数据,不能直接进行修改, 父组件的数据只能由父组件修改

1.3 特殊的prop.chilren

场景:当我们把内容嵌套在组件的标签内部时,组件会自动在 prop.children 接收该内容

image-20240814111851256image-20240814111901757image-20240814112045435image-20240814112110521

jsx
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 传递一个函数给子组件)

image.png
tsx
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. 兄弟组件通信

image.png

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

  1. A组件先通过子传父的方式把数据传递给父组件App
  2. App拿到数据之后通过父传子的方式再传递给B组件
jsx
// 兄弟组件通信 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. 跨层组件通信

image.png **实现步骤:**
  1. 使用 createContext方法创建一个上下文对象Ctx
  2. 在顶层组件(App)中通过 Ctx.Provider 组件提供数据
  3. 在底层组件(B)中通过 useContext 钩子函数获取消费数据
jsx
// 爷孙组件通信 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来模拟生命周期行为和通过传入特定的依赖项模拟数据监视行为

image.png

注意:

上面的组件中没有发生任何的用户事件,组件渲染完毕之后就需要获取服务器数据,整个过程属于“只由渲染引起的操作

2. 基础使用

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

  1. 参数1是一个函数,可以把它叫做副作用函数,在函数内部可以放置要执行的操作
  2. 参数2是一个数组(可选参),在数组里放置依赖项,不同依赖项会影响第一个参数函数的执行,当是一个空数组的时候,副作用函数只会在组件挂载到页面后执行一次

接口地址:http://geek.itheima.net/v1_0/channels

jsx
// 需求:在组件渲染完毕后获取频道数据并渲染
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函数第二个参数)**的不同,会有不同的执行表现

依赖项副作用功函数的执行时机
没有依赖项组件初始渲染 + 组件更新时执行(更新)
空数组依赖只在初始渲染时执行一次(渲染完毕)
添加特定依赖项组件初始渲染 + 依赖项变化时执行(监视)
jsx
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中开启了一个定时器,我们想在组件卸载时把这个定时器再清理掉,这个过程就是清理副作用

在副作用函数内会返回一个在组件卸载前执行的清理函数(模拟组件卸载时的生命周期函数)

image.png

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

jsx
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函数可以用来实现逻辑的封装和复用

image.png
jsx
// 封装自定义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使用规则

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

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

image.png

需求:

  1. 使用请求接口的方式获取评论列表并渲染
  2. 使用自定义Hook函数封装数据请求的逻辑
  3. 把评论中的每一项抽象成一个独立的组件实现渲染

1. 通过接口获取评论列表

  1. 使用 json-server 工具模拟接口服务,通过 axios 发送请求
  • 安装:pnpm i json-server -D

  • 初始化数据:在 src同级目录创建 db.json 并添加数据

  • 配置命令:package.json

    json
      "scripts": {
         .......
        "serve": "json-server --watch db.json --port 3001" // 服务运行在 3001端口
      },

Tips:json-server遵循 REST API 风格

  1. 使用 useEffect 在组件渲染完毕后调用接口获取数据
jsx
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函数封装数据请求

  1. 定义一个 use 打头的函数
  2. 函数内部编写封装的逻辑
  3. retrun 出去组件中使用的状态和方法
  4. 在组件中调用函数解构赋值使用
jsx
// 自定义 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 的方式获取父组件的数据和方法

jsx
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),可以独立于框架运行

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

image.png **为什么要使用Redux?**
  1. 独立于组件,无视组件之间的层级关系,简化通信问题
  2. 单向数据流清晰,易于定位bug
  3. 调试工具配套良好,方便调试

二、Redux快速体验

1. 实现计数器

需求:不和任何框架绑定,不使用任何构建工具,使用纯Redux实现计数器

image-20240816100459545 使用步骤:
  1. 定义一个 reducer 函数 (根据当前想要做的修改返回一个新的状态)
  2. 使用createStore方法传入 reducer函数 生成一个store实例对象
  3. 使用store实例的 subscribe方法 订阅数据的变化(数据一旦变化,可以得到通知)
  4. 使用store实例的 dispatch方法提交action对象 触发数据变化(告诉reducer你想怎么改数据)
  5. 使用store实例的 getState方法 获取最新的状态数据更新到视图中

代码实现:

html
<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的难点是理解它对于数据修改的规则, 下图动态展示了在整个数据的修改中,数据的流向

1 为了职责清晰,`Redux`代码被分为三个核心的概念,我们学`redux`,其实就是学这三个核心概念之间的配合,三个概念分别是:
  • state: 一个对象 存放着我们管理的数据
  • action: 一个对象 用来描述你想怎么改数据
  • reducer: 一个函数 根据 action 的描述更新 state

三、Redux与React - 环境准备

Redux 虽然是一个框架无关可以独立运行的插件,但是社区通常还是把它与 React 绑定在一起使用,以一个计数器案例体验一下Redux + React 的基础使用

1. 配套工具

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

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

2. 配置基础环境

  1. 使用 CRA(Create React App) 或 Vite 快速创建 React 项目
bash
npx create-react-app react-redux-pro (CRA)

pnpm create vite react-redux-pro --template react (Vite)
  1. 安装配套工具
bash
npm i @reduxjs/toolkit  react-redux (CRA)

pnpm i @reduxjs/toolkit  react-redux  (Vite)
  1. 启动项目
bash
npm run start  (CRA)

pnpm run dev  (Vite)

3. store目录结构设计

image.png
  1. 通常集中状态管理的部分都会单独创建一个单独的 store 目录

  2. 应用通常会有很多个子 store 模块,所以创建一个 modules 目录,在内部编写业务分类的子 store

  3. store 中的入口文件 index.js 的作用是组合 modules 中所有的子模块,并导出 store

四、Redux与React - 实现counter

1. 整体路径熟悉

image.png

2. 使用React Toolkit 创建 counterStore

store/modules/counterStore.js

javascript
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

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负责把ReduxReact 连接起来,内置 Provider组件通过 store 参数把创建好的store实例注入到应用中,链接正式建立

src/main.jsx

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 数据

image.png

5. React组件修改store中的数据

React组件中修改store中的数据需要借助另外一个hook函数:useDispatch,它的作用是生成提交action对象dispatch函数,使用样例如下:

jsx
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>
    </>
  );
}
  1. 组件中使用哪个 hook 函数获取 store 中的数据?

    useSelector

  2. 组件中使用哪个 hook 函数获取 dispatch 方法?

    useDispatch

  3. 如何得到要提交 action 对象?

    执行 store 模块中导出的 actionCreater 方法

五、Redux与React - 提交action传参

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

image.png 实现方式:在`reducers`的同步修改方法中添加`action`对象参数,在调用`actionCreater`的时候传递参数,参数会被传递到`action`对象`payload`属性上

image.png

六、Redux与React - 异步action处理

需求理解

在仓库中发请求获取数据并进行存储image.png

实现步骤

  1. 创建store的写法保持不变,配置好同步修改状态的方法

  2. 单独封装一个函数,在函数内部return一个新函数,在新函数中

    1. 封装异步请求获取数据
    2. 调用同步actionCreater传入异步数据生成一个action对象,并使用dispatch提交
  3. 组件中dispatch的写法保持不变

代码实现

src\store\modules\channelStore.js

javascript
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

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

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提交信息查看等

image.png

八、美团小案例

1. 案例演示

image.png

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

2. 准备并熟悉环境

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

3. 分类和商品列表渲染image.png

实现步骤:

  1. 启动项目(mock服务 + 前端服务)
  2. 使用 RTK 编写 store(使用异步 action 获取数据)
  3. 组件触发 action 并渲染数据

创建商品数据仓库

redux-meituan\src\store\modules\takeaway.js

javascript
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

jsx
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

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. 点击分类激活交互实现

image.png 编写store逻辑 `redux-meituan\src\store\modules\takeaway.js`
javascript
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

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. 商品列表切换显示

image.png

遍历生成商品列表,根据当前激活的菜单下标,展示对应的商品列表

jsx
.........
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. 添加购物车实现

image.png 编写store逻辑 redux-meituan\src\store\modules\takeaway.js

javascript
....
// 商品仓库
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

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. 统计区域实现

image.png

实现思路

  1. 使用 useEffect 监视 store 中的 cartList属性,当其发生变化时重新计算,当前购物车中商品总数量和总金额
  2. 根据商品总数量是否为0动态展示底部购物车状态栏高亮颜色
jsx
  // 获取仓库中购物车列表的数据
  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. 购物车列表功能实现

image.png

实现步骤:

  1. 使用 cartList 遍历渲染列表
  2. foods 仓库的 reducers 中增加减少和清空购物车的方法

控制列表渲染 redux-meituan\src\components\Cart\index.jsx

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

javascript
    // 添加到购物车
    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

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. 控制购物车显示和隐藏

image.png

jsx
+// 控制购物车面板的显示和隐藏
+  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 对应的组件会在页面中进行渲染 image.png

2. 创建路由开发环境

bash
# 使用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 dev

3. 快速开始

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

image.png
jsx
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组件:路由绑定,将路由规则应用到应用中

二、抽象路由模块

image.png
  1. 将路由组件放到 pagesviews
  2. 将路由配置抽离到 router/index.jsx
  3. mian.jsx/App.jsx 中渲染 RouterProvider组件并注入 router实例

配置路由规则:react-router-pro\src\router\index.jsx

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

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. 什么是路由导航

路由系统中的多个路由之间需要进行路由跳转,并且在跳转的同时有可能需要传递参数进行通信 image.png

2. 声明式导航

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

jsx
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 钩子得到导航方法,然后通过调用方法以命令式的形式进行路由跳转,比如想在登录请求完毕之后跳转就可以选择这种方式,更加灵活

jsx
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) 返回上一级路由

四、导航传参

image.png
  • 传递 searchParams/query 参数

    1. 在跳转路径后使用 ? 分割 & 拼接传递的参数

      jsx
      <button onClick={() => navigate("/login?id=123&name=张三")}>
           返回登录页并传递searchParams参数
      </button>
    2. 在目标组件中使用 useSearchParams 获取传递的 searchParams参数,并调用 get() 方法获取指定参数值

      jsx
      // 声明接收searchParams路由参数
        const [searchParams] = useSearchParams();
        let id = searchParams.get("id"); 
        let name = searchParams.get("name");
  • 传递 params 参数

    1. 在跳转路径 / 后传递参数

      jsx
      <button onClick={() => navigate("/login/001/张三")}>
          返回登录页并传递params参数
      </button>
    2. 在路由规则中在路径后使用 /:id 占位获取params参数

      jsx
      const router = createBrowserRouter([
        {
          path: "/login/:id/:name",
          element: <Login />,
        },
       ....
    3. 在目标组件中使用 useParams 获取 params 对象,使用 params.id 获取传递的参数

      jsx
        // 声明接收params路由参数
        const params = useParams();
        let id = params.id;
        let name = params.name;

五、嵌套路由配置

1. 什么是嵌套路由

在一级路由中又内嵌了其他路由,这种关系就叫做嵌套路由,嵌套至一级路由内的路由又称作二级路由,例如: image.png

2. 嵌套路由配置

实现步骤

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

image.png

3. 默认二级路由

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

image-20240902135616193image-20240902135504002

注意:

配置默认二级路由后该路由缺少了 path 属性,想要访问该默认路由,则path 应为其上一级路径

Tips:

Vue 中设置默认二级路由是将其 path 设置为 ""

4. 404路由配置

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

实现步骤:

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

image.png

* 通配符,上面 path 全部不匹配时,匹配此路由

5. 两种路由模式

各个主流框架的路由常用的路由模式有俩种,history 模式和 hash 模式,ReactRouter分别由 createBrowerRoutercreateHashRouter 函数负责创建

路由模式创建函数url表现底层原理是否需要后端支持
historycreateBrowerRouterurl/loginhistory对象 + pushState事件需要(会出现刷新404)
hashcreateHashRouterurl/#/login监听hashChange事件不需要

记账本案例

一、环境搭建

使用CRA / vite(推荐) 创建项目,并安装必要依赖,包括下列基础包

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

二、配置别名路径

1. 背景知识

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

2. 路径解析配置

2.1 CRA创建的项目(webpack)

配置步骤:

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

2.2 vite 创建的项目

修改 vite.config.js

json
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. 联想路径配置

配置步骤:

  1. 根目录下新增配置文件 jsconfig.json
  2. 添加路径提示配置
json
{
  // 编译选项
  "compilerOptions": {
    "baseUrl": "./",
    // 用于@联想路径
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

4. 使用路径别名 @

js
import App from "./App"; // 等同于下面一行代码
import App from "@/App"; ==> import App from "/src/App";

三、数据Mock实现

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

1. 常见的Mock方式

image.png

2. json-server实现Mock

实现步骤:

  1. 项目中安装 json-server

    npm i -D json-server
  2. 准备一个 json 文件 (素材里获取)

  3. 添加启动命令 (配置模拟数据的 json文件和服务运行的端口)

    json
    "scripts": {
    +  "server": "json-server --watch ./server/db.json --port 3001",
    },

运行 pnpm run server 开启 mock 服务 监视 ./server/db.json 当其变化时重启服务,服务端口为3001

  1. 访问接口进行测试

    访问 http://localhost:3001/ka 看是否有返回数据(遵循 RESTful风格的API

四、整体路由设计

image.png

一级路由组件:LayoutNewNotFound

二级路由组件:MonthYear

Layout

Month

Year

New

NotFound

react-bill-test\src\router\index.jsx

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

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

jsx
import { Outlet } from "react-router-dom";

function layout() {
  return (
    <>
      <div>Layout</div>
      {/* 展示二级路由组件 */}
      <Outlet />
    </>
  );
}

export default layout;

五、antD主题定制

1. 定制方案

image.png

2. 实现方式

  1. 全局定制
image.png
  1. 局部定制
image.png

3. 记账本主题色

react-bill-test\src\them.css

css
:root:root {
  --adm-color-primary: rgb(105, 174, 120);
}

六、Redux管理账目列表

image.png
  1. 创建账单相关的仓库模块
javascript
// 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;
  1. 组合子模块暴露大仓库
js
// 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;
  1. 将仓库 store 注入到应用中
jsx
// 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>
);
  1. 在组件中触发仓库中的异步方法获取数据,并获取仓库中的数据进行渲染
jsx
// react-bill-test\src\pages\Layout\index.jsx
function layout() {
  // 获取dispatch方法 用于调用仓库中的action方法
  const dispatch = useDispatch();
  
  // 组件渲染完调用仓库中的方法获取账单数据并存储
  useEffect(() => {
    dispatch(getBillList());
  }, []);
  return ....
}

七、TabBar功能实现

image.png

1. 静态布局实现

使用 antDesignMobileTabBar 标签栏 组件

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

scss
.layout {
  width: 100%;
  .container {
    width: 100%;
    position: fixed;
    top: 0;
    bottom: 50px;
  }
  .footer {
    position: fixed;
    bottom: 0;
    width: 100%;
  }
}

2. 切换路由实现

TabBar 组件提供onChange事件在切换面板时触发,会在事件回调函数中会入注入当前点击标签的 key,再通过编程式路由导航进行跳转

jsx
 // 切换菜单跳转路由
  const navigate = useNavigate()
  const swithRoute = (path) => {
    navigate(path)
  }

  return (
    <div className="layout">
      <div className="footer">
        <TabBar onChange={swithRoute}>
          {/* 省略... */}
        </TabBar>
      </div>
    </div>
  )

八、月度账单-统计区域

image.png

1. 准备静态结构

jsx
// 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;
css
// 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. 点击切换时间选择框

实现思路:

  1. 准备一个状态数据控制时间选择框的显示与隐藏
  2. 点击箭头切换状态
  3. 根据状态控制弹框打开关闭以及箭头样式
jsx
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. 切换时间显示

image.png

实现思路:

  1. 以当前时间作为默认值
  2. 在时间切换时完成时间修改
jsx
import dayjs from "dayjs"

// 获取当前时间作为选择器的默认值
const now = new Date();
const [currentData, setCurrentData] = useState(now);

return (
  <>
    .....
    <span className="text">
    {dayjs(currentData).format("YYYY 年 MM")} 月账单
   </span>

4. 统计功能实现

实现思路:

  1. Redux中获取到账单列表数据
  2. 数据二次处理 useMemo
  3. 按月分组逻辑实现 lodash
  4. 使用 useMemo 根据当月的账单数组计算支出、收入、总计
jsx
// 获取仓库中的账单数据
  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 可以作为计算属性使用,根据已有的响应式状态计算出新的响应式状态

js
const sttr = useMemo(()=>{ return 计算结果},[依赖计算的响应式数据])

执行时机:组件渲染时执行一次,当依赖计算的响应式数据发生变化时会重新计算

九、月度账单-单日统计列表实现

image.png

1. 准备组件和配套样式

jsx
// 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;

配套样式

scss
// 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. 按日分组账单数据

image-20240904181516263
javascript
+  // 当前选择月份账单按日分组
+  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. 遍历日账单组件并传入参数

jsx
{dayGroup.map((item, index) => {
  // 向子组件传递当前格式化后的日期和今日全部的账单数据列表
  return <DayBill key={index} date={item[0]} dayBill={item[1]} />;
})}

4. 接收数据计算统计渲染页面

jsx
// 组件函数的第一个参数为 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;

十、月度账单-单日账单列表展示

image.png

1. 渲染基础列表

jsx
// 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-准备静态数据

javascript
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

javascript
 <div className="billType">{billTypeToName[item.useFor]}</div>

十一、月度账单-切换打开关闭

image.pngimage.png
jsx
// 声明状态
const [visible, setVisible] = useState(false)

// 控制箭头
 <span 
   className={classNames('arrow', !visible && 'expand')} 
   onClick={() => setVisible(!visible)}></span>
     
// 控制列表显示
<div className="billList" style={{ display: ? "block" : "none" }}></div>

十二、月度账单-Icon组件封装

image.png

1. 准备公共组件

jsx
// 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 Icon

2. 设计参数

jsx
// 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. 使用组件

jsx
// 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. 记账 - 结构渲染

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

配套样式

css
// 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. 记账 - 支出和收入切换

需求分析:

  • 点击支出显示支出分类列表
  • 点击收入显示收入分类列表

实现步骤:

  1. 使用状态变量保存当前账单的状态 pay/income
  2. 点击 "支出/收入" 更新当前账单状态,并根据账单状态动态显示激活状态
  3. 通过 billListData["pay/income"] 获取当前账单状态列表并渲染
jsx
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

实现步骤:

  1. 收集数据(账单类型:type,账单金额:money,记账时间:date,账单类型:useFor)
  2. 金额 和 支出原因非空校验
  3. 在组件中编写上传数据的方法,点击保存时整理收集到的数据并调用上传数据的方法
  4. 提示上传成功跳转到首页
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";
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)}>
                &nbsp;&nbsp;{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;