技术思考 电商项目【购物车】

1,049 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

电商项目购物车模块

电商项目中购物车模块是必不可少的,围绕购物车模块的逻辑也很多,特此剖析梳理一下,查漏补缺。如果有啥好的意见欢迎讨论。

功能分析

购物车功能需求分析。 image.png

思路流程

  1. 购物车的各种操作都会有两种状态的区分,登录和未登录
  2. 所有操作都封装到 Pinia 中,组件只需要触发 actions 函数
  3. actions中通过 member 信息去区分登录状态
    1. 已登录,通过调用接口去服务端操作,响应成功后通过 actions 修改 Pinia 中的数据,最后局部刷新页面获取最新数据。
    2. 未登录时,通过 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';

已登录

添加购物车

实现步骤

  1. actions 中封装加入购物车的接口。
  2. 在商品详情页实现添加逻辑触发 actions 函数调用接口。

接口:加入购物车

接口基本信息

Path:  /member/cart

Method:  POST

请求参数

Body

名称类型是否必须默认值备注其他信息
skuIdstring必须SKUID
countinteger必须数量

根据接口文档封装添加购物车的接口请求函数。

// 加入购物车按钮被点击
async memberCart(data: { skuId: string; count: number }) {
  await http('POST', '/member/cart', data);
  // console.log(res);
  this.getCart();
},

点击按钮调用接口

在商品详情组件调用 actions 函数加入购物车。

  1. 准备好加入购物车接口所需的参数:skuId 和 count
  2. 点击按钮,调用接口,获取参数并传递。
  3. 没有 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

名称类型是否必须默认值备注其他信息
idsstring []必须SKUID 集合item 类型: string

实现步骤

  1. 编写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: '删除成功' });
      },
    },
    
  2. 在头部购物车叉叉的点击事件中进行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>&yen;{{ goods.nowPrice }}</p>
    </td>
    <td class="tc">
      <XtxCount :model-value="goods.count" :max="goods.stock" />
    </td>
    <td class="tc">
      <p class="f16 red">
        &yen;{{ (Number(goods.nowPrice) * goods.count).toFixed(2) }}
      </p>
    </td>
    <td class="tc">
      <p><a class="green" href="javascript:;">删除</a></p>
    </td>
  </tr>
</tbody>

删除数据

思路分析

  1. 点击删除按钮记录当前点击的商品 skuId
  2. 调用之前封装好的 action 函数实现删除。
<td class="tc">
  <p><a @click="cart.removeCart([goods.skuId])" class="green" href="javascript:;">删除</a></p>
</td>

注意:

删除光购物车之后使用元素占位,占位的方式通过 v-ifv-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>

单选操作

image.png

在之前练习的时候,我们实现修改单选操作都是用 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

请求参数

路径参数

参数名称示例备注
idSKUID

Body

名称类型是否必须默认值备注其他信息
selectedboolean非必须是否选中
countinteger非必须数量
  1. 根据接口文档定义 action 函数
    // 单选操作
    async changeSelect(
      skuId: string,
      data: { selected?: boolean; count?: number }
    ) {
        await http('put', `/member/cart/${skuId}`, data);
        // console.log(res);
        this.getCart();
      }
    },
    
  2. 获取当前选择框最新状态
    • :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

名称类型是否必须默认值备注其他信息
selectedboolean必须是否选中
idsstring []非必须skuId集合item 类型: string
├─非必须skuId

思路分析

  1. 封装调用修改全选接口的 actions 。

  2. 通过 getters 计算出选中状态 (注意: Pinia 的 getters 没有 set )

  3. 通过 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();
        },
      },
    });
    
  4. 在页面中调用方法,点击修改值

    <XtxCheckBox
      :model-value="cart.getAllCheck"
      @update:model-value="
        (val) => cart.changeSelectAll({ selected: val })
      "
      >全选</XtxCheckBox
    >
    

退出登录

image.png

问题思考

  1. 退出登录后,直接调用购物车系列接口会报错,怎么办?

    调用接口前,判断是否已登录。

  2. 退出登录后,要怎么处理购物车数据?

    数据都是存储到 Pinia 中,退出登录后清空购物车列表。

  3. 怎么处理已登录和未登录的用户的购物车

    获取 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;

未登录

加入购物车

实现步骤

  1. 点击加入购物车的时候,从商品详情中收集购物车商品展示所需数据,所需的字段需要和接口返回的数据的字段一致。

    <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>
    
  2. 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

请求参数

路径参数

参数名称示例备注
id1352956998412406785SKU_ID

实现思路

  1. 获取购物车列表的 action 中,未登录情况 下主动查询商品库存价格。
  2. 购物车的商品库存价格,更新成最新的库存价格信息。
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
├─ skuIdstring必须skuId
├─ selectedboolean必须是否选中
├─ countinteger必须数量

实现思路

  1. 编写 合并购物车的 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();
        },
    }
    
  2. 调用 合并购物车的 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();
        },
      },
    });