React+TS 实现购物车
@引子
- TodoList(待办事项又名土豆丝儿)和Cart购物车号称demo界两巨头!
- 为什么人家是巨头而你不是,那肯定是有原因的!
- 通过购物车,我们能把组件拆分、组件通信、数据定义、生命周期、数据侦听、事件交互等等这一些列组件化开发核心基础内容全部锻炼到!
- 因此营养丰富、口感丝滑,值得各位一吃再吃;
- 发车!
@效果展示
- 无非是加加减减、消消选选、量价联动、无情删除;
- 这里面有个互动上的难点,就是全选与单选的双向联动;
@静态页面开发
- 首先让妹子帮你做一个静态页面,效果如下;
- 没有妹子的就自己动手,但也要注意身体;
@创建工程
- 在开发阶段我们使用Vite,风驰电掣十分刺激!
- 后续选择React + JS或TS即可;
- 本例先JS专注逻辑实现,再TS严谨和规范其类型,给其它开发者一个良好可读的API;
npm init vite@latest
cd 项目目录
npm i
npm i -D sass
npm run dev
@静态页面移植
Cart.jsx
- 静态HTML复制到函数式组件的JSX里;
- 图片和SCSS作为模块导入进来;
import React from 'react';
import defImg from '../assets/img/default.jpg'
import './Cart.scss'
const Cart = () => {
return (
<div className="cart-wrapper">
<div className="top">
<div className='sel-box'>
<input type="checkbox" />
<i>全选</i>
</div>
<span className='imgname-box'>商品名称</span>
<span>单价</span>
<span className='count-box'>数量</span>
<span>金额</span>
<span>操作</span>
</div>
<div className="middle">
<ul>
<li className='item'>
<div className='sel-box'>
<input type="checkbox" />
</div>
<div className='imgname-box'>
<img src={defImg} alt="item" />
<span>商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称</span>
</div>
<div className='price-box'>
¥<span className='price'>0.00</span>
</div>
<div className='count-box'>
<button>-</button>
<input type="number" />
<button>+</button>
</div>
<div className='amount-box'>
<span className='price'>0.00</span>
</div>
<div className='action-box'>
<a href="#">移除商品</a>
</div>
</li>
<li className='item'>
<div className='sel-box'>
<input type="checkbox" />
</div>
<div className='imgname-box'>
<img src={defImg} alt="item" />
<span>商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称</span>
</div>
<div className='price-box'>
¥<span className='price'>0.00</span>
</div>
<div className='count-box'>
<button>-</button>
<input type="number" />
<button>+</button>
</div>
<div className='amount-box'>
<span className='price'>0.00</span>
</div>
<div className='action-box'>
<a href="#">移除商品</a>
</div>
</li>
<li className='item'>
<div className='sel-box'>
<input type="checkbox" />
</div>
<div className='imgname-box'>
<img src={defImg} alt="item" />
<span>商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称</span>
</div>
<div className='price-box'>
¥<span className='price'>0.00</span>
</div>
<div className='count-box'>
<button>-</button>
<input type="number" />
<button>+</button>
</div>
<div className='amount-box'>
<span className='price'>0.00</span>
</div>
<div className='action-box'>
<a href="#">移除商品</a>
</div>
</li>
<li className='item'>
<div className='sel-box'>
<input type="checkbox" />
</div>
<div className='imgname-box'>
<img src={defImg} alt="item" />
<span>商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称商品名称</span>
</div>
<div className='price-box'>
¥<span className='price'>0.00</span>
</div>
<div className='count-box'>
<button>-</button>
<input type="number" />
<button>+</button>
</div>
<div className='amount-box'>
<span className='price'>0.00</span>
</div>
<div className='action-box'>
<a href="#">移除商品</a>
</div>
</li>
</ul>
</div>
<div className="bottom">
<div className="left"></div>
<div className='count-box'>
已选商品 <span className='price'>0</span> 件
</div>
<div className='amount-box'>
总计 <span className='price'>0.00</span>
</div>
<div className="pay-box">
<i>结 算</i>
</div>
</div>
</div>
);
}
export default Cart;
App.jsx
- 导入Cart组件部署一下就齐活!
import './App.scss'
import Cart from './cart/Cart'
function App() {
return (
<div className="app">
<Cart></Cart>
</div>
)
}
export default App
@useEffect原理回顾
useEffect到底干嘛滴
- 在依赖变化时执行副作用!
- 类似于Vue中的
watch
和watchEffect
(ReactHook/Vue3父子关系) - 另外一个重要作用,是给函数式组件模拟生命周期!
依赖分类
- 全依赖(直接不给依赖参数)
- 空依赖[]
- 特定依赖[time,count]
工作流程:
- 当依赖项数据发生变化时,JSX要更新,函数式组件要重新执行,依赖该数据的副作用函数就在此时被回调;
- 如果副作用函数有返回值函数(清除函数),则会先调用上一次的清除函数;
- 清除函数常里可以做一些必要的检查与重置工作等;
模拟类组件的生命周期
全依赖(props/state)
- 任意props和state发生变化都回调副作用
- 组件挂载时props/state从无到有,副作用回调,可模拟
ComponentDidMount
- 任意数据发生更新时回调,可用于模拟
ComponentDidUpdate
特定依赖
- 依赖项发生变化时回调副作用
- 可用于模拟对应依赖项的
ComponentDidUpdate
空依赖的清除函数
- 框架保证该清除函数在组件销毁前回调一次
- 因此可用于模拟
ComponentWillUnmount
@加载数据
JSON mock数据
mock/cart.json
[
{
"name":"商品1",
"img":"default.jpg",
"price":12.34,
"count":5,
"selected":true
},
{
"name":"商品2",
"img":"default.jpg",
"price":56.78,
"count":4,
"selected":true
},
{
"name":"商品3",
"img":"default.jpg",
"price":90.12,
"count":3,
"selected":false
}
]
定义购物车数据
- 类型当然是数组!
const [cart, setCart] = useState([])
组件挂载时加载数据
- 使用useEffect模拟 组件挂载 生命周期
- 此处使用本地mock数据
import cart0 from "../../mock/cart.json"
/*
模拟组件挂载生命周期
组件首次挂载时获取用户数据
*/
useEffect(() => {
console.log("ComponentDidMount");
// 没有数据就加载数据,此处的cart0是本地JSON
!cart.length && setCart(cart0)
}, [])
@列表渲染
父组件Cart
- 将cart中的每个item渲染为一个CartItem子组件;
- 注意加key,原理是优化diff性能;
- 将item的序号和数据内容作为props传递给子组件以备后用;
Cart.jsx
<ul>{
cart.map(
(item, i) =>
<CartItem
index={i}
item={item}
key={item.name}>
</CartItem>
)
}</ul>
子组件CartItem
- CartItem子组件本质就是一个LI元素
CartItem.jsx
import React from 'react';
const CartItem = ({ index, item }) => {
return (
<li className='item'>...</li>
);
}
export default CartItem;
@事件核心知识
互动开发流程
- 做交互都的套路都差不多!
- 首先绑定事件,我们这主要就是onClick,onChange!
- 事件处理函数里该干嘛,最终最终肯定是要修改数据的
- 数据驱动视图后,用户才感觉交互有效果!
- 而数据通常是由父组件在维护的,子组件无法直接操作父组件中的数据;
- 所以子组件事件的具体处理逻辑,通常由父组件以函数形式传入子组件;
- 子组件事件发生 => 回调父组件注入的函数 => 修改父组件数据,驱动视图变化;
- 这就是我们所说的React组件通信:父传子PropsDown,子传父CallbackUp!
- 传一个函数给儿子回调就完了!🤓😝
- 比Vue中自定义事件那一套更简单粗暴不是吗? 😈
合成事件对象
- React中的事件对象是SyntheticEvent/合成事件;
- 结构你自己打粗来看下就知道了,API跟原生事件差不多,也能拿到原生事件对象;
- 目的是为了做全局事件委托,以提升性能;
- PS:Vue里的事件对象是原生的哦~ (卡哇伊呢~)
不可变值原理
- 一个地址只能用一次——这就是所谓的React不可变值原理!!!
- 以下API会返回新的地址:
arr.map
,arr.filter
,{...obj}
,[...arr]
- 以下API不会更改数组地址:
arr.forEach
Object.assign(target,newKeys)
中的target是拿来做地址的!- 面试会问!面试会问!面试会问!
@加加减减
定义事件处理函数
- 加减单品的实现原理很简单,根据index序号找到对应的商品,count属性加减一下即可;
- 注意每次重新
setCart
时,要保证cart数组的地址发生变化!!! - 这里末将使用了
useCallback
缓存函数,cart数据发生变化时就把函数内容刷新一下; - 逻辑复杂时能提升一些性能,你可以不用,但建议还是用!
/* 添加单品 */
const addItem = useCallback(
(i) => {
console.log("addItem", i);
setCart(cart.map(
(item, _i) => {
_i === i && item.count++
return item
}
))
},
[cart],
)
/* 减少单品 */
const subItem = useCallback(
(i) => {
console.log("subItem", i);
setCart(cart.map(
(item, _i) => {
(_i === i && item.count) && item.count--
return item
}
))
},
[cart],
)
/* 移除单品 */
const removeItem = useCallback(
(i) => {
console.log("removeItem", i);
setCart(cart.filter((item, _i) => _i !== i))
},
[cart],
)
给子组件传递过去
Cart.jsx
<ul>{
cart.map(
(item, i) =>
<CartItem
index={i}
item={item}
addItem={addItem}
subItem={subItem}
removeItem={removeItem}
toggleSelection={toggleItem}
key={item.name}>
</CartItem>
)
}</ul>
事件发生时回调
CartItem.jsx
import React from 'react';
const CartItem = ({ index, item, addItem, subItem, removeItem, toggleSelection }) => {
return (
<li className='item'>
<div className='sel-box'>
<input type="checkbox" checked={item.selected} onChange={
() => toggleSelection(index)
} />
</div>
<div className='imgname-box'>
<img src={"/img/" + item.img} alt="item" />
<span>{item.name}</span>
</div>
<div className='price-box'>
¥<span className='price'>{item.price}</span>
</div>
<div className='count-box'>
{/* 单品减减 */}
<button onClick={() => subItem(index)}>-</button>
{/* value实时显示单品数量 */}
<input value={item.count} onChange={
newVal => { console.log(newVal) }
} />
{/* 单品加加 */}
<button onClick={() => {
console.log("on + click", index);
addItem(index)
}}>+</button>
</div>
<div className='amount-box'>
{/* 动态计算单品金额 */}
<span className='price'>{
(item.price * item.count).toFixed(2)
}</span>
</div>
<div className='action-box'>
{/* 移除单品 */}
<a href="#" onClick={()=>removeItem(index)}>移除商品</a>
</div>
</li>
);
}
export default CartItem;
计算总量与总金额
/* 计算总数量 */
const getTotalCount = useCallback(
() => {
// console.log("getTotalItems");
return cart.filter(
item => item.selected
).reduce(
(pv, cv, i) => {
// console.log(pv, cv);
return pv + cv.count
},
0
)
},
[cart],
)
/* 计算总金额 */
const getTotalAmount = useCallback(
() => {
// console.log("getTotalAmount");
return cart.filter(
item => item.selected
).reduce(
(pv, cv, i) => {
// console.log(pv, cv);
return pv + cv.price * cv.count
},
0
)
},
[cart],
)
@消消选选
定义input事件处理函数
/* 消选单品 */
const toggleItem = useCallback(
(i) => {
console.log("toggleItem", i);
console.log("cart", cart);
// 根据序号找出消选的那个item
const item = cart.find(
(item, _i) => {
console.log("_i", _i);
return _i === i
}
)
if (item) {
// 选中状态取反
item.selected = !item.selected
// 修改数据
setCart([...cart])
}
},
[cart],
)
给自组件传递过去
- 同上,此处略
子组件触发事件
import React from 'react';
const CartItem = ({ index, item, addItem, subItem, removeItem, toggleSelection }) => {
return (
<li className='item'>
<div className='sel-box'>
{/* 回调父组件注入的方法 */}
<input type="checkbox" checked={item.selected} onChange={
() => toggleSelection(index)
} />
</div>
...
</li>
);
}
export default CartItem;
@全选/单选联动(难点)
- 之所以是一个难点,是因为需要双向联动,类似于Vue中的双向数据绑定;
- 这里购物车数据
cart
和全选状态allSelected
相互影响对方,处理不好容易形成死循环; - 且看末将如何处置!
定义全选框状态
Cart.jsx
/* 定义数据 */
// 购物车数据
const [cart, setCart] = useState([])
// 全选框状态
const [allSelected, setAllSelected] = useState(false)
/* 函数式组件=>渲染JSX */
return (
<div className="cart-wrapper">
<div className="top">
<div className='sel-box'>
{/* 全选与否由allSelected控制 */}
<input type="checkbox"
checked={allSelected}
...
/>
<i>全选</i>
</div>
<span className='imgname-box'>商品名称</span>
<span>单价</span>
<span className='count-box'>数量</span>
<span>金额</span>
<span>操作</span>
</div>
<div className="middle">...</div>
<div className="bottom">...</div>
</div>
);
单选驱动全选
- 每次单选变更时,会触发cart数据项中的selected属性变化,是否已然形成全选之势,需要在每次cart数据发生变化时判断一下;
- 这里我们使用useEffect来侦听cart的变化,并在必要时去修改
allSelected
这个state,由它去控制全选的勾选与否;
/*
侦听购物车数据变化,以切换全选状态
cart驱动allSelected
*/
useEffect(() => {
// console.log("useEffect", "cart=>allSelected");
// 确实全选了就变更allSelected为true 否则就是false
cart.every(item => item.selected)
? setAllSelected(true)
: setAllSelected(false)
// return () => { }
}, [cart])
全选驱动单选
- 当用户去操作全选框时,准备对原来的
allSelected
数据取反; - 正确的做法是去操作
cart
数据,由刚刚的单选驱动全选逻辑去修改allSelected
; cart
只要事实上全选了,刚刚的单选驱动全选逻辑自然会把allSelected
数据置为true;cart
只要事实上全消了,刚刚的单选驱动全选逻辑自然会把allSelected
数据置为false;- 不要在事件中去直接去操作
allSelected
数据,政出多门(多处代码去修改同一数据)会造成代码逻辑混乱; - 更不要侦听
allSelected
又去驱动cart
变化,那样你驱动我=>我变化后又反过来驱动你=>你变化了又来驱动我=>...,那就死循环了! - 甚之甚之! 🤬 😈 👿 💀 👹 👺 👻
用户操作全选框
<div className='sel-box'>
{/* 全选与否由allSelected控制 */}
<input type="checkbox"
// 全选与否由allSelected控制
checked={allSelected}
// 用户操作全选框时 对原来的状态取反
onChange={
(e) => {
console.log("全选input onChanged", !allSelected);
toggleAllSelected(!allSelected)
}
} />
<i>全选</i>
</div>
处理全选/全消事件
这是错误示范!
/* allSelected不宜再直接驱动cart 会陷入死锁 */
// useEffect(() => {
// cart.forEach(item => item.selected = allSelected)
// setCart([...cart])
// }, [allSelected])
这是正确示范!
/*
用户主动使用【全选】功能时
先修改cart数据(全选或全消)然后由cart驱动allSelected
*/
const toggleAllSelected = useCallback(
(value) => {
console.log("toggleAllSelected", value);
// 不要直接去操作allSelected数据
// 后面cart变化了自然会去操作
// 保持“政出单门”!!!
// setAllSelected(value)
// 先修改cart数据为(全选或全消)
// 修改购物车数据=>【数据侦听逻辑A】会触发setAllSelected
cart.forEach(item => item.selected = value)
setCart([...cart])
},
[cart],
)
单向数据流
- React中没有双向数据流,只有单向!
- Vue中的
v-model
“双向数据流”也只是父传子+子传父 合二为一的一个语法糖而已!
@TS重构项目
- 小型团队和个人项目其实还是使用JS更高效一些,开发快,沟通成本并不高;
- 大型团队项目和开源项目(相当于使用团队是全世界),使用TS规范数据类型就会带来比较多的好处;
- 好处之一是API提示更友好,可读性更强,输入输出分别是什么,一看类型基本就了然了;
- 好处之二是所有的入参、类型、props等等都给你规范好了,就能严格约束你按正确的方式去使用API,否则直接就编译不通过,节约了团队沟通成本!
- 极简TS语法入门
- 本例主要规范一下商品数据类型、父传子props类型、事件处理函数类型、自定义组件类型;
- 按需定义好你的关键类型即可,可以有适量的any;
创建React+TS工程
npm init vite@latest
后续选择 React + TS 即可
定义商品条目类型
全局定义商品条目类型
vite-env.d.ts
interface Item {
name: string;
img: string;
price: number;
count: number;
selected: boolean;
}
cart的类型是Item数组
Cart.tsx
const [cart, setCart] = useState<Item[]>([])
定义事件处理器类型
- 本例中大量根据商品序号去定位具体条目的事件处理器,定义一下
- 给number进去void出来的函数一个别名即可
vite-env.d.ts
/* 根据Item的序号去做相应处理的事件处理函数 */
type ItemEventHandler = (index: number) => void
定义组件可接收的Props类型
- 规范父组件给子组件注入的Props类型
- 此处我们规定业务Props都是必传的
- 每个组件都必须具有接收children这一props的能力,这是React的规定,也需要满足一下
vite-env.d.ts
/* CartItem接收的Props */
interface CartItemProps {
/* 序号与商品数据 */
index: number;
item: Item;
/* 接收父组件注入的事件处理器 */
addItem: ItemEventHandler;
subItem: ItemEventHandler;
removeItem: ItemEventHandler;
toggleSelection: ItemEventHandler;
// React要求一个组件的props中必须有children的定义,满足一下
// children可以不传
children?: any;
}
子组件使用定义好的Props类型/CartItem.tsx
const CartItem = ({
index,
item,
addItem,
subItem,
removeItem,
toggleSelection,
}: CartItemProps) => {
return (
<li className="item">...</li>
);
};
export default CartItem;
父组件在给子组件传递Props时就需要遵守规范了,例如
Cart.tsx
/* 添加单品 */
const addItem = useCallback(
(i:number) => {
console.log("addItem", i);
setCart(cart.map(
(item, _i) => {
_i === i && item.count++
return item
}
))
},
[cart],
)
祝大家撸码愉快,2023大展宏兔 🐇🐇🐇💰💰💰
本项目源码 watch,follow,fork!!!
点赞收藏加关注了吗 😈
转载自:https://juejin.cn/post/7188576912304767033