技术思考 电商项目【购物车】
电商项目购物车模块
电商项目中购物车模块是必不可少的,围绕购物车模块的逻辑也很多,特此剖析梳理一下,查漏补缺。如果有啥好的意见欢迎讨论。
功能分析
购物车功能需求分析。
思路流程
- 购物车的各种操作都会有两种状态的区分,登录和未登录
- 所有操作都封装到
Pinia
中,组件只需要触发actions
函数 - 在
actions
中通过member
信息去区分登录状态- 已登录,通过调用接口去服务端操作,响应成功后通过
actions
修改Pinia
中的数据,最后局部刷新页面获取最新数据。 - 未登录时,通过
actions
修改Pinia
中的数据即可,再用Pinia
实现持久化,同步保存在本地。
- 已登录,通过调用接口去服务端操作,响应成功后通过
准备模块 Store
由于多个模块都要用到购物车的数据,因此封装一个购物车的 store
模块,在 actions
内封装调用接口请求的函数,在 state
内存储购物车数据,在 getters
内计算出商品总价和数量等属性。
import { defineStore } from "pinia";
const useCartStore = defineStore("cart",{
// 状态
state: () => ({
// 购物车列表
list: [],
}),
// 计算
getters: {},
// 方法
actions: {},
});
export default useCartStore;
在 总管文件 index.ts
中全部导出。
export * from './modules/cart';
已登录
添加购物车
实现步骤
actions
中封装加入购物车的接口。- 在商品详情页实现添加逻辑触发
actions
函数调用接口。
接口:加入购物车
接口基本信息
Path: /member/cart
Method: POST
请求参数
Body
名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
---|---|---|---|---|---|
skuId | string | 必须 | SKUID | ||
count | integer | 必须 | 数量 |
根据接口文档封装添加购物车的接口请求函数。
// 加入购物车按钮被点击
async memberCart(data: { skuId: string; count: number }) {
await http('POST', '/member/cart', data);
// console.log(res);
this.getCart();
},
点击按钮调用接口
在商品详情组件调用 actions
函数加入购物车。
- 准备好加入购物车接口所需的参数:
skuId
和count
- 点击按钮,调用接口,获取参数并传递。
- 没有
skuId
需提示用户。
<script setup lang="ts">
// 商品数量
const count = ref(1);
// XtxSku 组件选中的商品信息
const skuId = ref("");
const changeFn = (skuInfo: SkuEmit) => {
// console.log(skuInfo);
skuId.value = skuInfo.skuId;
};
// 加入购物按钮点击
const { cart } = useStore();
const addCart = () => {
// 没有 skuId,提醒用户并退出函数
if (!skuId.value) {
return message({ type: "warn", text: "请选择完整商品规则~" });
}
// 调用加入购物车接口
cart.addCart({
skuId: skuId.value,
count: count.value,
});
};
</script>
<template>
<!-- 规格选择组件 -->
<XtxSku :goods="goods" @change="changeFn"></XtxSku>
<!-- 数量选择组件 -->
<XtxCount
v-model="count"
isLabel
:min="1"
:max="goods.inventory"
></XtxCount>
<!-- 按钮组件 -->
<XtxButton @click="addCart" type="primary" size="middle"
>添加购物车</XtxButton
>
</template>
头部购物车
购物车列表
Path: /member/cart
Method: GET
请求参数:无
根据接口,获取数据,通过数据渲染头部购物车组件,并在 getters
中计算出商品的数量、商品的总件数和商品的价格,渲染在页面上。
getters: {
// 计算有效商品列表 isEffective = true filter
effectiveList(): CartList {
return this.list.filter((item) => item.stock > 0 && item.isEffective);
},
// 有效商品总数量 把effctiveList中的每一项的count叠加起来
effectiveListCounts(): number {
return this.effectiveList.reduce((sum, item) => sum + item.count, 0);
},
// 总钱数 = 所有单项的钱数累加 单项的钱数 = 数量 * 单价
effectiveListPrice(): string {
return this.effectiveList
.reduce((sum, item) => sum + item.count * Number(item.nowPrice), 0)
.toFixed(2);
},
},
actions: {
// 获取购物车数据
async getCart() {
if (this.isLogin) {
const res = await http<CartList>('get', '/member/cart');
console.log(res);
this.list = res.data.result;
} else {
console.log('未登录');
}
},
},
删除功能实现
接口:删除/清空购物车商品
基本信息
Path: /member/cart
Method: DELETE
请求参数
Body
名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
---|---|---|---|---|---|
ids | string [] | 必须 | SKUID 集合 | item 类型: string |
实现步骤
-
编写actions函数进行删除操作
actions: { // 删除购物车商品 async removeCart(skuIds: string[]) { const res = await http("DELETE", "/member/cart", { ids: skuIds }); console.log("DELETE", "/member/cart", res.data.result); // 🎯主动获取最新购物车列表 this.getCart(); // 提示 message({ type: 'success', text: '删除成功' }); }, },
-
在头部购物车叉叉的点击事件中进行action调用,传参当前商品的
skuId
<i @click="cart.removeCart({ ids: [item.skuId] })" class="iconfont icon-close-new" ></i>
列表购物车
数据渲染
点击头部购物车小图标跳转至列表购物车页面,关于路由设置,路由跳转这里一笔带过不做过多描述。列表购物车的数据和头部购物车一致,也是从之前设置好的 store/cart/list
中获取。
注意:
- 最新价格是
nowPrice
- 计数器需要设置最大库存值
max
- 价格小计需要
toFixed(2)
保留两位小数
<!-- 有效商品 -->
<tbody>
<tr v-for="goods in cart.effectiveList" :key="goods.skuId">
<td><XtxCheckBox :model-value="goods.selected" /></td>
<td>
<div class="goods">
<RouterLink :to="`/goods/${goods.id}`">
<img :src="goods.picture" :alt="goods.name" />
</RouterLink>
<div>
<p class="name ellipsis">{{ goods.name }}</p>
<p class="attr">{{ goods.attrsText }}</p>
</div>
</div>
</td>
<td class="tc">
<p>¥{{ goods.nowPrice }}</p>
</td>
<td class="tc">
<XtxCount :model-value="goods.count" :max="goods.stock" />
</td>
<td class="tc">
<p class="f16 red">
¥{{ (Number(goods.nowPrice) * goods.count).toFixed(2) }}
</p>
</td>
<td class="tc">
<p><a class="green" href="javascript:;">删除</a></p>
</td>
</tr>
</tbody>
删除数据
思路分析
- 点击删除按钮记录当前点击的商品
skuId
- 调用之前封装好的
action
函数实现删除。
<td class="tc">
<p><a @click="cart.removeCart([goods.skuId])" class="green" href="javascript:;">删除</a></p>
</td>
注意:
删除光购物车之后使用元素占位,占位的方式通过
v-if
和v-else
进行判断。<tr> <td colspan="6"> <div class="cart-none" style="text-align: center"> <img src="@/assets/images/none.png" alt="" /> <p>购物车内暂时没有商品</p> <div class="btn" style="margin: 20px"> <XtxButton type="primary"> 继续逛逛 </XtxButton> </div> </div> </td> </tr>
单选操作
在之前练习的时候,我们实现修改单选操作都是用 v-model
双向绑定在复选框 checkbox
上,修改时把新的值存起来。但是那是在本地操作数据,而这次需要通知后台服务器,让服务器把数据库内的数据也修改。因此如果直接 v-model
修改只能修改前端效果,实际上后台服务器的数据依旧没变。
怎么做呢?在学习 vue3
基础时,有学习到在 vue3
中, v-model
是一个语法糖,实质上是 :model-value
和 @update:model-value
的结合。
:model-value
:单向数据渲染,前台数据随着后台数据库的数据变化而变化。@update:model-value
:当用户修改值时把新的值传给后台让后台修改值。
接口:修改购物车商品
Path: /member/cart/:id
Method: PUT
请求参数
路径参数
参数名称 | 示例 | 备注 |
---|---|---|
id | SKUID |
Body
名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
---|---|---|---|---|---|
selected | boolean | 非必须 | 是否选中 | ||
count | integer | 非必须 | 数量 |
- 根据接口文档定义
action
函数// 单选操作 async changeSelect( skuId: string, data: { selected?: boolean; count?: number } ) { await http('put', `/member/cart/${skuId}`, data); // console.log(res); this.getCart(); } },
- 获取当前选择框最新状态
- 用
:modelValue
单向绑定数据动态渲染数据 - 用
@update:model-value
在值发生改变时调用接口函数把新的值传给后台
<XtxCheckBox :model-value="item.selected" @update:model-value=" (val) => cart.changeSelect(item.skuId, { selected: val, }) " />
- 用
修改数量
操作思路和单选操作一样,也是通过拆分语法糖 v-model
调用接口把新的数据传给后台。接口和单选操作一样。
<XtxCount
:model-value="item.count"
@update:model-value="
(val) => cart.changeSelect(item.skuId, { count: val })
"
/>
全选切换
接口:购物车全选/取消全选
Path: /member/cart/selected
Method: PUT
接口描述:
ids参数如果不传,表示用户访问的是全选和取消全选操作,后端根据 selected 确定用户是全选和取消全选
请求参数
Body
名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
---|---|---|---|---|---|
selected | boolean | 必须 | 是否选中 | ||
ids | string [] | 非必须 | skuId集合 | item 类型: string | |
├─ | 非必须 | skuId |
思路分析
-
封装调用修改全选接口的
actions
。 -
通过
getters
计算出选中状态 (注意:Pinia
的getters
没有set
) -
通过
v-model
和computed
组合实现全选效果。const useCartStore = defineStore('cart', { persist: true, // 状态 state: () => { }), // 计算 getters: { // 计算全选状态 getAllCheck(): boolean { return this.getGoodCart.every((v) => v.selected); }, }, // 方法 actions: { // 清空购物车 clearCart() { this.list = []; }, // 全选操作 async changeSelectAll(data: { selected: boolean; ids?: string[] }) { await http('put', `/member/cart/selected`, data); this.getCart(); }, }, });
-
在页面中调用方法,点击修改值
<XtxCheckBox :model-value="cart.getAllCheck" @update:model-value=" (val) => cart.changeSelectAll({ selected: val }) " >全选</XtxCheckBox >
退出登录
问题思考
-
退出登录后,直接调用购物车系列接口会报错,怎么办?
调用接口前,判断是否已登录。
-
退出登录后,要怎么处理购物车数据?
数据都是存储到
Pinia
中,退出登录后清空购物车列表。 -
怎么处理已登录和未登录的用户的购物车
获取
member
模块的是否有 token
,已登录调用接口,未登录本地操作。
退出清空购物车
创建一个清空购物车的 action
函数,在 member
模块的退出登录模块调用。
const useCartStore = defineStore("cart", {
// 方法
actions: {
...
+ // 清空购物车
+ clearCart() {
+ // 退出登录需清空购物车
+ this.list = [];
+ },
},
});
已登录和未登录的设计
const useCartStore = defineStore("cart",{
// 计算
getters: {
// 获取当前的登录状态
isLogin(): boolean {
const { member } = useStore();
return Boolean(member.profile.token);
},
...
},
// 方法
actions: {
// 加入购物车按钮被点击
async memberCart(cartItem: CartItem) {
const data = {
skuId: cartItem.skuId,
count: cartItem.count,
} as CartItem;
if (this.isLogin) {
await http('POST', '/member/cart', data);
// console.log(res);
this.getCart();
} else {
console.log('未登录');
}
},
},
});
export default useCartStore;
未登录
加入购物车
实现步骤
-
点击加入购物车的时候,从商品详情中收集购物车商品展示所需数据,所需的字段需要和接口返回的数据的字段一致。
<script setup lang="ts"> // 选中的商品规格文本 const attrsText = ref(""); // 加入购物按钮点击 const addCart = () => { // 没有 skuId,提醒用户并退出函数 if (!skuId.value) { return message({ type: "warn", text: "请选择完整商品规则~" }); } if (!goods.value) return; // 没有商品则返回,防止ts报错 const cartItem = { // 第一部分:商品详情中有的 id: goods.value.id, // 商品id name: goods.value.name, // 商品名称 picture: goods.value.mainPictures[0], // 图片 price: goods.value.oldPrice, // 旧价格 nowPrice: goods.value.price, // 新价格 stock: goods.value.inventory, // 库存 // 第二部分:商品详情中没有的,自己通过响应式数据收集 count: count.value, // 商品数量 skuId: skuId.value, // skuId attrsText: attrsText.value, // 商品规格文本 // 第三部分:设置默认值即可 selected: true, // 默认商品选中 isEffective: true, // 默认商品有效 } as CartItem; // as 断言防止类型报错 // 调用加入购物车接口 cart.addCart(cartItem); }; </script>
-
actions 中完成添加操作(未登录)。
有两种情况:
- 购物车没有相同商品:直接推送到第一个
- 有一样的商品,数量累加
actions: { // 加入购物车 async addCart(data: CartItem) { const { skuId, count } = data; if (this.isLogin) { // 已登录情况 - 调用接口 const res = await http("POST", "/member/cart", { skuId, count }); this.getCartList(); } else { // 未登录情况 - 操作本地数据(相当于高级版todos) // 添加商品分两种情况: const cartItem = this.list.find((item) => item.skuId === skuId); if (!cartItem) { // 情况1:新添加的商品,前添加到数组中 this.list.unshift(data); } else { // 情况2:已添加过的的商品,累加数量即可 cartItem.count += count; } } message({ type: "success", text: "添加成功~" }); }, }
删除购物车
通过删除按钮被点击时传过来的 skuId
在本地数据中过滤,实现删除。
actions: {
// 删除/清空购物车商品
async deleteCart(skuIds: string[]) {
if (this.isLogin) {
// 已登录情况 - 调用接口
await http("DELETE", "/member/cart", { ids: skuIds });
this.getCartList();
} else {
// 未登录情况 - 操作本地数据(相当于高级版todos)
+ this.list = this.list.filter((item) => !skuIds.includes(item.skuId));
}
message({ type: "success", text: "删除成功~" });
},
}
选中状态切换
由于是同一个接口,因此需要判断当前触发事件的是单选还是数量。通过前台返回的字段来判断。
actions: {
// 修改购物车商品-修改选中-修改数量
async updateCart(skuId: string,data: { selected?: boolean; count?: number }) {
const { selected, count } = data;
if (this.isLogin) {
// 已登录情况 - 调用接口
await http("PUT", `/member/cart/${skuId}`, {selected, count});
this.getCartList();
} else {
// 未登录情况 - 操作本地数据(相当于高级版todos)
+ const cartItem = this.list.find((item) => item.skuId === skuId);
+ if (cartItem) {
+ if (count) cartItem.count = count;
+ // 🚨 false 为假值,主要需要判断是否为 undefined
+ if (selected !== undefined) cartItem.selected = selected;
+ }
}
},
}
注意:
在判断单选,如果当前需要把单选变为假,则
if(selected)
判断不会生效,因此需要判断有没有这个字段。
全选切换
循环遍历数据内全部的数据,把它们的值都修改为传过来的值。
actions: {
// 购物车全选/取消全选
async updateCartAllSelected(data: { selected: boolean; ids?: string[] }) {
if (this.isLogin) {
// 已登录情况 - 调用接口
await http("PUT", "/member/cart/selected", data);
this.getCartList();
} else {
// 未登录情况 - 操作本地数据(相当于高级版todos)
+ this.list.forEach((item) => {
+ item.selected = data.selected;
+ });
}
},
}
主动更新本地购物车信息
电商中,商品的价格、库存都不是固定的,因此需要时不时去调用接口查询,如果失效,则在页面中显示给用户,让用户即使不登录也能知道。
接口:查询商品库存价格信息
Path: /goods/stock/:id
Method: GET
请求参数
路径参数
参数名称 | 示例 | 备注 |
---|---|---|
id | 1352956998412406785 | SKU_ID |
实现思路
- 获取购物车列表的 action 中,未登录情况 下主动查询商品库存价格。
- 购物车的商品库存价格,更新成最新的库存价格信息。
actions: {
// 获取购物车列表
async getCartList() {
if (this.isLogin) {
// 已登录情况 - 调用接口
const res = await http<CartList>("GET", "/member/cart");
this.list = res.data.result;
} else {
// 🚨未登录情况 - 每次获取购物车都查询最新商品价格和库存
// 遍历购物车的每一项商品
this.list.forEach(async (cartItem) => {
const { skuId } = cartItem;
// 根据 skuId 获取最新商品信息
const res = await http<CartItem>("GET", `/goods/stock/${skuId}`);
// 保存最新商品信息
const lastCartInfo = res.data.result;
// 更新商品现价
cartItem.nowPrice = lastCartInfo.nowPrice;
// 更新商品库存
cartItem.stock = lastCartInfo.stock;
// 更新商品是否有效
cartItem.isEffective = lastCartInfo.isEffective;
});
}
},
}
合并购物车
用户登录后之前保存的商品自然要一并拼接上去,不然用户体验极差。
接口:合并购物车
基本信息
Path: /member/cart/merge
Method: POST
请求参数
Body
名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
---|---|---|---|---|---|
object [] | 非必须 | 购物车sku集合 | item 类型: object | ||
├─ skuId | string | 必须 | skuId | ||
├─ selected | boolean | 必须 | 是否选中 | ||
├─ count | integer | 必须 | 数量 |
实现思路
- 编写 合并购物车的
action
函数 (将对于购物车的处理,统一到Pinia
中)actions: { // 合并购物车 async mergeLocalCart() { const data = this.list.map(({ skuId, selected, count }) => ({ skuId, selected, count, })); const res = await http("POST", "/member/cart/merge", data); console.log("POST", "/member/cart/merge", res.data.result); // 合并成功,重新获取购物车列表 this.getCartList(); }, }
- 调用 合并购物车的
action
函数 (思考:在哪调用?答:登录完成后)const useMemberStore = defineStore({ // 方法 actions: { async loginSuccess() { // 存储到本地 saveStorageProfile(this.profile); // 登录成功提示 message({ type: "success", text: "登录成功" }); // console.log(router); // 🐛 在非 .vue 组件中 useRoute() 返回 undefined,没法使用 // const route = useRoute() // 📌 解决方案,通过 router 路由实例 currentRoute 获取 const route = router.currentRoute.value; // console.log(route.path); if (route.query.target) { // 跳转到指定地址 router.push(decodeURIComponent(route.query.target as string)); } else { // 跳转到首页 router.push("/"); } + // 登录成功后,合并购物车 + const { cart } = useStore(); + cart.mergeLocalCart(); }, }, });
转载自:https://juejin.cn/post/7102795588164386853