avatar


6.React [2/4]

Redux

简介

Redux,是React开发中最常用的集中状态管理工具,也可以独立于框架运行。

Redux

优点

  1. 独立于组件,无视组件之间的层级关系,简化通信问题。
  2. 单项数据流清晰,易于定位BUG。
  3. 调试工具配套良好,方便调试。

快速体验

我们只使用Redux实现计数器,不和任何框架绑定,不使用任何构建工具。

Redux计数器

步骤:

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

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<html>

<body>
<button id="decrement">-</button>
<span id="count">0</span>
<button id="increment">+</button>

<script src="https://unpkg.com/redux@4.2.1/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>

</body>

</html>

对于单一的HTML文件,我们可以通过这个插件,快速部署。

Five Server

右键,然后单击"Open with Five Server"即可。

Open With Five Server

数据流架构

下图展示了在整个数据的修改中,数据的流向。

数据流架构

Redux代码被分为三个核心的概念,分别是:

  1. State:对象,存放着我们管理的数据。
  2. Action:对象,用来描述我们想怎么改数据。
  3. Reducer:函数,根据action更新state

View,视图,提交Action对象,对应上文代码的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 增
const inBtn = document.getElementById('increment')
inBtn.addEventListener('click', () => {
store.dispatch({
type: 'INCREMENT'
})
})
// 减
const dBtn = document.getElementById('decrement')
dBtn.addEventListener('click', () => {
store.dispatch({
type: 'DECREMENT'
})
})

Reducer,对应上文代码的:

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

Store,对应上文代码的:

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

根据Store,再更新View的内容,对应上文代码的:

1
2
3
4
5
// 订阅数据变化
store.subscribe(() => {
console.log(store.getState())
document.getElementById('count').innerText = store.getState().count
})

Redux与React

两个包

Redux虽然与框架无关,可以独立运行的插件;但通常还是将其与React绑定在一起使用。

在React中使用redux,需要安装两个包:

  1. Redux Toolkit,(RTK),用来编写和Redux相关的逻辑。
  2. react-redux,用来链接Redux和React组件。

安装命令,示例代码:

1
npm i @reduxjs/toolkit  react-redux

配置基础环境

基于CRA快速创建React项目,示例代码:

1
npx create-react-app react-redux

然后我们cdreact-redux目录,再安装Redux Toolkitreact-redux,示例代码:

1
npm i @reduxjs/toolkit  react-redux

store目录结构设计

在Redux开发中:

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

我们在src目录下,创建store目录;并在store目录中创建index.jsmodules目录;然后在modules目录中创建counterStore.jschannelStore.js

store目录结构

整体过程

counter计数器为例,整体过程如下:

counter-整体流程

基于ReactToolkit创建counterStore

编辑上文创建的counterStore.js,内容如下:

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

const counterStore = createSlice({
// 模块名称独一无二
name: 'counter',
// 初始数据
initialState: {
count: 0
},
// 修改数据的同步方法
reducers: {
increment (state) {
state.count++
},
decrement(state){
state.count--
}
}
})

// 结构出actionCreater
const { increment,decrement } = counterStore.actions

// 获取reducer函数
const counterReducer = counterStore.reducer

// 导出
// 以按需导出的方式,导出 actionCreater
export { increment, decrement }
// 以默认导出的方式,导出 reducer
export default counterReducer

编辑上文创建的index.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { configureStore } from '@reduxjs/toolkit'

// 导入子模块 counterReducer
import counterReducer from './modules/counterStore'

const store = configureStore({
reducer: {
// 注册子模块
counter: counterReducer
}
})

export default store

为React注入store

react-redux负责把ReduxReact链接起来,内置Provider组件,通过store参数把创建好的store实例注入到应用中,建立链接。

修改src目录下的index.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// 导入store
import store from './store'
// 导入store提供组件Provider
import { Provider } from 'react-redux'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
// 提供store数据
<Provider store={store}>
<App />
</Provider>
);

React组件使用store中的数据

在React组件中使用store中的数据,需要用到一个钩子函数:useSelector

钩子函数useSelector的作用是把store中的数据映射到组件中。

示例代码:

1
2
3
4
5
6
7
8
9
10
import { useSelector } from "react-redux";

function App() {
const { count } = useSelector(state => state.counter)
return (
<div> { count } </div>
);
}

export default App;

解释说明:const { count } = useSelector(state => state.counter)state.countercounter,对应如下代码reducer的Key,counter

1
2
3
4
5
6
const store =  configureStore({
reducer: {
// 注册子模块
counter: counterReducer
}
})

React组件使用store中的数据

React组件修改store中的数据

在React组件中修改store中的数据需要借助另外一个hook函数,useDispatch,其作用是生成提交action对象的dispatch函数。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useDispatch, useSelector } from 'react-redux'
// 导入创建action对象的方法
import { decrement, increment } from './store/modules/counterStore'

function App() {
const { count } = useSelector(state => state.counter)
const dispatch = useDispatch()
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{ count }</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
)
}

export default App;

提交action传参

需求

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

实现

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

counterStore.js,内容如下:

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

const counterStore = createSlice({
// 模块名称独一无二
name: 'counter',
// 初始数据
initialState: {
count: 0
},
// 修改数据的同步方法
reducers: {
increment (state) {
state.count++
},
decrement(state){
state.count--
},
toNum(state, action){
state.count = action.payload
}
}
})

// 结构出actionCreater
const { increment,decrement,toNum } = counterStore.actions

// 获取reducer函数
const counterReducer = counterStore.reducer

// 导出
// 以按需导出的方式,导出 actionCreater
export { increment, decrement, toNum }
// 以默认导出的方式,导出 reducer
export default counterReducer

App.js,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useDispatch, useSelector } from 'react-redux'
// 导入创建action对象的方法
import { decrement, increment, toNum } from './store/modules/counterStore'

function App() {
const { count } = useSelector(state => state.counter)
const dispatch = useDispatch()
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{ count }</span>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(toNum(10))}>to 10</button>
<button onClick={() => dispatch(toNum(20))}>to 20</button>
</div>
)
}

export default App;

异步action处理

步骤

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

实现

对于第一步,没有变化。channelStore.js内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { createSlice } from '@reduxjs/toolkit'

const counterStore = createSlice({
// 模块名称独一无二
name: 'channel',
// 初始数据
initialState: {
channelList: []
},
// 修改数据的同步方法
reducers: {
setChannelList(state, action){
state.channelList = action.payload
}
}
})

第二步,在channelStore.js中新增如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

const { setChannelList } = channelStore.actions

const fetchChannelList = () => {
return async (dispatch) => {
const res = await axios.get('XXXX')
dispatch(setChannelList(res.data.data.channels))
}
}


export { fetchChannelList }

const channelReducer = channelStore.reducer
export default channelReducer
  • XXX链接的返回如下:
    1
    {"data":{"channels":[{"id":0,"name":"推荐"},{"id":1,"name":"html"},{"id":2,"name":"开发者资讯"},{"id":4,"name":"c++"},{"id":6,"name":"css"},{"id":7,"name":"数据库"},{"id":8,"name":"区块链"},{"id":9,"name":"go"},{"id":10,"name":"产品"},{"id":11,"name":"后端"},{"id":12,"name":"linux"},{"id":13,"name":"人工智能"},{"id":14,"name":"php"},{"id":15,"name":"javascript"},{"id":16,"name":"架构"},{"id":17,"name":"前端"},{"id":18,"name":"python"},{"id":19,"name":"java"},{"id":20,"name":"算法"},{"id":21,"name":"面试"},{"id":22,"name":"科技动态"},{"id":23,"name":"js"},{"id":24,"name":"设计"},{"id":25,"name":"数码产品"},{"id":26,"name":"软件测试"}]},"message":"OK"}

第三步,也几乎没有变化。示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useDispatch, useSelector } from 'react-redux'
import { fetchChannelList } from './store/modules/channelStore'
import { useEffect } from 'react'

function App() {
const { channelList } = useSelector(state => state.channel)

const dispatch = useDispatch()

useEffect(() => {
dispatch(fetchChannelList())
},[dispatch])

return (
<div>
<ul>
{ channelList.map(item => <li key={item.id}>{item.name}</li>) }
</ul>
</div>
)
}

export default App

完整代码

channelStore.js

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


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

const { setChannelList } = channelStore.actions

const fetchChannelList = () => {
return async (dispatch) => {
const res = await axios.get('XXX')
dispatch(setChannelList(res.data.data.channels))
}
}


export { fetchChannelList }

const channelReducer = channelStore.reducer
export default channelReducer

index.js

1
2
3
4
5
6
7
8
9
10
11
import { configureStore } from '@reduxjs/toolkit'

import channelReducer from './modules/channelStore'

const store = configureStore({
reducer: {
channel: channelReducer
}
})

export default store

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useDispatch, useSelector } from 'react-redux'
import { fetchChannelList } from './store/modules/channelStore'
import { useEffect } from 'react'

function App() {
const { channelList } = useSelector(state => state.channel)

const dispatch = useDispatch()

useEffect(() => {
dispatch(fetchChannelList())
},[dispatch])

return (
<div>
<ul>
{ channelList.map(item => <li key={item.id}>{item.name}</li>) }
</ul>
</div>
)
}

export default App

调试工具:Redux DevTools

Redux DevTools,一个Chrome浏览器的插件。由Redux官方提供了针对于Redux的调试工具,支持实时state信息展示,action提交信息查看等。

Redux-devtools

安装完成后,在开发者工具中,会看到Redux的tab页。

案例一

背景

案例一 外卖

假设存在一个外卖项目,但是该项目之后静态页面,没有任何交互功能,我们要在其基础上添加各种交互功能。

redux-meituan.zip提供 下载链接

其目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
+--server
| +--data.json
+--README.md
+--public
| +--favicon.ico
| +--index.html
| +--logo512.png
| +--manifest.json
| +--robots.txt
| +--logo192.png
+--package-lock.json
+--package.json
+--src
| +--index.js
| +--components
| | +--NavBar
| | | +--index.scss
| | | +--index.js
| | +--FoodsCategory
| | | +--index.scss
| | | +--index.js
| | | +--FoodItem
| | | | +--index.scss
| | | | +--index.js
| | +--Count
| | | +--index.scss
| | | +--index.js
| | +--Menu
| | | +--index.scss
| | | +--index.js
| | +--Cart
| | | +--index.scss
| | | +--index.js
| +--App.js
| +--App.scss
| +--store
| | +--index.js
| | +--modules
| | | +--takeaway.js

下载完成后,解压,然后:

  1. 安装所有依赖
    1
    npm i
  2. 启动mock服务(内置了json-server)
    1
    npm run serve
  3. 启动前端服务
    1
    npm run start

分类和商品列表渲染

分类和商品列表渲染

编写store逻辑

编辑takeaway.js,和我们上文的"异步action处理",几乎没有区别。

示例代码:

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

const foodsStore = createSlice({
name: 'foods',
initialState: {
// 商品列表
foodsList: []
},
reducers: {
// 更改商品列表
setFoodsList (state, action) {
state.foodsList = action.payload
}
}
})

// 异步获取部分
const { setFoodsList } = foodsStore.actions
const fetchFoodsList = () => {
return async (dispatch) => {
// 编写异步逻辑
const res = await axios.get('http://localhost:3004/takeaway')
// 调用dispatch函数提交action
dispatch(setFoodsList(res.data))
}
}

export { fetchFoodsList }

const reducer = foodsStore.reducer

export default reducer

编辑store目录下的index.js,示例代码:

1
2
3
4
5
6
7
8
9
10
11

import foodsReducer from './modules/takeaway'
import { configureStore } from '@reduxjs/toolkit'

const store = configureStore({
reducer: {
foods: foodsReducer
}
})

export default store

修改src目录下的index.js,注入。示例代码:

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

import App from './App'
// 注入store

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

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

组件使用store数据

修改APP.jsAPP.js中,之前以const foodsList = [XXX]的方式定义了foodsList,现在我们要将其改为触发action执行。
四个关键点:

  1. useDispatch -> dispatch
    1
    const dispatch = useDispatch()
  2. actionCreater 导入
    1
    import { fetchFoodsList } from './store/modules/takeaway'
  3. useEffect
    1
    2
    3
    useEffect(() => {
    dispatch(fetchFoodsList())
    }, [dispatch])
  4. 获取foodsList渲染数据列表,基于useSelector
    1
    const { foodsList } = useSelector(state => state.foods)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 省略部分代码
import { useDispatch, useSelector } from 'react-redux'
import { fetchFoodsList } from './store/modules/takeaway'
import { useEffect } from 'react'

const App = () => {
// 触发action执行
const dispatch = useDispatch()
useEffect(() => {
dispatch(fetchFoodsList())
}, [dispatch])

return (
<div className="home">
{/* 导航 */}
<NavBar />

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

export default App

点击分类激活交互

点击分类激活交互

编写store逻辑

新增一个状态"菜单激活下标值"。

示例代码:

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

const foodsStore = createSlice({
name: 'foods',
initialState: {
// 商品列表
foodsList: [],
// 菜单激活下标值
activeIndex: 0
},
reducers: {
// 更改商品列表
setFoodsList (state, action) {
state.foodsList = action.payload
},
// 更改activeIndex
changeActiveIndex (state, action) {
state.activeIndex = action.payload
}
}
})

// 异步获取部分
const { setFoodsList, changeActiveIndex } = foodsStore.actions
const fetchFoodsList = () => {
return async (dispatch) => {
// 编写异步逻辑
const res = await axios.get('http://localhost:3004/takeaway')
// 调用dispatch函数提交action
dispatch(setFoodsList(res.data))
}
}

export { fetchFoodsList, changeActiveIndex }

const reducer = foodsStore.reducer

export default reducer

编写组件逻辑

主要操作:

  1. 提交action切换激活index
  2. 动态控制active显示

修改components/Menu/index.js

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import classNames from 'classnames'
import './index.scss'
import { useDispatch, useSelector } from 'react-redux'
import { changeActiveIndex } from '../../store/modules/takeaway'

const Menu = () => {
const { foodsList, activeIndex } = useSelector(state => state.foods)
const dispatch = useDispatch()
const menus = foodsList.map(item => ({ tag: item.tag, name: item.name }))
return (
<nav className="list-menu">
{/* 添加active类名会变成激活状态 */}
{menus.map((item, index) => {
return (
<div
onClick={() => dispatch(changeActiveIndex(index))}
key={item.tag}
className={classNames(
'list-menu-item',
activeIndex === index && 'active'
)}
>
{item.name}
</div>
)
})}
</nav>
)
}

export default Menu

商品列表切换显示

商品列表切换显示

思路:将activeIndex作为判断条件,控制右侧的显示。

修改APP.js,关键代码如下:

1
const { foodsList, activeIndex } = useSelector(state => state.foods)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div className="goods-list">
{/* 外卖商品列表 */}
{foodsList.map((item, index) => {
return (
activeIndex === index && <FoodsCategory
key={item.tag}
// 列表标题
name={item.name}
// 列表商品
foods={item.foods}
/>
)
})}
</div>

添加购物车

添加购物车

编写store逻辑

关键步骤:

  1. initialState中新增购物车相关变量cartList
  2. reducers新增"添加购物车"相关方法addCart
    如果添加过,就数量加一;如果,没有添加过,则进行PUSH。
  3. 导出相关方法。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 编写store
import { createSlice } from "@reduxjs/toolkit"
import axios from "axios"

const foodsStore = createSlice({
name: 'foods',
initialState: {
// 商品列表
foodsList: [],
// 菜单激活下标值
activeIndex: 0,
// 购物车
cartList: []
},
reducers: {
// 更改商品列表
setFoodsList (state, action) {
state.foodsList = action.payload
},
// 更改activeIndex
changeActiveIndex (state, action) {
state.activeIndex = action.payload
},
// 添加购物车
addCart (state, action) {
// 是否添加过?以action.payload.id去cartList中匹配 匹配到了 添加过
const item = state.cartList.find(item => item.id === action.payload.id)
if (item) {
item.count++
} else {
state.cartList.push(action.payload)
}
}
},
})

// 异步获取部分
const { setFoodsList, changeActiveIndex, addCart } = foodsStore.actions
const fetchFoodsList = () => {
return async (dispatch) => {
// 编写异步逻辑
const res = await axios.get('http://localhost:3004/takeaway')
// 调用dispatch函数提交action
dispatch(setFoodsList(res.data))
}
}

export { fetchFoodsList, changeActiveIndex, addCart }

const reducer = foodsStore.reducer

export default reducer

编写组件逻辑

组件中点击时收集数据提交action添加购物车。

1
2
import { useDispatch } from 'react-redux'
import { addCart } from '../../../store/modules/takeaway'
1
const dispatch = useDispatch()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div className="goods-count">
<span className="plus" onClick={() => dispatch(addCart({
id,
picture,
name,
unit,
description,
food_tag_list,
month_saled,
like_ratio_desc,
price,
tag,
count
}))}></span>
</div>

统计区域

统计区域

  1. 基于store中的cartList的length渲染数量。
  2. 基于store中的cartList累加price * count。
  3. 购物车cartList的length不为零则高亮。

修改components/Cart/index.js

示例代码:

1
2
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
1
2
3
4
5
6
// 获取 cartList
const { cartList } = useSelector(state => state.foods)
// 计算总价
const totalPrice = cartList.reduce((a, c) => a + c.price * c.count, 0)

const dispatch = useDispatch()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{/* fill 添加fill类名可以切换购物车状态*/}
{/* 购物车数量 */}
<div className={classNames('icon', cartList.length > 0 && 'fill')}>
{cartList.length && <div className="cartCornerMark">{ cartList.length }</div>}
</div>
{/* 购物车价格 */}
<div className="main">
<div className="price">
<span className="payableAmount">
<span className="payableAmountUnit">¥</span>
{ totalPrice }
</span>
</div>
<span className="text">预估另需配送费 ¥5</span>
</div>
{/* 结算 or 起送 */}
{cartList.length > 0 ? (
<div className="goToPreview">去结算</div>
) : (
<div className="minFee">¥20起送</div>
)}

购物车列表

购物车列表

增减以及清除逻辑

takeaway.js中新增三个方法increCountdecreCountclearCart,并导出。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// count增
increCount (state, action) {
// 关键点:找到当前要修改谁的count id
const item = state.cartList.find(item => item.id === action.payload.id)
item.count++
},
// count减
decreCount (state, action) {
// 关键点:找到当前要修改谁的count id
const item = state.cartList.find(item => item.id === action.payload.id)
if (item.count === 0) {
return
}
item.count--
},
// 清空购物车
clearCart (state) {
state.cartList = []
}

控制列表渲染

修改components/Cart/index.js,主要内容如下:

1
import { decreCount, increCount, clearCart } from '../../store/modules/takeaway'
1
2
3
<span className="clearCart" onClick={() => dispatch(clearCart())}>>
清空购物车
</span>
1
2
3
4
5
6
7
<div className="skuBtnWrapper btnGroup">
<Count
count={item.count}
onPlus={() => dispatch(increCount({ id: item.id }))}
onMinus={() => dispatch(decreCount({ id: item.id }))}
/>
</div>

购物车显示和隐藏

控制购物车显示和隐藏

只需要使用useState进行状态管理。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 控制购物车打开关闭的状态
const [visible, setVisible] = useState(false)

const onShow = () => {
if (cartList.length > 0) {
setVisible(true)
}
}

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

// 给购物车DIV新增`onClick`事件
<div onClick={onShow} className={classNames('icon', cartList.length > 0 && 'fill')}>
{cartList.length && <div className="cartCornerMark">{ cartList.length }</div>}
</div>

完整版本

完整版本redux-meituan-finish.zip提供 下载链接

路由

什么是前端路由

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

要实现前端路由,需要依赖一个包:react-router-dom

安装命令:

1
npm i react-router-dom

快速体验

创建路由开发环境,示例代码:

1
2
3
4
5
6
7
8
9
10
# 使用CRA创建项目
npx create-react-app react-router

cd react-router

# 安装最新的ReactRouter包
npm i react-router-dom

# 启动项目
npm run start

创建一个可以切换登录页和文章页的路由系统。示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider} from 'react-router-dom'

// 创建root实例对象,并配置路由关系。
const router = createBrowserRouter([
{
path:'/login',
element: <div>登录</div>
},
{
path:'/article',
element: <div>文章</div>
}
])

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

如图所示,我们访问不同的路径,会得到不同的页面。

入门案例

抽象路由模块

在实际工作中,登录页和文章页等,不会是div标签,而通常是独立的文件。

其目录结构通常如下:
抽象路由模块

page/Article/index.js的内容如下:

1
2
3
4
5
const Article = () => {
return <div>文章页</div>
}

export default Article

page/Login/index.js的内容如下:

1
2
3
4
5
const Login = () => {
return <div>登录页</div>
}

export default Login

router/index.js的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Login from "../page/Login";

import Article from "../page/Article";

import { createBrowserRouter } from "react-router-dom";

// 创建root实例对象,并配置路由关系。
const router = createBrowserRouter([
{
path:'/login',
element: <Login />
},
{
path:'/article',
element: <Article />
}
])

export default router

index.js的内容如下:

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

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

路由导航

什么是路由导航

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

声明式导航

声明式导航是指通过在模版中通过内置的组件<Link/>描述出要跳转到哪里去。

示例代码:

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

const Login = () => {
return (
<div>
登录页
<Link to="/article">跳转到文章页</Link>
</div>
)
}

export default Login

运行结果:

声明式导航

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

编程式导航

编程式导航是指通过useNavigate钩子得到导航方法,然后通过调用方法以命令式的形式进行路由跳转。

比如想在登录请求完毕之后跳转到首页就可以选择这种方式。

示例代码:

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

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

export default Login

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

导航传参

searchParams传参

跳转页:

1
navigate('/article?id=1001&name=jack')

目标页:

1
2
const [params] = useSearchParams()
let id = params.get('id')

params传参

跳转页:

1
navigate('/article/1001')

目标页:

1
2
const params = useParams()
let id = params.id

路由关系声明:

1
2
3
4
5
6
7
8
9
10
11
// 创建root实例对象,并配置路由关系。
const router = createBrowserRouter([
{
path:'/login',
element: <Login />
},
{
path:'/article/:id',
element: <Article />
}
])
  • 注意 path:'/article/:id',声明id

嵌套路由

什么是嵌套路由

在一级路由中又内嵌了其他路由,这种关系就叫做嵌套路由,嵌套至一级路由内的路由又称作二级路由。

例如:

嵌套路由

嵌套路由实现

步骤

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

嵌套路由实现步骤

代码

目录结构如下:

嵌套路由目录结构

page/About/index.js

1
2
3
4
5
const About = () => {
return <div>About Page</div>
}

export default About

page/Board/index.js

1
2
3
4
5
const Board = () => {
return <div>Board Page</div>
}

export default Board

page/Layout/index.js

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

const Layout = () => {
return (
<div>
Layout Page
<Link to="/board">面板</Link>
<Link to="/about">关于</Link>
{/* 配置二级路由出口 */}
<Outlet />
</div>
)
}

export default Layout

router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createBrowserRouter } from "react-router-dom";
import Layout from "../page/Layout";
import Board from "../page/Board";
import About from "../page/About";

// 创建root实例对象,并配置路由关系。
const router = createBrowserRouter([
{
path:'/',
element: <Layout />,
children: [
{
path:'board',
element: <Board />
},
{
path:'about',
element: <About />
}
]
}
])

export default router

运行结果:
嵌套路由运行结果

默认二级路由

什么是默认二级路由

例如,现在我们希望面板页面默认会被渲染。

这就是默认二级路由,当访问的是一级路由时,默认的二级路由组件也会被渲染。

方法

在实现上,只需要在二级路由的位置去掉path,设置index属性为true

实践

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createBrowserRouter } from "react-router-dom";
import Layout from "../page/Layout";
import Board from "../page/Board";
import About from "../page/About";

// 创建root实例对象,并配置路由关系。
const router = createBrowserRouter([
{
path:'/',
element: <Layout />,
children: [
{
index:true,
element: <Board />
},
{
path:'about',
element: <About />
}
]
}
])

export default router

运行结果:

默认渲染board页面。

默认渲染board页面

注意

我们再点击一下面板,会有如下错误:

1
2
Unexpected Application Error!
404 Not Found

这是因为现在board页面的路径不是/board,而是/,修改page/Layout/index.js,内容如下:

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

const Layout = () => {
return (
<div>
Layout Page
<Link to="/">面板</Link>
<Link to="/about">关于</Link>
{/* 配置二级路由出口 */}
<Outlet />
</div>
)
}

export default Layout

解释说明:将<Link to="/board">面板</Link>修改为<Link to="/">面板</Link>

404路由配置

步骤:

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

404路由配置

两种路由模式

各个主流框架的路由常用的路由模式有两种:

  1. history模式
    在ReactRouter由createBrowerRouter创建。
  2. hash模式
    在ReactRouter由createHashRouter创建。
路由模式 url表现 底层原理 是否需要后端支持
history url/login history对象 + pushState事件 需要
hash url/#/login 监听hashChange事件 不需要

上文我们用的都是createBrowerRouter,在这里我们切换为createHashRouter

只需要将createBrowerRouter换成createHashRouter,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createHashRouter } from "react-router-dom";
import Layout from "../page/Layout";
import Board from "../page/Board";
import About from "../page/About";

// 创建root实例对象,并配置路由关系。
const router = createHashRouter([
{
path:'/',
element: <Layout />,
children: [
{
index:true,
element: <Board />
},
{
path:'about',
element: <About />
}
]
}
])

export default router

运行结果:

createHashRouter

我们会看到,URL风格也变了。

案例二

环境搭建

基于CRA创建项目,并安装必要依赖:

  • Redux状态管理:@reduxjs/toolkitreact-redux
  • 路由:react-router-dom
  • 时间处理:dayjs
  • class类名处理:classnames
  • 移动端组件库:antd-mobile
  • 请求插件:axios

配置别名路径

概述

  1. 路径解析配置
    针对webpack,把@/解析为src/
    基于carco插件
  2. 路径联想配置
    针对VSCode,在VSCode中输入@/时,自动联想出来对应的src/下的子级目录。
    基于jsconfig.json

路径解析配置

步骤:

  1. 安装craco
    1
    npm i -D @craco/craco
  2. 项目根目录下创建配置文件craco.config.js
    必须在项目根目录下
    名称必须是craco.config.js
  3. 配置文件中添加路径解析配置:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    const path = require('path')

    module.exports= {
    webpack: {
    alias: {
    '@': path.resolve(__dirname,'src')
    }
    }
    }
  4. 包文件中配置启动和打包命令
    修改package.json中的scripts中的内容,修改为:
    1
    2
    3
    4
    5
    6
    "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
    },

联想路径配置

步骤:

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

联想路径配置

整体路由设计

整体路由设计

  1. 两个一级路由
    1. Layout
    2. New
  2. 两个二级路由
    1. Layout - Month
    2. Layout - Year
1
2
3
4
5
6
7
8
9
10
11
12
13
+--index.js
+--pages
| +--Month
| | +--index.js
| +--Layout
| | +--index.js
| +--Year
| | +--index.js
| +--New
| | +--index.js
+--App.js
+--router
| +--index.js

pages/Month/index.js

1
2
3
4
5
const Month = () => {
return <div>Month</div>
}

export default Month

pages/Layout/index.js

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

const Layout = () => {

return (<div>
<Outlet></Outlet>
<div>Layout</div>
</div>)
}

export default Layout

pages/Year/index.js

1
2
3
4
5
const Year = () => {
return <div>Year</div>
}

export default Year

pages/New/index.js

1
2
3
4
5
const New = () => {
return <div>New</div>
}

export default New

router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 创建路由实例 绑定path element

import Layout from "@/pages/Layout";
import Month from "@/pages/Month";
import New from "@/pages/New";
import Year from "@/pages/Year";
import { createBrowserRouter } from "react-router-dom";

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

export default router

index.js

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

import router from './router';

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

</RouterProvider>
);

管理账目列表(Redux)

store/modules/billStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 账单列表相关store

import { createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const billStore = createSlice({
name: 'bill',
// 数据状态state
initialState: {
billList: []
},
reducers: {
// 同步修改方法
setBillList (state, action) {
state.billList = action.payload
}
}
})

// 解构actionCreater函数
const { setBillList } = billStore.actions
// 编写异步
const getBillList = () => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.get('http://localhost:3004/ka')
// 触发同步reducer
dispatch(setBillList(res.data))
}
}

export { getBillList }
// 导出reducer
const reducer = billStore.reducer

export default reducer

store/index.js

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

import { configureStore } from '@reduxjs/toolkit'
import billReducer from './modules/billStore'

const store = configureStore({
reducer: {
bill: billReducer
}
})

export default store

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Provider } from 'react-redux'

import store from './store';
import router from './router';

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

pages/Layout/index.js

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

const Layout = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(getBillList())
},[dispatch])
return (<div>
<Outlet></Outlet>
<div>Layout</div>
</div>)
}

export default Layout

TabBar功能

TabBar功能

实现要点:

  • 使用antD的TabBar标签栏组件进行布局以及路由的切换。
  • 监听change事件,在事件回调中调用路由跳转方法。

pages/Layout/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { getBillList } from "@/store/modules/billStore"
import { useEffect } from "react"
import { useDispatch } from "react-redux"
import { Outlet, useNavigate } from "react-router-dom"
import { TabBar } from "antd-mobile"
import './index.scss'

import {
BillOutline,
CalculatorOutline,
AddCircleOutline
} from 'antd-mobile-icons'

const tabs = [
{
key: '/month',
title: '月度账单',
icon: <BillOutline />,
},
{
key: '/new',
title: '记账',
icon: <AddCircleOutline />,
},
{
key: '/year',
title: '年度账单',
icon: <CalculatorOutline />,
},
]

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

export default Layout

pages/Layout/index.scss

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

月度账单-统计区域

准备静态结构

pages/Month/index.js

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

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

export default Month

pages/Month/index.scss

1
.monthlyBill{--ka-text-color:#191d26;height:100%;background:linear-gradient(180deg,#ffffff,#f5f5f5 100%);background-size:100% 240px;background-repeat:no-repeat;background-color:rgba(245,245,245,0.9);color:var(--ka-text-color);.nav{--adm-font-size-10:16px;color:#121826;background-color:transparent;.adm-nav-bar-back-arrow{font-size:20px;}}.content{height:573px;padding:0 10px;overflow-y:scroll;-ms-overflow-style:none;scrollbar-width:none;&::-webkit-scrollbar{display:none;}> .header{height:135px;padding:20px 20px 0px 18.5px;margin-bottom:10px;background-image:url(https://kakawanyifan.com/-/1/20/06/react-bill.jpg);background-size:100% 100%;.date{display:flex;align-items:center;margin-bottom:25px;font-size:16px;.arrow{display:inline-block;width:7px;height:7px;margin-top:-3px;margin-left:9px;border-top:2px solid #121826;border-left:2px solid #121826;transform:rotate(225deg);transform-origin:center;transition:all 0.3s;}.arrow.expand{transform:translate(0,2px) rotate(45deg);}}}}.twoLineOverview{display:flex;justify-content:space-between;width:250px;.item{display:flex;flex-direction:column;.money{height:24px;line-height:24px;margin-bottom:5px;font-size:18px;}.type{height:14px;line-height:14px;font-size:12px;}}}}

点击切换时间选择框

需求:

  1. 点击打开时间选择弹框
  2. 点击取消/确认按钮以及蒙层区域都可以关闭弹框
  3. 弹框关闭时箭头朝下,打开是箭头朝上

思路:

  1. 准备一个状态数据
  2. 点击切换状态
  3. 根据状态控制弹框打开关闭以及箭头样式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import {  useState } from 'react'
import classNames from 'classnames'

// 控制时间选择器打开关闭
const [dateVisible, setDateVisible] = useState(false)
// 时间选择框确实事件
const dateConfirm = (date) => {
// 关闭弹框
setDateVisible(false)
}

{/* 时间切换区域 */}
<div className="date" onClick={() => setDateVisible(true)}>
【部分代码略】
<span className={classNames('arrow', dateVisible && 'expand')}></span>
</div>

{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={dateVisible}
onCancel={() => setDateVisible(false)}
onConfirm={dateConfirm}
onClose={() => setDateVisible(false)}
max={new Date()}
/>

切换时间显示

思路:

  1. 以当前时间作为默认值
  2. 在时间切换时完成时间修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import dayjs from "dayjs"

const [currentMonth, setCurrentMonth] = useState(() => {
return dayjs().format('YYYY-MM')
})

const dateConfirm = (date) => {
// 关闭弹框
setDateVisible(false)
const month = dayjs(date).format('YYYY-MM')
setCurrentMonth(month)
}

{/* 时间切换区域 */}
<div className="date" onClick={() => setDateVisible(true)}>
<span className="text">
{currentMonth} 月账单
</span>
<span className={classNames('arrow', dateVisible && 'expand')}></span>
</div>

统计功能实现

思路:

  1. 按月分组
  2. 根据获取到的时间作为key取当月的账单数组
  3. 根据当月的账单数组计算支出、收入、总计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 按月分组
const billList = useSelector(state => state.bill.billList)

const monthGroup = useMemo(() => {
// return出去计算之后的值
return _.groupBy(billList, (item) => dayjs(item.date).format('YYYY-MM'))
}, [billList])

// 时间选择框确实事件
const dateConfirm = (date) => {
// 关闭弹框
setDateVisible(false)
const month = dayjs(date).format('YYYY-MM')
const monthKey = dayjs(date).format('YYYY-MM')
setMonthList(monthGroup[monthKey])
setCurrentMonth(month)
}


const [currentMonthList, setMonthList] = useState([])

// 计算统计
const monthResult = useMemo(() => {
// 支出 / 收入 / 结余
if (currentMonthList === undefined){
return {'pay':0,'income':0,'total':0}
}
const pay = currentMonthList.filter(item => item.type === 'pay').reduce((a, c) => a + c.money, 0)
const income = currentMonthList.filter(item => item.type === 'income').reduce((a, c) => a + c.money, 0)
return {
pay,
income,
total: (pay + income)
}
}, [currentMonthList])

// 初始化的时候把当前月的统计数据显示出来
useEffect(() => {
const nowDate = dayjs().format('YYYY-MM')
// 边界值控制
if (monthGroup[nowDate]) {
setMonthList(monthGroup[nowDate])
}
}, [monthGroup])

{/* 统计区域 */}
<div className='twoLineOverview'>
<div className="item">
<span className="money">{monthResult.pay.toFixed(2)}</span>
<span className="type">支出</span>
</div>
<div className="item">
<span className="money">{monthResult.income.toFixed(2)}</span>
<span className="type">收入</span>
</div>
<div className="item">
<span className="money">{monthResult.total.toFixed(2)}</span>
<span className="type">结余</span>
</div>
</div>

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

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

准备组件和配套样式

pages/Month/components/DayBill/index.js

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

const DailyBill = () => {
return (
<div className={classNames('dailyBill')}>
<div className="header">
<div className="dateIcon">
<span className="date">{'03月23日'}</span>
<span className={classNames('arrow')}></span>
</div>
<div className="oneLineOverview">
<div className="pay">
<span className="type">支出</span>
<span className="money">{100}</span>
</div>
<div className="income">
<span className="type">收入</span>
<span className="money">{200}</span>
</div>
<div className="balance">
<span className="money">{100}</span>
<span className="type">结余</span>
</div>
</div>
</div>
</div>
)
}
export default DailyBill

pages/Month/components/DayBill/index.css

1
.dailyBill{margin-bottom:10px;border-radius:10px;background:#ffffff;.header{--ka-text-color:#888c98;padding:15px 15px 10px 15px;.dateIcon{display:flex;justify-content:space-between;align-items:center;height:21px;margin-bottom:9px;.arrow{display:inline-block;width:5px;height:5px;margin-top:-3px;margin-left:9px;border-top:2px solid #888c98;border-left:2px solid #888c98;transform:rotate(225deg);transform-origin:center;transition:all 0.3s;}.arrow.expand{transform:translate(0,2px) rotate(45deg);}.date{font-size:14px;}}}.oneLineOverview{display:flex;justify-content:space-between;.pay{flex:1;.type{font-size:10px;margin-right:2.5px;color:#e56a77;}.money{color:var(--ka-text-color);font-size:13px;}}.income{flex:1;.type{font-size:10px;margin-right:2.5px;color:#4f827c;}.money{color:var(--ka-text-color);font-size:13px;}}.balance{flex:1;margin-bottom:5px;text-align:right;.money{line-height:17px;margin-right:6px;font-size:17px;}.type{font-size:10px;color:var(--ka-text-color);}}}.billList{padding:15px 10px 15px 15px;border-top:1px solid #ececec;.bill{display:flex;justify-content:space-between;align-items:center;height:43px;margin-bottom:15px;&:last-child{margin-bottom:0;}.icon{margin-right:10px;font-size:25px;}.detail{flex:1;padding:4px 0;.billType{display:flex;align-items:center;height:17px;line-height:17px;font-size:14px;padding-left:4px;}}.money{font-size:17px;&.pay{color:#ff917b;}&.income{color:#4f827c;}}}}}.dailyBill.expand{.header{border-bottom:1px solid #ececec;}.billList{display:block;}}

pages/Month/index.js新增代码:

1
2
{/* 单日列表 */}
<DailyBill />

按日分组账单数据

1
2
3
4
5
6
7
const dayGroup = useMemo(() => {
const group = _.groupBy(currentMonthList, (item) => dayjs(item.date).format('YYYY-MM-DD'))
return {
dayKeys: Object.keys(group),
group
}
}, [currentMonthList])

遍历日账单组件并传入参数

1
2
3
4
{/* 单日列表 */}
{dayGroup.dayKeys.map(dayKey => (
<DailyBill key={dayKey} date={dayKey} billList={dayGroup.group[dayKey]} />
))}

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

pages/Month/components/DayBill/index.js

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

const DailyBill = ({ date, billList }) => {
const dayResult = useMemo(() => {
// 支出 / 收入 / 结余
const pay = billList.filter(item => item.type === 'pay').reduce((a, c) => a + c.money, 0)
const income = billList.filter(item => item.type === 'income').reduce((a, c) => a + c.money, 0)
return {
pay,
income,
total: pay + income
}
}, [billList])
return (
<div className={classNames('dailyBill')}>
<div className="header">
<div className="dateIcon">
<span className="date">{date}</span>
</div>
<div className="oneLineOverview">
<div className="pay">
<span className="type">支出</span>
<span className="money">{dayResult.pay.toFixed(2)}</span>
</div>
<div className="income">
<span className="type">收入</span>
<span className="money">{dayResult.income.toFixed(2)}</span>
</div>
<div className="balance">
<span className="money">{dayResult.total.toFixed(2)}</span>
<span className="type">结余</span>
</div>
</div>
</div>
</div>
)
}

export default DailyBill

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

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

渲染基础列表

pages/Month/components/DayBill/index.js<div className="header">新增如下内容,新增的内容是其兄弟节点。

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

适配Type

准备静态数据

contants/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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
}, {})

适配type

1
2
3
import { billTypeToName } from '@/contants/index'

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

记账功能

结构渲染

pages/New/index.js

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

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

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

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

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

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

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

export default New

pages/New/index.scss

1
.keepAccounts{--ka-bg-color:#daf2e1;--ka-color:#69ae78;--ka-border-color:#191d26;height:100%;background-color:var(--ka-bg-color);.nav{--adm-font-size-10:16px;color:#121826;background-color:transparent;&::after{height:0;}.adm-nav-bar-back-arrow{font-size:20px;}}.header{height:132px;.kaType{padding:9px 0;text-align:center;.adm-button{--adm-font-size-9:13px;&:first-child{margin-right:10px;}}.selected{color:#fff;--background-color:var(--ka-border-color);}}.kaFormWrapper{padding:10px 22.5px 20px;.kaForm{display:flex;padding:11px 15px 11px 12px;border:0.5px solid var(--ka-border-color);border-radius:9px;background-color:#fff;.date{display:flex;align-items:center;height:28px;padding:5.5px 5px;border-radius:4px;// color:#4f825e;color:var(--ka-color);background-color:var(--ka-bg-color);.icon{margin-right:6px;font-size:17px;}.text{font-size:16px;}}.kaInput{flex:1;display:flex;align-items:center;.input{flex:1;margin-right:10px;--text-align:right;--font-size:24px;--color:var(--ka-color);--placeholder-color:#d1d1d1;}.iconYuan{font-size:24px;}}}}}.container{}.kaTypeList{height:490px;padding:20px 11px;padding-bottom:70px;overflow-y:scroll;background:#ffffff;border-radius:20px 20px 0 0;-ms-overflow-style:none;scrollbar-width:none;&::-webkit-scrollbar{display:none;}.kaType{margin-bottom:25px;font-size:12px;color:#333;.title{padding-left:5px;margin-bottom:5px;font-size:13px;color:#808080;}.list{display:flex;.item{width:65px;height:65px;padding:9px 0;margin-right:7px;text-align:center;border:0.5px solid #fff;&:last-child{margin-right:0;}.icon{height:25px;line-height:25px;margin-bottom:5px;font-size:25px;}}.item.selected{border:0.5px solid var(--ka-border-color);border-radius:5px;background:var(--ka-bg-color);}}}}.btns{position:fixed;bottom:15px;width:100%;text-align:center;.btn{width:200px;--border-width:0;--background-color:#fafafa;--text-color:#616161;&:first-child{margin-right:15px;}}.btn.save{--background-color:var(--ka-bg-color);--text-color:var(--ka-color);}}}

支出和收入切换

示例代码:

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

// 1. 区分账单状态
const [billType, setBillType] = useState('income')

{/* 2. 点击切换状态 */}
<Button
shape="rounded"
className={classNames(billType==='pay'?'selected':'')}
onClick={() => setBillType('pay')}
>
支出
</Button>
<Button
className={classNames(billType==='income'?'selected':'')}
onClick={() => setBillType('income')}
shape="rounded"
>
收入
</Button>

<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 type={item.type} />
</div>
<div className="text">{item.name}</div>
</div>
)
})}
</div>
</div>
)
})}
</div>

新增一笔

pages/New/index.js
示例代码:

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

const New = () => {
// 收集金额
const [money, setMoney] = useState(0)
const moneyChange = (value) => {
setMoney(value)
}

// 收集账单类型
const [useFor, setUseFor] = useState('')
const dispatch = useDispatch()
// 保存账单
const saveBill = () => {
// 收集表单数据
const data = {
type: billType,
money: billType === 'pay' ? -money : +money,
date: date,
useFor: useFor
}
console.log(data)
dispatch(addBillList(data))
}

const [dateVisible, setDateVisible] = useState(false)
const [date,setDate] = useState(new Date())


const dateConfirm = (value) => {
console.log(value)
setDate(value)
setDateVisible(false)
}


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

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

<div className="kaFormWrapper">
<div className="kaForm">
<div className="date">
<Icon type="calendar" className="icon" />
<span className="text" onClick={() => setDateVisible(true)}>{dayjs(date).format('YYYY-MM-DD')}</span>
<DatePicker
className="kaDate"
title="记账日期"
max={new Date()}
visible={dateVisible}
onCancel={() => setDateVisible(false)}
onClose={() => setDateVisible(false)}
onConfirm={ dateConfirm }
/>
</div>
<div className="kaInput">
<Input
className="input"
placeholder="0.00"
type="number"
value={money}
onChange={moneyChange}
/>
<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',
useFor === item.type ? 'selected' : ''
)}
key={item.type}
onClick={() => setUseFor(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" onClick={saveBill}>
保 存
</Button>
</div>
</div>
)
}

export default New

store/modules/billStore.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 账单列表相关store

import { createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const billStore = createSlice({
name: 'bill',
// 数据状态state
initialState: {
billList: []
},
reducers: {
// 同步修改方法
setBillList (state, action) {
state.billList = action.payload
},
// 同步添加账单方法
addBill (state, action) {
state.billList.push(action.payload)
}
}
})

// 解构actionCreater函数
const { setBillList, addBill } = billStore.actions
// 编写异步
const getBillList = () => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.get('http://localhost:3004/ka')
// 触发同步reducer
dispatch(setBillList(res.data))
}
}

const addBillList = (data) => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.post('http://localhost:3004/ka', data)
// 触发同步reducer
dispatch(addBill(res.data))
}
}

export { getBillList, addBillList }
// 导出reducer
const reducer = billStore.reducer

export default reducer

完整版本

完整版本react-bill.zip提供 下载链接

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/12006
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

留言板