Redux
简介
Redux,React开发中最常用的集中状态管理工具,也可以独立于框架运行。
如果没有Redux,我们需要处理复杂的组件通信。有了Redux,统一交由Redux完成。
优点
- 独立于组件,无视组件之间的层级关系,简化通信问题。
- 单项数据流清晰,易于定位BUG。
- 调试工具配套良好,方便调试。
快速体验
我们只使用Redux实现计数器,不和任何框架绑定,不使用任何构建工具。
步骤:
- 定义一个
reducer
函数。
根据当前想要做的修改返回一个新的状态。
- 使用
createStore
方法传入reducer
函数,生成一个store
实例对象。
- 使用
store
实例的subscribe
方法订阅数据的变化
数据一旦变化,可以得到通知。
- 使用
store
实例的dispatch
方法提交action
对象,触发数据变化。
告诉reducer
我们想怎么改数据。
- 使用
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> 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 } } 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文件,我们可以通过这个插件,快速部署。
右键,然后单击"Open with Five Server"即可。
数据流架构
下图展示了在整个数据的修改中,数据的流向。
Redux有三个核心的概念,分别是:
State
:对象,存放着我们管理的数据。
Action
:对象,用来描述我们想怎么改数据。
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
|
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
| 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
两个包
在React中使用redux,需要安装两个包:
- Redux Toolkit,(RTK),用来编写和Redux相关的逻辑。
- react-redux,用来链接Redux和React组件。
安装命令,示例代码:
1
| npm i @reduxjs/toolkit react-redux
|
store目录结构设计
在Redux开发中:
- 通常集中状态管理的部分都会单独创建一个单独的
store
目录。
- 通常会有很多个子
store
模块,所以创建一个modules
目录,在内部编写业务分类的子store
。
store
中的入口文件index.js
的作用是组合modules
中所有的子模块,并导出store
。
我们基于CRA
快速创建React项目,示例代码:
1
| npx create-react-app react-redux
|
然后我们cd
到react-redux
目录,再安装Redux Toolkit
和react-redux
,示例代码:
1
| npm i @reduxjs/toolkit react-redux
|
接下来,我们在src
目录下,创建store
目录;并在store
目录中创建index.js
和modules
目录;然后在modules
目录中创建counterStore.js
和channelStore.js
。
整体过程
以counter
计数器为例,整体过程如下:
store/modules/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-- } } })
const { increment,decrement } = counterStore.actions
const counterReducer = counterStore.reducer
export { increment, decrement }
export default counterReducer
|
store/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './modules/counterStore'
const store = configureStore({ reducer: { counter: counterReducer } })
export default store
|
为React注入store
react-redux
负责把Redux
和React
链接起来,内置Provider
组件,通过store
参数把创建好的store
实例注入到应用中,建立链接。
修改src
目录下的index.js
,添加<Provider store={store}>
:
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';
import store from './store'
import { Provider } from 'react-redux'
const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <Provider store={store}> <App /> </Provider> );
|
React组件使用store中的数据
在React组件中使用store
中的数据,需要依赖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.counter
的counter
,对应如下代码reducer
的Keycounter
。
1 2 3 4 5 6
| const store = configureStore({ reducer: { counter: counterReducer } })
|
React组件修改store中的数据
在React组件中修改store
中的数据需要依赖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'
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 10
和to 20
,直接把count
值修改到对应的数字,目标count
值是在组件中传递过去的,需要在提交action
的时候传递参数。
实现
在reducers
的同步修改方法中添加action
对象参数,在调用actionCreater
的时候传递参数,参数会被传递到action
对象payload
属性上。
store/modules/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 } } })
const { increment,decrement,toNum } = counterStore.actions
const counterReducer = counterStore.reducer
export { increment, decrement, toNum }
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'
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处理
步骤
- 创建store的写法保持不变,配置好同步修改状态的方法
- 单独封装一个函数,在函数内部return一个新函数,在新函数中,执行如下操作:
- 封装异步请求获取数据
- 调用同步actionCreater传入异步数据生成一个action对象,并使用dispatch提交
- 组件中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
|
第三步,也几乎没有变化。示例代码:
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
|
完整代码
store/modules/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
| 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
|
store/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,一个Chrome浏览器的插件。由Redux官方提供了针对于Redux的调试工具,支持实时state信息展示,action提交信息查看等。
安装完成后,在开发者工具中,会看到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
|
下载完成后,解压,然后:
- 安装所有依赖
- 启动mock服务(内置了json-server)
- 启动前端服务
分类和商品列表渲染
store逻辑
store/modules/takeaway.js
:
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| 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(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
|
index.js
,添加<Provider store={store}>
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React from 'react' import { createRoot } from 'react-dom/client'
import App from './App'
import { Provider } from 'react-redux' import store from './store'
const root = createRoot(document.getElementById('root')) root.render( <Provider store={store}> <App /> </Provider> )
|
组件逻辑
在APP.js
中,之前以const foodsList = [XXX]
的方式定义了foodsList
,现在我们要将其改为触发action执行。
关键点:
useDispatch
-> dispatch
1
| const dispatch = useDispatch()
|
- 导入
actionCreater
1
| import { fetchFoodsList } from './store/modules/takeaway'
|
useEffect
1 2 3
| useEffect(() => { dispatch(fetchFoodsList()) }, [dispatch])
|
- 获取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 = () => { 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逻辑
新增一个状态"菜单激活下标值"。
store/modules/takeaway.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| 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 }, 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(setFoodsList(res.data)) } }
export { fetchFoodsList, changeActiveIndex }
const reducer = foodsStore.reducer
export default reducer
|
组件逻辑
步骤:
- 提交
action
切换index
- 动态控制
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"> {} {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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const { foodsList, activeIndex } = useSelector(state => state.foods)
<div className="goods-list"> {} {foodsList.map((item, index) => { return ( activeIndex === index && <FoodsCategory key={item.tag} // 列表标题 name={item.name} // 列表商品 foods={item.foods} /> ) })} </div>
|
添加购物车
store逻辑
步骤:
- 在
initialState
中新增购物车相关变量cartList
。
- 在
reducers
新增"添加购物车"相关方法addCart
。
如果添加过,就数量加一;如果,没有添加过,则进行PUSH。
- 导出相关方法。
store/modules/takeaway.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| 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 }, changeActiveIndex (state, action) { state.activeIndex = action.payload }, addCart (state, action) { 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(setFoodsList(res.data)) } }
export { fetchFoodsList, changeActiveIndex, addCart }
const reducer = foodsStore.reducer
export default reducer
|
组件逻辑
组件中点击时收集数据提交action
添加购物车。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { useDispatch } from 'react-redux' import { addCart } from '../../../store/modules/takeaway'
const dispatch = useDispatch()
<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>
|
统计区域
步骤:
- 基于store中的cartList的length渲染数量。
- 基于store中的cartList累加price * count。
- 购物车cartList的length不为零则高亮。
components/Cart/index.js
:
1 2
| import { useState } from 'react' import { useDispatch, useSelector } from 'react-redux'
|
1 2 3 4 5 6
| 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
| {} {} <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> {} {cartList.length > 0 ? ( <div className="goToPreview">去结算</div> ) : ( <div className="minFee">¥20起送</div> )}
|
购物车列表
增减以及清除逻辑
在takeaway.js
中新增三个方法increCount
、decreCount
和clearCart
,并导出。
store/modules/takeaway.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| increCount (state, action) { const item = state.cartList.find(item => item.id === action.payload.id) item.count++ },
decreCount (state, action) { 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { decreCount, increCount, clearCart } from '../../store/modules/takeaway'
<span className="clearCart" onClick={() => dispatch(clearCart())}>> 清空购物车 </span>
<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) } }
{} <div className={ classNames('cartOverlay', visible && 'visible') } onClick={() => setVisible(false)} />
<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 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'
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";
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
| const router = createBrowserRouter([ { path:'/login', element: <Login /> }, { path:'/article/:id', element: <Article /> } ])
|
- 注意
path:'/article/:id'
,声明id
嵌套路由
什么是嵌套路由
在一级路由中又内嵌了其他路由,这种关系就叫做嵌套路由,嵌套至一级路由内的路由又称作二级路由。
例如:
嵌套路由实现
步骤
- 使用
children
属性配置路由嵌套关系。
- 使用
<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";
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";
const router = createBrowserRouter([ { path:'/', element: <Layout />, children: [ { index:true, element: <Board /> }, { path:'about', element: <About /> } ] } ])
export default router
|
运行结果:
我们看到默认渲染了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路由配置
步骤:
- 准备一个NotFound组件。
- 在路由表数组的末尾,以
*
号作为路由path配置路由。
注意是末尾!
两种路由模式
各个主流框架的路由常用的路由模式有两种:
- history模式
在ReactRouter由createBrowerRouter创建。
- 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";
const router = createHashRouter([ { path:'/', element: <Layout />, children: [ { index:true, element: <Board /> }, { path:'about', element: <About /> } ] } ])
export default router
|
运行结果:
我们会看到,URL风格也变了。
案例二
环境搭建
基于CRA创建项目,并安装必要依赖:
- Redux状态管理:
@reduxjs/toolkit
、react-redux
- 路由:
react-router-dom
- 时间处理:
dayjs
- class类名处理:
classnames
- 移动端组件库:
antd-mobile
- 请求插件:
axios
配置别名路径
概述
- 路径解析配置
针对webpack,把@/
解析为src/
。
基于carco
插件
- 路径联想配置
针对VSCode,在VSCode中输入@/
时,自动联想出来对应的src/
下的子级目录。
基于jsconfig.json
路径解析配置
步骤:
- 安装craco
- 项目根目录下创建配置文件
craco.config.js
必须在项目根目录下
名称必须是craco.config.js
- 配置文件中添加路径解析配置:
1 2 3 4 5 6 7 8 9
| const path = require('path')
module.exports= { webpack: { alias: { '@': path.resolve(__dirname,'src') } } }
|
- 包文件中配置启动和打包命令
修改package.json
中的scripts
中的内容,修改为:1 2 3 4 5 6
| "scripts": { "start": "craco start", "build": "craco build", "test": "react-scripts test", "eject": "react-scripts eject" },
|
联想路径配置
步骤:
- 在根目录下新增配置文件
jsconfig.json
。
- 添加路径提示配置
1 2 3 4 5 6 7 8 9 10
| { "compilerOptions": { "baseUrl": "./", "paths": { "@/*": [ "src/*" ] } } }
|
整体路由设计
- 两个一级路由
Layout
New
- 两个二级路由
Layout - Month
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
|
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
|
import { createSlice } from '@reduxjs/toolkit' import axios from 'axios'
const billStore = createSlice({ name: 'bill', initialState: { billList: [] }, reducers: { setBillList (state, action) { state.billList = action.payload } } })
const { setBillList } = billStore.actions
const getBillList = () => { return async (dispatch) => { const res = await axios.get('http://localhost:3004/ka') dispatch(setBillList(res.data)) } }
export { getBillList }
const reducer = billStore.reducer
export default reducer
|
store/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12
|
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功能
实现要点:
- 使用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/07/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 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 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>
|
统计功能实现
思路:
- 按月分组
- 根据获取到的时间作为key取当月的账单数组
- 根据当月的账单数组计算支出、收入、总计
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 _.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 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'
const [billType, setBillType] = useState('income')
{} <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
|
import { createSlice } from '@reduxjs/toolkit' import axios from 'axios'
const billStore = createSlice({ name: 'bill', initialState: { billList: [] }, reducers: { setBillList (state, action) { state.billList = action.payload }, addBill (state, action) { state.billList.push(action.payload) } } })
const { setBillList, addBill } = billStore.actions
const getBillList = () => { return async (dispatch) => { const res = await axios.get('http://localhost:3004/ka') dispatch(setBillList(res.data)) } }
const addBillList = (data) => { return async (dispatch) => { const res = await axios.post('http://localhost:3004/ka', data) dispatch(addBill(res.data)) } }
export { getBillList, addBillList }
const reducer = billStore.reducer
export default reducer
|
完整版本
完整版本react-bill.zip
提供下载链接。