Vue 购物车状态管理完整实现:从用户标识到结算总价

本文以一个完整的电商购物车功能为主线,深入剖析 Vuex 模块化状态管理的工程实践。从 UUID 用户标识的生成与持久化,到选中状态的联动逻辑,从乐观更新与悲观更新的权衡,到 getters 缓存计算总价的底层原理,每一个技术决策背后都有明确的工程动机。


目录

  1. 零、导读与学习价值
  2. 一、购物车的核心状态设计
  3. 二、用户标识:UUID 与 localStorage 持久化
  4. 三、购物车数据的 Vuex 管理
  5. 四、选中状态管理:全选与单选联动
  6. 五、删除操作:单删与批量删
  7. 六、总价计算:getters 的价值
  8. 七、购物车数量同步策略
  9. 总结

零、导读与学习价值

0.1 购物车功能清单

一个生产级购物车涉及的工程点远比想象中多。以下是本文覆盖的完整功能矩阵:

功能模块 技术要点 复杂度
加入购物车 接口调用 + 成功跳转 + 失败提示
购物车成功页 sessionStorage 跨页传参,避免路由参数刷新丢失
用户标识 UUID 生成 + localStorage 持久化 + 请求头注入
数据获取与渲染 复杂嵌套数组的 Vuex 管理
选中/取消选中 乐观更新 vs 悲观更新的策略选择
单个删除 commit 本地删除 vs dispatch 重新拉取
批量删除 过滤已选商品 ID + 批量接口
总价计算 getters 缓存计算,避免重复遍历
数量加减 防抖处理,避免频繁请求

0.2 核心名词速查

名词 含义
UUID Universally Unique Identifier,通用唯一识别码,128 位标识符
userTempId 未登录用户的临时标识,存入 localStorage 并随每次请求携带
isChecked 购物车商品的选中状态,1 代表选中,0 代表未选中
乐观更新 接口调用成功后直接修改本地 store,不重新拉取数据
悲观更新 接口调用成功后重新请求服务端数据,确保本地与服务端一致
getters Vuex 中的计算属性,基于 state 派生数据,结果会被缓存
skuId Stock Keeping Unit,库存单位 ID,唯一标识一个具体商品规格

0.3 购物车的工程挑战

购物车看似简单,实则是前端工程中状态管理复杂度最高的场景之一。以下三个问题每个初学者都会遇到:

问题一:页面刷新后购物车数据丢失
路由参数($route.params)在刷新后会丢失。购物车成功页需要展示商品信息,如果通过路由传参,刷新后信息消失,用户体验极差。正确方案是使用 sessionStorage 临时存储,配合 Vuex 在组件内恢复。

问题二:调用购物车接口却拿不到数据
这是最经典的"无数据"陷阱。根本原因是:购物车接口需要识别"是谁的购物车"。未登录状态下,服务端依靠请求头中的 userTempId 区分不同用户。如果请求头没有携带这个字段,服务端不知道是谁,自然返回空数组。

问题三:选中状态更新后 UI 不刷新
直接修改 state 中对象的属性,Vue 2 的响应式系统可能无法追踪(对象新增属性问题)。正确做法是通过 mutation 中的 find + 属性赋值,让 Vue 知道数据发生了变化。


一、购物车的核心状态设计

1.1 名词解释

State(状态):Vuex 中存储数据的地方,相当于组件的 data,但是全局共享的。

Mutation:Vuex 中唯一可以同步修改 state 的方法。每个 mutation 都有一个字符串类型的事件类型(type)和一个回调函数(handler),回调函数接受 state 作为第一个参数。

Action:处理异步逻辑的地方。Action 不直接修改 state,而是通过提交 mutation 来完成状态修改。Action 可以包含任意异步操作。

namespaced(命名空间):当模块使用 namespaced: true 时,模块内部的 getter、mutation、action 都会根据模块注册的路径命名。这避免了不同模块间的命名冲突。

1.2 购物车模块的整体架构

购物车模块的状态设计遵循"最小必要"原则:state 只存原始数据,派生数据通过 getters 计算,副作用通过 actions 处理。

在这里插入图片描述

【代码注释】该图呈现购物车 Vuex 模块的单向数据流闭环。蓝色 API 层只负责发请求;紫色 Actions 接收 dispatchawait 接口后 commit 一个 mutation;黄色 Mutations 是唯一能同步改写绿色 State(cartList 原始数组)的入口;橙色 Getters 基于 State 派生总价/件数并缓存;灰色组件层通过 v-for 读 State、通过 getters 读派生值,再以 dispatch/commit 把用户操作送回上游。关注这条环:组件永远不直接调 API、不直接写 State,数据只能沿"组件 → action → mutation → state → 组件"单向流动——这正是 Vuex 让购物车这种多入口(详情页加购、列表页改数量、结算页读总价)状态保持可追踪的根本原因。市面应用:京东/淘宝级购物车的"加购数量角标全局同步",本质就是多个组件共享同一 State,靠这套单向流避免角标与列表数字打架。

1.3 购物车数据的真实结构

理解数据结构是设计 state 的第一步。购物车接口返回的数据是一个嵌套结构:

// 接口返回的原始数据结构
[
  {
    // 购物车组(通常按卖家分组)
    cartInfoList: [
      {
        id: 1,
        skuId: "1234",
        skuName: "美的电饭煲 WFZ5099IH",
        imgUrl: "https://...",
        skuPrice: 599.00,
        skuNum: 2,           // 购买数量
        isChecked: 1,        // 选中状态:1=选中,0=未选中
        // ...其他字段
      }
    ]
  }
]

【代码注释】这段是购物车接口返回的真实数据形状,关键在于它是数组套数组:最外层数组通常按卖家/店铺分组,真正的商品行躺在 data[0].cartInfoList 里,每行带 skuId(规格唯一标识)、skuNum(数量)、isChecked(选中状态,注意是数字 1/0 而非布尔)。看清这个嵌套层级是设计 state 的前提——很多人直接拿 data 当列表渲染,结果拿到的是分组对象而非商品,页面一片空白。市面应用:电商购物车按店铺分组、外卖按商家分组、订单按时间段分组,后端返回"分组数组 + 组内明细数组"是极常见的结构,前端落库前通常会先拍平(flatten)成一维数组再交给 Vuex。

注意关键细节:接口返回的是数组套数组,实际商品列表在 data[0].cartInfoList 中,而不是直接在 data 中。这是很多开发者踩坑的地方。

// store/cart.js
const state = {
  cartList: []  // 存储购物车商品列表(已拍平的 cartInfoList)
}

const mutations = {
  SAVE_CART_LIST(state, cartList) {
    state.cartList = cartList
  }
}

const actions = {
  async getCartListAsync({ commit }) {
    const { data } = await getCartList()
    // 关键:取 data[0].cartInfoList,而不是直接用 data
    // data[0] 可能不存在(购物车为空时),所以用可选链 + 默认空数组
    commit('SAVE_CART_LIST', data[0] ? data[0].cartInfoList : [])
  }
}

export default {
  namespaced: true,  // 开启命名空间,避免与其他模块冲突
  state,
  mutations,
  actions
}

【代码注释】

  • namespaced: true:开启后,该模块的所有操作都需要加前缀,如 cart/getCartListAsync。这在大型项目中避免了命名冲突,是 Vuex 模块化的最佳实践。
  • data[0] ? data[0].cartInfoList : []:防御性编程。当购物车为空时,接口可能返回 [],此时 data[0]undefined,直接访问会报错。三元表达式保证了空购物车状态的安全处理。

【实战要点】

state 设计的三条红线(踩中任意一条都会埋下难以排查的隐患):

红线 错误做法 后果 正确做法
不在 state 存派生数据 totalPrice 也写进 state,每次改数量手动同步 数量变了忘了改 totalPrice,列表与总价对不上 用 getter 计算总价,源数据变它自动重算
不在 mutation 里做异步 mutation 里直接 await getCartList() 开启 strict 模式 会报错,devtools 无法捕捉前后快照 异步全部放 action,成功后再 commit
不让组件直接改 state 组件里 this.$store.state.cart.cartList.push(...) 绕过 mutation,状态变更不可追踪 一律走 dispatchactioncommit

排查"购物车列表渲染不出来"时,按"接口是否返回 → 是否取了 data[0].cartInfoList → mutation 是否 commit 了 → 组件是否读对了命名空间路径 cart/..."四步定位,能覆盖九成问题。Vuex 官方建议生产环境关闭 strict 模式——它对整棵 state 树跑同步深度 watcher,频繁 commit 时性能开销明显(参见 Vuex Strict Mode 文档)。

【本章小结】

购物车 state 的设计原则是:只存服务端返回的原始数组,不在 state 里存计算结果。所有派生数据(总价、总件数、是否全选)通过 getters 计算,这样可以充分利用 Vuex 的缓存机制,避免重复计算。

概念 职责 能否异步 谁来调用
State 存原始数据(cartList —— mutation 改、组件读
Mutation 唯一同步改 state 的入口 必须同步 action 用 commit 触发
Action 处理异步、编排业务 可异步 组件用 dispatch 触发
Getters 派生数据并缓存 —— 组件用 mapGetters

记忆口诀:“原始进 state,派生进 getter,异步进 action,改写只走 mutation”——四句话锁死单向数据流,谁越界谁出 bug。

【面试考点】

Q:Vuex 的 mutation 和 action 有什么区别?为什么要分开?

A:mutation 必须是同步函数,这使得每次 mutation 提交都产生一个可记录、可追踪的状态变更,Vuex devtools 才能在时间线上精确记录每一次状态变化。action 可以包含任意异步操作,但最终通过提交 mutation 来改变 state。这种设计让状态变更始终可预测、可调试。如果 mutation 允许异步,当多个异步 mutation 同时进行时,devtools 无法确定哪次状态变更对应哪个 mutation,调试就变得不可能。


二、用户标识:UUID 与 localStorage 持久化

2.1 名词解释

UUID(通用唯一识别码):一种用于信息系统中对象的标准化标识符,格式为 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx,共 128 位,理论上在全球范围内唯一。

UUID v4:基于随机数生成的 UUID 版本,使用加密安全的随机数生成器,碰撞概率极低(约 5.3×10⁻³⁶)。这是前端最常用的版本,因为不需要依赖时间戳或 MAC 地址。

localStorage:浏览器提供的持久化键值存储,数据不会随页面关闭而消失,除非手动清除或调用 removeItem。与 sessionStorage 的区别是:sessionStorage 在标签页关闭后即失效。

请求拦截器:Axios 提供的钩子,在请求真正发出前执行。用于统一处理请求头、鉴权 Token、加载状态等公共逻辑,避免在每个 API 调用中重复写相同代码。

2.2 为什么需要 userTempId

购物车系统面对两类用户:已登录用户和未登录用户。

在这里插入图片描述

【代码注释】该图把"购物车归属谁"这个核心问题拆成一条决策链。蓝色「用户访问」进入黄色菱形判断是否登录:已登录走绿色 userId,服务端直接关联账号购物车;未登录走橙色 userTempId,服务端为这个匿名标识维护一份临时购物车。橙色节点向下连到紫色「localStorage 持久化」,并有一条回边「复用同一标识」——这是关键:同一个匿名用户在多次访问间必须拿到同一个 userTempId,否则服务端会把他当成不同人,购物车就"丢了"。最后橙→绿的「登录后合并」表示登录时把临时车并入账号车。市面应用:电商网站"没登录也能往购物车加东西,登录后东西还在",背后就是这套临时标识 + 登录合并机制;标识一旦每次刷新都重新生成,就会复现"加了购物车却查不到"的经典 bug。

如果不传递 userTempId,服务端无法区分两个同时浏览的匿名用户,购物车数据会发生混淆,也无法在用户登录后合并购物车。

2.3 UUID 的生成原理

UUID v4 的 128 位数据几乎全部由随机数填充,只有几个固定位用于标识版本(4)和变体。浏览器环境下,uuid 库使用 crypto.getRandomValues() API 生成密码学安全的随机数,确保即使快速连续生成多个 UUID,也不会产生碰撞。

UUID v4 格式:
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx

其中:
  4    → 固定值,标识 v4 版本
  y    → 固定为 8、9、a、b 之一,标识 RFC 4122 变体
  其余 x → 随机生成的十六进制数字

【代码注释】这段拆解 UUID v4 的位结构:128 位里只有版本位(固定为 4)和变体位(8/9/a/b)是确定的,其余全是随机十六进制。为什么前端首选 v4 而非 v1?因为 v1 依赖时间戳 + MAC 地址,会泄露设备信息且在虚拟化环境下可能重复;v4 走 crypto.getRandomValues() 的密码学安全随机源,碰撞概率约 5.3×10⁻³⁶,对"匿名用户标识"这种不需要可排序、只需要全局唯一的场景最合适。市面应用:埋点设备号、匿名会话 ID、前端生成的临时订单号都偏好 UUID v4;现代浏览器还可直接用原生 crypto.randomUUID() 免装依赖。

2.4 localStorage vs sessionStorage 的选择

特性 localStorage sessionStorage
生命周期 永久(手动清除才消失) 标签页关闭即清除
作用域 同源所有标签页共享 单个标签页私有
适合场景 用户 Token、语言偏好、购物车标识 一次性会话数据

userTempId 应该使用 localStorage。原因:如果用户关闭了标签页,下次再来时应该还能找回自己的购物车。sessionStorage 会让购物车在每次关闭标签页后消失,这对用户极不友好。

2.5 完整实现

// src/utils/auth.js
import { v4 as uuidV4 } from 'uuid'

/**
 * 获取或生成用户临时标识
 * 首次调用时生成 UUID 并存入 localStorage
 * 后续调用直接从 localStorage 读取
 * @returns {string} userTempId
 */
export const getUserTempId = function () {
  let userTempId = localStorage.getItem('userTempId')
  if (!userTempId) {
    // 首次访问:生成 UUID v4 并持久化
    userTempId = uuidV4()
    localStorage.setItem('userTempId', userTempId)
  }
  return userTempId
}

【代码注释】getUserTempId 是典型的"懒加载 + 缓存"(lazy + memoize)模式:先尝试从 localStorage 读,读不到才生成并写回。它保证了两点——应用启动时不会无谓地生成标识(用户可能从不加购),以及同一浏览器在不同访问间始终返回同一个 ID。为什么不在模块顶层直接生成?那样会让"标识生成"这一副作用在文件被 import 时就发生,违背"按需生成",也不利于测试。市面应用:前端生成的设备指纹、A/B 实验分桶 ID、匿名收藏夹标识都用这个模式——首次访问落库、后续读缓存。

// src/request/index.js(请求拦截器)
import { getUserTempId } from '@/utils/auth'

shopRequest.interceptors.request.use(config => {
  nprogress.start()  // 开启进度条

  // 每次请求都携带用户临时标识
  // 服务端通过这个字段识别是哪个匿名用户的购物车
  config.headers.userTempId = getUserTempId()

  return config
})

【代码注释】

  • getUserTempId 使用"懒加载 + 缓存"模式:第一次调用时才生成 UUID 并存储,后续调用直接读取缓存。这样既不会在应用启动时就产生 UUID(可能不需要),也保证了整个会话期间标识的一致性。
  • 在请求拦截器中注入 userTempId,而不是在每个 API 调用中手动添加,这是"一次配置,全局生效"的最佳实践。未来如果需要添加 Token 鉴权,只需在这里扩展,不需要修改所有 API 文件。

【实战要点】

购物车数据为空的排查顺序

  1. 检查是否真的往购物车里添加了商品(接口 /cart/addToCart 是否调用成功)
  2. 检查请求头是否携带了 userTempId(打开 Network 面板查看 Request Headers)
  3. 检查 userTempId 是否在不同请求间保持一致(如果每次请求都生成新的 UUID,服务端会认为是不同用户)
  4. 检查接口返回的数据结构,确认正确访问 data[0].cartInfoList

【本章小结】

未登录用户的购物车归属问题,靠"前端生成 UUID v4 → 存 localStorage → 请求拦截器注入请求头"这条链解决。三种"标识 + 存储 + 注入"选型对比如下:

维度 选定方案 备选方案 为什么选它
标识生成 UUID v4(随机) UUID v1(时间戳+MAC)/ 自增 ID v4 不泄露设备信息、无需服务端分配、碰撞概率约 5.3×10⁻³⁶
持久化 localStorage sessionStorage / Cookie 关标签页不丢、不随请求自动发送、容量 5-10MB
注入方式 请求拦截器统一注入 每个 API 手动加 header 一次配置全局生效,后续加 Token 只改一处

记忆口诀:“v4 随机不泄密,local 持久跨会话,拦截器里一次注入处处带”——三段分别对应"生成什么、存哪里、怎么带"。

【面试考点】

Q:为什么选择 localStorage 而不是 Cookie 来存储 userTempId?

A:Cookie 的缺点在于会随每个请求自动携带(增加带宽),且有大小限制(约 4KB),还涉及跨域和 SameSite 等安全策略。localStorage 存储在客户端,只有 JavaScript 可以主动读取,不会自动发送,且容量更大(约 5-10MB)。对于 userTempId 这种只需要前端读取并手动放入请求头的场景,localStorage 更合适。Cookie 更适合需要服务端直接读取(如 Session 鉴权)的场景。


三、购物车数据的 Vuex 管理

3.1 名词解释

模块注册(modules):Vuex 允许将 store 分割成模块,每个模块拥有自己的 state、mutation、action、getter。大型应用中,这避免了单一 store 文件过于庞大的问题。

async/await:ES2017 引入的异步处理语法。async 标记一个函数为异步函数,await 在异步函数内暂停执行直到 Promise 完成。相比 .then() 链式调用,async/await 让异步代码的读写更接近同步代码的逻辑结构。

3.2 加入购物车的完整流程

加入购物车不是简单地调用一个接口,而是一个完整的用户交互流程:成功时跳转到成功页,失败时给出提示。

在这里插入图片描述

【代码注释】该时序图展示"加入购物车"从点击到落地的五方协作:用户触发详情页,详情页 dispatch 一个 action,action 向后端发加购请求,后端返回 code 200 或错误。下方两个分组是成败两条路:成功路径里详情页先把商品信息写进 sessionStorage、再 router.push 到成功页,成功页从 sessionStorage 取数据展示——之所以走 sessionStorage 而非路由参数,是因为图片 URL 可能超长、且刷新后路由参数易丢;失败路径则 reject 回详情页弹错误提示。重点在那条"await 接口成功后才跳转"的顺序:若不等接口直接跳,用户已到成功页而服务端尚未保存,就会出现"成功页显示了、购物车里却没有"的不一致。市面应用:所有"操作后跳转结果页"的流程(下单成功、支付成功、提交工单)都遵循这条"先确认服务端、再跳转、跨页用 storage 传参"的时序。

3.3 成功页的跨组件数据传递

为什么不用路由参数传递商品信息?

路由参数(/addCartSuccess?name=xxx&img=yyy)有两个致命问题:

  1. URL 长度有限制,商品图片 URL 本身就可能超过限制
  2. 用户刷新页面时,路由参数会触发组件重新创建,但如果是从 URL 直接访问,后退后会丢失上下文

sessionStorage 方案:在详情页跳转前将商品信息存入 sessionStorage,在成功页读取。关闭标签页后数据自动清除,不会造成数据污染。

// src/pages/Detail/index.vue
async addCart() {
  // 第一步:将商品信息存入 sessionStorage
  // 这里存储了购买数量、规格选项、以及从 skuInfo 展开的所有商品字段
  sessionStorage.setItem('addCartInfo', JSON.stringify({
    buyNum: this.buyNum,
    attrList: this.spuSaleAttrList,
    ...this.skuInfo  // 包含 skuName、skuDefaultImg、price 等字段
  }))

  // 第二步:调用加入购物车接口(await 确保接口成功后才跳转)
  await this.$store.dispatch('cart/postAddToCartAsync', {
    skuId: this.$route.params.id,
    skuNum: this.buyNum
  })

  // 第三步:接口成功,跳转到成功页
  this.$router.push('/addCartSuccess')
}

【代码注释】addCart 把"加购 + 跳转"编排成三步严格有序的流程。第一步先把商品信息(数量、规格、...this.skuInfo 展开的全部字段)写进 sessionStorage,用作跨页传参的载体;第二步 await 加购接口——这个 await 是整段的关键,没有它接口尚未返回就跳转,会出现"成功页显示了、服务端还没存"的不一致;第三步接口成功后才 router.push。用 ...this.skuInfo 而非逐字段手写,既简洁又避免漏字段。市面应用:所有"提交后跳转结果页"的业务(下单、支付、报名)都遵循"先 storage 暂存 → await 确认 → 再跳转"的编排,保证页面与服务端状态一致。

<!-- src/pages/AddCartSuccess/index.vue -->
<template>
  <div class="cart-complete-wrap">
    <div class="cart-complete">
      <h3><i class="iconfont icon-success"></i>商品已成功加入购物车!</h3>
      <div class="goods">
        <div class="left-good">
          <div class="left-pic">
            <img width="60" height="60" :src="cdnBase + addCartInfo.skuDefaultImg">
          </div>
          <div class="right-info">
            <p class="title">{{ addCartInfo.skuName }}</p>
            <!-- 展示已选规格:遍历属性列表,找到 isChecked==='1' 的规格值 -->
            <p v-for="item in addCartInfo.attrList" :key="item.id" class="attr">
              {{ item.saleAttrName }}:{{ item.spuSaleAttrValueList.find(v => v.isChecked === '1').saleAttrValueName }}
            </p>
            <p class="attr">数量:{{ addCartInfo.buyNum }}</p>
            <p class="attr">价格:{{ addCartInfo.price }}</p>
          </div>
        </div>
        <div class="right-gocart">
          <a @click.prevent="$router.go(-1)" href="#" class="sui-btn">查看商品详情</a>
          <router-link to="/cart" class="sui-btn btn-danger">去购物车结算</router-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'AddCartSuccess',
  data() {
    return {
      // 图片 CDN 前缀:接口常只返回相对路径,前端拼接统一域名
      cdnBase: 'https://cdn.example.com',
      // 从 sessionStorage 读取商品信息,若不存在则返回空对象
      // 这样即使用户直接访问此页面,也不会报错
      addCartInfo: sessionStorage.getItem('addCartInfo')
        ? JSON.parse(sessionStorage.getItem('addCartInfo'))
        : {}
    }
  }
}
</script>

【代码注释】

  • await this.$store.dispatch(...) 中的 await 至关重要。如果不加 await,接口还没调用完成就跳转了,服务端可能还没有保存商品,但用户已经在成功页了。加上 await 保证了"接口成功 → 跳转"的顺序。
  • ...this.skuInfo 使用展开运算符将 skuInfo 对象的所有属性合并进存储对象,比 name: this.skuInfo.name, img: this.skuInfo.img... 更简洁,也不会因为漏掉某个字段而出 bug。

【实战要点】

"加购成功页"跨页传参的三种方案权衡

方案 刷新是否保留 容量限制 适用场景
路由 query(?name=xxx 保留,但 URL 暴露在地址栏 受 URL 长度限制(图片 URL 易超) 短、可公开的参数(如 id)
Vuex(内存) 刷新即丢(store 重建) 同一会话内、不刷新的临时数据
sessionStorage 同标签页刷新保留,关闭即清 约 5MB 跨页传商品快照(本文选用)

之所以选 sessionStorage 而非 Vuex:成功页是独立路由,用户刷新成功页时 Vuex 会被重建、数据丢失,而 sessionStorage 在同一标签页内刷新仍在。读取时务必加 sessionStorage.getItem(...) ? JSON.parse(...) : {} 的兜底,避免用户直接访问 URL 时 JSON.parse(null) 抛错(参见 MDN sessionStorage)。另一个关键顺序:await 加购接口、再 router.push,否则会出现"到了成功页、服务端却没存上"的不一致。

【本章小结】

购物车数据管理的核心是三层解耦:API 层负责请求,Store 层负责数据持久化与业务逻辑,组件层负责展示与用户交互。组件不直接调用 API,而是通过 dispatch 触发 action,让数据流向单向、可追踪。

文件 职责 不该做的事
API 层 api/cart.js 封装请求路径与方法 不碰 store、不碰 DOM
Store 层 store/cart.js 异步编排 + 数据持久化 不直接操作组件
组件层 Detail/Cart.vue 渲染 + 用户交互 不直接调 API、不直接改 state

记忆口诀:“API 只发请求,store 只管数据,组件只画界面;dispatch 上行、state 下行,绝不抄近路”

【面试考点】

Q:Vuex 的 action 中为什么可以 return 一个 Promise?

A:action 本身返回的就是一个 Promise(因为 async 函数总是返回 Promise)。当组件中 dispatch 一个 action 时,返回值就是这个 Promise。因此组件可以用 await this.$store.dispatch(...) 等待 action 完成,并在 .catch()try/catch 中处理错误。这是组件感知异步操作结果的标准方式,比在 action 里直接修改组件状态更符合单向数据流的原则。


四、选中状态管理:全选与单选联动

4.1 名词解释

isChecked:购物车商品的选中状态字段。在本项目中,服务端使用数字类型:1 表示选中,0 表示未选中(注意不是 Boolean 的 true/false)。

乐观更新(Optimistic Update):接口调用后,不等待服务端确认,直接修改本地 state,若接口失败则回滚。用户体验好,但需要处理回滚逻辑。

悲观更新(Pessimistic Update):接口调用成功后,重新从服务端拉取最新数据更新本地 state。数据与服务端强一致,但每次操作都有额外的网络请求。

4.2 两种更新策略的对比

在这里插入图片描述

【代码注释】该图左红右绿并排两种状态同步策略。红色「悲观更新」:点击 → 等接口成功 → dispatch 重新拉取整个 cartList → 服务端数据覆盖本地 → UI 才更新;它的代价是用户每次勾选都要等一个网络往返,UI 有可感知延迟,但数据与服务端强一致。绿色「乐观更新」:点击后立即 commit 翻转本地 isChecked、UI 零延迟响应,再后台调接口;成功就保持,失败则回滚到操作前的快照并提示。注意绿色方案里那条红色「回滚」分支——这是乐观更新的灵魂:多家工程团队都强调 rollback 是乐观 UI 不可省略的部分,省了它一旦接口失败,UI 就会和服务端永久错位。市面应用:点赞、收藏、购物车勾选这类"高频、可逆、失败率低"的操作普遍用乐观更新;而支付、下单这类"低频、不可逆、高风险"操作坚持悲观更新。

乐观更新的三个工程暗坑(深入)

把乐观更新做"对"远比做"出来"难。结合业界实践(Optimistic UI 的正确姿势与陷阱Duolingo 前端预测实践),有三个坑必须提前设计:

  1. 快照与回滚(Snapshot & Rollback):在 commit 翻转本地状态前,先把旧值存进局部变量;接口失败时用这个快照还原。“回滚是乐观 UI 的心脏”——没有快照就没有正确的回滚。
  2. 响应乱序(Out-of-order Responses):用户快速连点复选框,会并发多个请求,它们可能乱序返回。若用先返回的旧响应去回滚,UI 会被还原成一个早已过期、不代表用户当前意图的值。业界标准对策是请求标识(request identity):每次乐观变更分配一个递增 id,只允许"最新一次请求"提交成功或回滚,过期响应直接丢弃。
  3. 接口幂等(Idempotency):乐观更新常伴随重试,后端必须容忍重复提交——用 PUT 或在 POST 里带唯一 actionId,保证同一操作发两次结果一致。

一句话总结业界共识:先把后端安全网(幂等键、版本号/ETag、409 冲突返回、settle 后 re-sync)铺好,再上乐观渲染;顺序反了,只会把后端日志里的数据不一致搬到用户眼前的像素上。

4.3 方案一:悲观更新实现

// store/cart.js
const actions = {
  async getCartIsCheckedByIdAsync({ dispatch }, { skuID, isChecked }) {
    const res = await getCartIsCheckedById(skuID, isChecked)
    if (res.code === 200) {
      // 直接重新拉取购物车列表,用服务端数据覆盖本地
      dispatch('getCartListAsync')
    } else {
      alert('操作失败:' + res.message)
    }
  }
}

【代码注释】这是悲观更新的 action:切换接口成功后,不自己改本地数据,而是 dispatch('getCartListAsync') 把整个购物车重新拉一遍,用服务端的"权威版本"覆盖本地。好处是绝对一致——哪怕后端在切换选中时附带改了别的字段(如重新计算了优惠),本地也能拿到最新结果;代价是多一次完整列表请求且 UI 有等待延迟。注意它解构的是 dispatch 而非 commit,因为重新拉取本身又是一个异步 action。市面应用:涉及金额联动、库存校验、优惠重算的勾选(如"勾选后触发满减重算"),更适合悲观更新,避免本地算错总价误导用户。

4.4 方案二:乐观更新实现(推荐)

// store/cart.js
const mutations = {
  // 根据商品 ID 更新选中状态
  UP_CART_LIST_CHECKED_BY_ID(state, { skuID, isChecked }) {
    // 在购物车列表中找到目标商品
    const info = state.cartList.find(v => v.skuId === skuID)
    // 找到则更新 isChecked,Vue 响应式系统会自动触发视图更新
    if (info) info.isChecked = isChecked
  }
}

const actions = {
  async getCartIsCheckedByIdAsync({ commit }, { skuID, isChecked }) {
    const res = await getCartIsCheckedById(skuID, isChecked)
    if (res.code === 200) {
      // 接口成功:通过 mutation 直接更新本地 state
      // 不需要重新请求服务端,减少一次网络往返
      commit('UP_CART_LIST_CHECKED_BY_ID', { skuID, isChecked })
    } else {
      alert('操作失败:' + res.message)
      // 失败时不 commit,state 保持原样,UI 不变
    }
  }
}

【代码注释】这是乐观更新的 mutation + action 组合。mutation UP_CART_LIST_CHECKED_BY_IDfind 拿到目标商品的对象引用,直接改它的 isChecked——因为该属性在加入响应式系统时已被 defineProperty 处理,改它会触发 setter、自动刷新视图(这正是面试高频题:改已存在属性能响应、新增属性不能)。action 里只有接口 code === 200commit,失败则什么都不做、state 保持原样。要补的工程细节是前文提到的三个暗坑:生产级实现应在 commit 前存旧值快照、失败时回滚,并用请求标识丢弃乱序响应。市面应用:商品勾选、消息已读、开关类设置普遍用这套"找引用改属性 + 失败保持原样"的乐观模式。

<!-- src/pages/Cart/index.vue -->
<template>
  <li class="cart-list-con1">
    <input
      type="checkbox"
      :checked="item.isChecked === 1"
      @change="$store.dispatch('cart/getCartIsCheckedByIdAsync', {
        skuID: item.skuId,
        isChecked: item.isChecked === 1 ? 0 : 1
      })"
      name="chk_list"
    >
  </li>
</template>

【代码注释】

  • :checked="item.isChecked === 1":注意这里是严格等于 1(数字),而不是 === true。服务端返回的 isChecked 是数字类型,如果用布尔比较会永远为 false,复选框无法正确初始化选中状态。
  • isChecked: item.isChecked === 1 ? 0 : 1:点击时切换状态。当前是 1(选中)则变为 0(取消),当前是 0 则变为 1(选中)。逻辑清晰,一行代码完成切换。
  • 方案二的核心优势:用户点击后 UI 立即响应(因为本地 state 已经更新),不需要等待网络请求返回。这就是乐观更新的"乐观"之处:乐观地认为请求会成功,先更新 UI,再等结果。

【实战要点】

何时选择乐观更新,何时选择悲观更新?

场景 推荐策略 原因
切换选中状态(高频操作) 乐观更新 操作可逆,失败概率低,用户需要即时反馈
批量删除 悲观更新 操作不可逆,需要服务端确认后才能更新 UI
支付/结算 悲观更新 涉及金额,必须以服务端为准
修改商品数量 防抖 + 乐观更新 频繁触发,需要防抖减少请求;本地即时响应提升体验

【本章小结】

选中状态的核心是"先改本地还是先等接口"这道选择题。两种策略的本质对比:

维度 乐观更新(推荐用于勾选) 悲观更新(推荐用于结算)
UI 响应 立即(先 commit 本地) 有等待(先等接口再拉取)
数据一致性 最终一致(需回滚兜底) 强一致(服务端为准)
网络往返 失败才回滚,省一次重拉 每次都多一次完整重拉
实现复杂度 高(需快照、回滚、防乱序、幂等) 低(成功后无脑重拉)
适用操作 高频、可逆、低失败率(勾选/点赞) 低频、不可逆、高风险(支付/下单)

技术细节上,本章还揭示了 Vue 2 响应式的经典规则:通过 find 拿到已存在对象的引用改其 isChecked 能触发更新(属性已被 defineProperty 劫持),新增属性则不能(需 Vue.set)。

记忆口诀:“乐观先画再问,悲观先问再画;高频可逆走乐观,涉钱不可逆走悲观;乐观三件套——快照、回滚、防乱序”

【面试考点】

Q:Vue 2 中,修改数组对象的属性为什么能触发响应式更新?

A:Vue 2 的响应式是通过 Object.defineProperty 对每个属性做 getter/setter 劫持实现的。当我们通过 state.cartList.find(v => v.skuId === skuID) 找到一个对象引用并修改其 isChecked 属性时,这个属性在对象被加入响应式系统时已经被 defineProperty 处理过,所以修改它会触发 setter,进而触发视图更新。但是,如果给对象新增一个之前不存在的属性(如 item.newField = value),则无法触发响应,因为新属性没有经过 defineProperty 处理,此时应该使用 Vue.set(item, 'newField', value)


五、删除操作:单删与批量删

5.1 名词解释

filter:JavaScript 数组方法,返回一个新数组,包含所有通过回调函数测试的元素。不修改原数组。在 Vuex mutation 中,用 state.cartList = state.cartList.filter(...) 实现删除某个元素。

skuId:库存单位 ID,每个商品规格的唯一标识符。删除购物车商品时,传递 skuId 给服务端,告知删除哪个具体规格的商品。

批量接口(batch API):一次请求删除多个资源的接口设计模式。相比逐个发送删除请求,批量接口减少了 HTTP 请求次数,降低了服务端压力。

5.2 单个商品删除

删除操作是不可逆的,因此在选择更新策略时,有两种思路:重新拉取(数据强一致)或本地过滤(减少请求)。本项目选择本地过滤方案,因为删除本身是确定性操作,不需要服务端告知删后的状态。

// src/api/cart.js
// DELETE 请求删除单个商品
export const deleteCartListById = (skuId) =>
  shopRequest.delete(`/cart/deleteCart/${skuId}`)

【代码注释】这是 API 层封装:用 RESTful 风格的 DELETE 方法删除资源,skuId 拼进路径表示"删哪条"。把接口集中封装在 api/cart.js 而非散落在组件里,好处是请求路径、方法、参数约定只有一处,改后端契约时不必全局搜索。注意模板字符串里的 ${skuId}——这是 ES6 字符串插值,比 '/cart/deleteCart/' + skuId 更易读。市面应用:中后台系统的 CRUD 接口几乎都按"资源 + HTTP 动词"封装成这样的一行函数,配合 TypeScript 还能给参数/返回值加类型。

// store/cart.js
const mutations = {
  // 通过 filter 生成新数组(不包含目标 skuId 的商品)
  // 赋值给 state.cartList,触发响应式更新
  DELETE_CART_LIST_BY_ID(state, skuId) {
    state.cartList = state.cartList.filter(v => v.skuId !== skuId)
  }
}

const actions = {
  async deleteCartListByIdAsync({ commit }, skuId) {
    // 先调用删除接口
    await deleteCartListById(skuId)
    // 接口成功后,本地同步删除
    commit('DELETE_CART_LIST_BY_ID', skuId)
  }
}

【代码注释】单删走"先接口、后本地"的乐观式同步:action 先 await 删除接口,成功后再 commit 让 mutation 本地删除。mutation 用 filter 生成一个不含目标 skuId 的新数组并整体赋值给 state.cartList——赋值操作被 setter 劫持,触发响应式更新。为什么用 filter 重新赋值而非 splice?两者都能触发响应(splice 被 Vue 重写过),但 filter 不必先 findIndex、语义"留下不等于它的"更直白。市面应用:列表型数据的删除(待办、收藏、消息)普遍用 filter 重新赋值,代码意图清晰且天然不可变(immutable),利于排查。

<!-- 购物车列表项 -->
<li class="cart-list-con7">
  <a
    href="#none"
    class="sindelet"
    @click.prevent="$store.dispatch('cart/deleteCartListByIdAsync', item.skuId)"
  >删除</a>
</li>

【代码注释】模板里删除按钮直接 dispatch 删除 action,把当前行的 item.skuId 作为载荷传过去。@click.prevent.prevent 修饰符阻止 <a href="#none"> 的默认跳转,避免页面滚到顶部或 URL 出现 #none。组件层只负责"告诉 store 删哪条",具体怎么删(调接口 + 改本地)全交给 action——这就是视图层与数据层的职责分离。市面应用:列表项的"删除/编辑/置顶"操作按钮普遍采用"模板 dispatch + action 处理"的写法,组件保持薄、逻辑沉到 store,便于复用与测试。

5.3 批量删除选中商品

批量删除的关键是:从 state 中筛选出所有 isChecked === 1 的商品,提取它们的 skuId 数组,传给批量删除接口。

// src/api/cart.js
// POST 批量删除,参数为 skuId 数组(请求体)
export const deleteCartListBatch = (data) =>
  shopRequest.post('/cart/batchDeleteCart', data)

【代码注释】批量删除用 POST 而非 DELETE,是因为要删除的 skuId 是一个数组、需放进请求体(部分服务端/网关对 DELETE 带 body 支持不一致,故约定走 POST)。批量接口的工程价值是把"删 N 条"从 N 次 HTTP 请求压缩成 1 次,既降低服务端压力,也避免逐条删除时部分成功部分失败的中间态。市面应用:购物车清空、批量审批、批量下架等"多选 + 一键操作"功能都依赖批量接口;设计上通常还会让后端返回每条的成功/失败明细,便于前端提示"3 条成功、1 条因已售罄删除失败"。

// store/cart.js
const actions = {
  async deleteCartListBatchAsync({ dispatch, state }) {
    // 操作前弹窗确认,防止误操作
    if (window.confirm('您确定要删除选中的商品吗?')) {
      // 从 cartList 中筛选已选中商品,提取 skuId 数组
      const selectedSkuIds = state.cartList
        .filter(v => v.isChecked === 1)
        .map(item => item.skuId)

      // 调用批量删除接口
      await deleteCartListBatch(selectedSkuIds)

      // 批量删除后,重新拉取购物车数据(悲观更新)
      // 批量删除涉及多条数据,本地手动过滤更复杂,直接重拉更安全
      await dispatch('getCartListAsync')
    }
  }
}

【代码注释】批量删除 action 有三个关键设计点:① 先 window.confirm 二次确认,因为删除不可逆、批量误删代价大;② 用 filter(isChecked === 1).map(skuId) 这条"过滤 + 映射"链从 cartList 里提取所有选中项的 ID 数组;③ 删除成功后重新拉取(悲观更新)而非本地过滤——因为一次删多条,本地手动维护多个删除点容易出错,直接重拉一份权威数据更稳。这里解构了 state 以读取列表、dispatch 以触发重拉。市面应用:涉及多条、不可逆的批量操作(批量删除、批量归档)几乎都采用"确认 → 批量接口 → 重新拉取"的悲观链路,用一次额外请求换取数据强一致。

<div class="option">
  <a href="#none" @click="$store.dispatch('cart/deleteCartListBatchAsync')">
    删除选中的商品
  </a>
</div>

【代码注释】

  • 单删使用乐观更新(本地过滤):因为是单条数据,本地操作逻辑简单,直接 filter 即可。
  • 批量删除使用悲观更新(重新拉取):因为涉及多条数据,本地手动维护多个删除状态比重新拉取更复杂,且更容易出错。
  • .filter(v => v.isChecked === 1).map(item => item.skuId) 是常见的"过滤 + 映射"链式操作,语义清晰:先找到所有已选商品,再提取它们的 ID。

【实战要点】

filter vs splice 在 Vuex mutation 中的选择

在 Vuex mutation 中删除数组元素,推荐使用 filter 返回新数组并赋值,而不是 splice 原地修改:

// 推荐:返回新数组,语义清晰,不修改原数组
state.cartList = state.cartList.filter(v => v.skuId !== skuId)

// 不推荐:原地修改,需要手动找到 index,代码更繁琐
const index = state.cartList.findIndex(v => v.skuId === skuId)
if (index !== -1) state.cartList.splice(index, 1)

【代码注释】这组对比两种删数组元素的写法。filter 版"留下不等于目标的元素"再整体赋值,一行表意清晰、且产出新数组不改原数据(immutable 友好);splice 版需先 findIndex 拿下标、再判越界、再原地删,三步且易写错边界。两者都能触发响应(splice 是 Vue2 重写过的变异方法之一,赋值则被 setter 劫持),所以选择标准是可读性而非能否响应。市面应用:Vuex/Pinia 里删列表项普遍偏好 filter 重新赋值;只有在超大数组、对性能极敏感、且确定只删一项时,才会用 splice 省掉一次数组拷贝。

【本章小结】

删除操作分单删与批量删,二者在更新策略上做了不同取舍:

维度 单个删除 批量删除
HTTP 方法 DELETE(skuId 拼路径) POST(skuId 数组进 body)
更新策略 乐观(本地 filter 删一项) 悲观(删完重新拉取全量)
请求次数 1 次 1 次(N 条压成一次)
二次确认 一般不需要 window.confirm 防误删
为何如此 单条删确定、本地维护简单 多条删本地维护易错,重拉更稳

批量删的数据提取链是模板范式:filter(isChecked === 1).map(skuId) —— 先筛选已选、再映射出 ID 数组。

记忆口诀:“单删 DELETE 本地 filter,批删 POST 数组进 body;单条乐观省请求,多条悲观重拉求稳;批量必加 confirm 防手抖”

【面试考点】

Q:Vue 2 中哪些数组方法可以触发响应式更新?

A:Vue 2 对以下数组变异方法(mutating methods)进行了拦截,调用它们会触发视图更新:pushpopshiftunshiftsplicesortreverse。这些方法都会修改原数组。而 filtermapslice 等返回新数组的方法,本身不会触发更新,但将返回的新数组赋值给响应式属性(如 state.cartList = state.cartList.filter(...))会触发更新,因为赋值操作被 setter 劫持了。


六、总价计算:getters 的价值

6.1 名词解释

Getters:Vuex 的计算属性。类比 Vue 组件中的 computed,getter 基于 state 计算派生数据,并且结果会被缓存。只有当它依赖的 state 发生变化时,getter 才会重新计算。

累加器(Accumulator)reduce 方法的第一个参数,在每次迭代中接收上一次的返回值,用于累积计算结果。是函数式编程的核心概念之一。

派生数据(Derived Data):从原始数据计算得出的数据。不应该直接存入 state,而应该通过 getter 按需计算,这样原始数据和派生数据始终保持同步,不会出现数据不一致的问题。

6.2 为什么不在组件中直接计算

理论上,可以在每个组件中用 computed 属性计算总价。但这会带来一个问题:如果多个组件都需要这个数据(比如页头显示购物车数量),就需要在每个组件中重复写相同的计算逻辑。

Vuex getters 的优势:

  1. 集中管理:计算逻辑只写一次,多个组件共享
  2. 缓存优化:依赖的 state 不变则不重新计算,避免重复遍历大数组
  3. 测试友好:可以单独对 getter 进行单元测试

6.3 总价计算的完整实现

// store/cart.js
const getters = {
  /**
   * 计算已选中商品的数量和总价
   * @param {Object} state - 解构 state,直接使用 cartList
   * @returns {{ checkedNum: number, checkedPrice: number }}
   */
  getCountResult({ cartList }) {
    let checkedNum = 0   // 已选中的商品种数(不是总件数)
    let checkedPrice = 0 // 已选中商品的总价

    cartList.forEach(item => {
      if (item.isChecked === 1) {
        checkedNum++
        // 小计 = 单价 × 数量
        checkedPrice += item.skuPrice * item.skuNum
      }
    })

    return {
      checkedNum,
      checkedPrice
    }
  }
}

【代码注释】getCountResult 是核心 getter:遍历 cartList,只对 isChecked === 1 的商品累加件数与小计(单价 × 数量),返回 { checkedNum, checkedPrice }。把这段计算放进 getter 而非组件,是为了让"总价/件数"这份派生数据集中一处、被多个组件(购物车页、结算条、页头角标)共享,并复用 Vuex 基于 computed 的缓存——cartList 不变就不重算,哪怕列表有上千项。函数签名用 { cartList } 解构,一眼能看出它依赖哪些 state。市面应用:购物车总价、订单合计、已选数量这类"由列表派生的汇总值"标准做法都是 getter,既避免多组件重复算,又靠依赖追踪保证数据永远与源同步。

<!-- src/pages/Cart/index.vue -->
<template>
  <div class="cart-tool">
    <div class="money-box">
      <!-- 已选择件数 -->
      <div class="chosed">
        已选择 <span>{{ getCountResult.checkedNum }}</span> 件商品
      </div>
      <!-- 总价:通过过滤器格式化为货币显示 -->
      <div class="sumprice">
        <em>总价(不含运费):</em>
        <i class="summoney">{{ getCountResult.checkedPrice | currency(2, '¥') }}</i>
      </div>
      <div class="sumbtn">
        <a class="sum-btn" href="###" target="_blank">结算</a>
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'Cart',
  computed: {
    // 将 cart 模块的 getCountResult getter 映射为本地计算属性
    ...mapGetters('cart', ['getCountResult'])
  }
}
</script>

【代码注释】组件用 mapGetters('cart', ['getCountResult']) 把命名空间模块 cart 的 getter 映射成本地 computed,模板里就能直接写 getCountResult.checkedNum,无需 this.$store.getters['cart/getCountResult'] 这种冗长写法。第一个参数 'cart' 是模块命名空间(前文 namespaced: true 的回报)。模板里的 | currency(2, '¥') 是 Vue2 过滤器,把金额格式化成带符号、两位小数的货币串。市面应用mapState/mapGetters/mapActions 这组辅助函数是 Vuex 项目的标配,用展开运算符并入 computed/methods,让组件以最少样板代码接入全局状态。

6.4 getters 的缓存机制原理

Vuex getters 的缓存基于 Vue 的 computed 实现。当组件访问 getter 时,实际上访问的是一个 computed 属性。Vue 会追踪这个 computed 的依赖(即访问了哪些响应式数据),只要这些依赖没有变化,getter 的返回值就从缓存中读取,不会重新执行计算函数。

第一次访问 getCountResult:
  → 执行 forEach 遍历 cartList
  → 计算出 { checkedNum: 2, checkedPrice: 1198 }
  → 缓存结果
  → 返回结果

第二次访问(cartList 未变化):
  → 检测到依赖未变化
  → 直接从缓存返回 { checkedNum: 2, checkedPrice: 1198 }
  → 不执行 forEach(即使购物车有 1000 个商品)

cartList 发生变化(如取消选中某商品):
  → 缓存失效
  → 下次访问时重新执行 forEach
  → 更新缓存

【代码注释】

  • getCountResult({ cartList }) 使用了 ES6 解构语法直接从 state 提取 cartList,比 state.cartList 更简洁,且在函数签名处就能看出 getter 依赖了哪些 state 字段。
  • checkedNum++ 计算的是选中的商品种数(每种商品算 1 件,不管数量是多少)。如果需要计算总件数(所有选中商品的数量之和),应该改为 checkedNum += item.skuNum。实际业务中通常展示的是"已选 X 件",指的是种数,而不是总数量。

【实战要点】

使用 reduce 重构 forEach 计算

上面的实现使用了 let 声明的可变变量,可以用 reduce 改写为更函数式的风格:

getCountResult({ cartList }) {
  return cartList
    .filter(item => item.isChecked === 1)
    .reduce(
      (acc, item) => {
        acc.checkedNum++
        acc.checkedPrice += item.skuPrice * item.skuNum
        return acc
      },
      { checkedNum: 0, checkedPrice: 0 }  // 初始累加器
    )
}

【代码注释】这是用 reduce 把前面的 forEach + 可变变量改写成函数式风格:先 filter 出选中项,再用 reduce 把它们"折叠"进一个累加器对象 { checkedNum, checkedPrice },第二个参数 { checkedNum: 0, checkedPrice: 0 } 是累加器初值。它没有 let 外部变量,输入相同输出必定相同,更易测试、无副作用。两版性能基本一致,选哪种看团队口味。市面应用:购物车合计、报表汇总、把数组规约成统计对象(如按状态分组计数)等"多字段累加"场景,reduce 是函数式社区的标准解法。

补一个面试常被追问的细节:getter 的缓存只对"无参 getter"自动生效。一旦写成返回函数的"方法式 getter"(如 getById: state => id => state.list.find(...)),每次调用都会重新执行、不再缓存(Vue 官方对 computed 缓存的说明)。原因是带参 getter 等于每次传入不同入参,框架无法判定结果可复用。若这类带参派生确实是性能瓶颈,工程上会引入 reselect 之类的记忆化(memoize)库手动缓存。getCountResult 是无参 getter,因此能享受到自动缓存。

【本章小结】

总价/件数这类"由列表派生的汇总值",标准做法是放进 getter 而非组件 computed,核心收益来自缓存与共享:

维度 组件内 computed Vuex getter 方法式 getter(带参)
缓存 有(依赖不变不重算) 有(同 computed 机制) ,每次调用都执行
复用范围 仅当前组件 多组件经 mapGetters 共享 多组件共享,但不缓存
适用 单组件私有派生值 全局共享、无参的汇总值 需按入参查询(如按 id 取)

Vuex 官方明确:“getter 的返回值会根据它的依赖被缓存起来,只有依赖值发生改变才会被重新计算”——底层就是复用 Vue 的 computed 响应式追踪。

记忆口诀:“派生汇总进 getter,无参才享自动缓存;多组件共享靠 mapGetters,一带参就退化成方法、缓存失效”

【面试考点】

Q:Vuex getters 和组件 computed 有什么区别?

A:本质上都是基于 Vue 响应式系统的缓存计算属性,工作原理相同。区别在于:

  • computed 属于单个组件,只能在该组件中使用
  • getters 属于 Vuex store,可以通过 mapGetters 在任意组件中访问,实现了计算逻辑的复用
  • getters 接受 state 和其他 getters 作为参数,可以组合多个派生数据;computed 只能访问组件自身的 data 和其他 computed

七、购物车数量同步策略

7.1 名词解释

防抖(Debounce):在事件被触发后,等待一段时间再执行函数。如果在等待期间事件再次触发,重新计时。用于处理频繁触发的事件(如输入、窗口 resize、滚动)。

节流(Throttle):在一段时间内,无论事件触发多少次,函数只执行一次。用于固定频率执行的场景(如游戏帧渲染、无限滚动加载)。

lodash:一个流行的 JavaScript 工具库,提供了大量实用函数,包括 _.debounce_.throttle_.cloneDeep 等。在生产项目中,使用成熟的库实现防抖比手写更可靠。

7.2 数量加减的三种实现方案

购物车数量输入框存在一个经典问题:用户输入"1、0、0"准备输入 100 时,如果每次输入都立即发请求,会依次发送 skuNum=1skuNum=10skuNum=100 三个请求,不仅浪费资源,还可能因为接口乱序导致最终数量错误。

在这里插入图片描述

【代码注释】该图把"改数量"拆成两段。上半段是三种触发方案的取舍:灰色 change(失焦触发一次)实现最简单但体验差(用户必须点别处才生效);黄色「原生防抖」要自己 setTimeout/clearTimeout 管理 timer;绿色「lodash 防抖」代码最简洁、生产推荐。下半段是公共校验链:黄色菱形用正则验证数值是否落在 1-200,合法走绿色更新 buyNum、再 dispatch 紫色接口同步(并携带最新请求标识防乱序),不合法走红色把输入框还原成上一个有效值。为什么必须防抖?用户想输 100 时会依次敲出 1、10、100,若每次 input 都发请求,就会连发 skuNum=1/10/100 三个请求,既浪费又可能因乱序让最终数量定格在错误值。市面应用:购物车数量框、搜索联想、表单实时校验,都用"防抖 + 范围校验 + 还原非法输入"这套组合拳控制请求频率。

7.3 三种方案的代码对比

<script>
import { debounce } from 'lodash'

// 验证商品数量的正则:1-200 的正整数
const goodsNumReg = /^[1-9]\d?$|^1\d{2}$|^200$/

export default {
  data() {
    return {
      buyNum: 1,
      timer: null  // 原生防抖需要手动管理 timer
    }
  },
  methods: {
    // 方案一:@change 事件(失去焦点后触发)
    // 简单但体验差:用户必须点击别处才会触发
    upBuyNum(e) {
      const num = Number(e.target.value.trim())
      if (goodsNumReg.test(num)) {
        this.buyNum = num
      } else {
        // 不合法:还原输入框为当前有效值
        e.target.value = this.buyNum
      }
    },

    // 方案二:@input + 原生防抖(手动实现)
    // 每次输入都重置 timer,停止输入 1000ms 后执行
    upBuyNum2(e) {
      if (this.timer) clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        const num = Number(e.target.value.trim())
        if (goodsNumReg.test(num)) {
          this.buyNum = num
        } else {
          e.target.value = this.buyNum
        }
      }, 1000)
    },

    // 方案三:@input + lodash debounce(生产推荐)
    // 写法注意:必须用 function 而不是箭头函数
    // 箭头函数没有自己的 this,导致 this.buyNum 访问错误
    upBuyNum3: debounce(function (e) {
      const num = Number(e.target.value.trim())
      if (goodsNumReg.test(num)) {
        this.buyNum = num
        // 更新后同步到 Vuex(若是购物车页面)
        // this.$store.dispatch('cart/updateCartNumAsync', { skuId: ..., skuNum: num })
      } else {
        e.target.value = this.buyNum
      }
    }, 1000),

    // 点击加/减按钮
    changeBuyNum(e) {
      if (e.target.classList.contains('mins')) {
        if (this.buyNum === 1) return  // 最小值边界
        this.buyNum--
      } else if (e.target.classList.contains('plus')) {
        if (this.buyNum === 200) return  // 最大值边界
        this.buyNum++
      }
      // 点击按钮后立即同步(不需要防抖,因为每次点击只触发一次)
    }
  }
}
</script>

【代码注释】这段并排三种数量输入方案,核心差异在"何时触发 + 谁管节流"。方案一 @change 失焦才触发一次,最简单但用户得点别处才生效;方案二 @input + 手写 setTimeout/clearTimeout,每次输入重置 timer、停 1000ms 才执行,能防抖但要自己管理 this.timer;方案三 @input + lodash.debounce 最简洁。方案三必须用普通 function 而非箭头函数——debounce 会把 this 绑成调用时的组件实例,箭头函数没有自己的 this、会继承到 export default 对象上,导致 this.buyNumundefined,这是高频踩坑点。三个方案共用 goodsNumReg 正则做 1-200 范围校验,非法时把输入框还原成上一个有效值。加减按钮 changeBuyNum 则不防抖:点击有天然物理间隔、且每次都是明确意图,并在 1/200 处做了边界拦截。市面应用:数量步进器、星级评分、表单数字框普遍是"按钮即时 + 输入框防抖"的混合策略。

7.4 购物车中的数量 Vuex 同步

当购物车页面需要修改商品数量时,修改需要同步到服务端。整个流程与切换选中状态类似:

// store/cart.js
const mutations = {
  // 本地更新商品数量(乐观更新)
  UP_CART_ITEM_NUM(state, { skuId, skuNum }) {
    const item = state.cartList.find(v => v.skuId === skuId)
    if (item) item.skuNum = skuNum
  }
}

const actions = {
  // 防抖应该在组件层做,这里只负责接口调用和状态更新
  async updateCartNumAsync({ commit }, { skuId, skuNum }) {
    // 这里的接口复用"加入购物车"接口,传入新的数量即可
    const res = await postAddToCart(skuId, skuNum)
    if (res.code === 200) {
      commit('UP_CART_ITEM_NUM', { skuId, skuNum })
    } else {
      alert('数量修改失败:' + res.message)
    }
  }
}

【代码注释】

  • debounce(function(e) {...}, 1000) 中必须使用普通函数而不是箭头函数。原因:lodash 的 debounce 会将 this 绑定到调用时的上下文(即 Vue 组件实例)。箭头函数没有自己的 this,会继承外层作用域的 this(此处是 export default 对象,不是组件实例),导致 this.buyNumundefined
  • 点击加减按钮不需要防抖,因为按钮点击有自然的物理间隔,不会像键盘输入那样每秒触发十几次。但如果用户疯狂连续点击,可以考虑在按钮点击 action 中实现节流或请求队列。

【实战要点】

防抖 vs 节流在购物车场景的选择

场景 策略 原因
数量输入框(键盘输入) 防抖 1000ms 用户打字期间不发请求,停止输入后发一次
加减按钮(点击事件) 无需防抖 每次点击都是有意的操作,不应该被合并
全局搜索框 防抖 300-500ms 平衡实时性和请求频率
窗口 resize 节流 固定频率重新计算布局

【本章小结】

购物车数量同步的最佳实践是:防抖在组件层处理(用 lodash debounce 装饰事件处理函数),Vuex action 层只负责接口调用和 state 更新,不关心防抖逻辑。这样职责清晰:组件负责用户交互的频率控制,store 负责数据的一致性保证。

【面试考点】

Q:防抖和节流有什么区别?各自适用什么场景?

A:

  • 防抖:事件触发后等待固定时间再执行,期间再触发则重新计时。本质是"最后一次触发才执行"。适合场景:搜索框输入、表单验证、窗口 resize 后重新布局。
  • 节流:固定时间间隔内只执行一次,无论触发多少次。本质是"定时执行"。适合场景:页面滚动、mousemove 追踪、按钮防重复点击。

两者都是减少函数执行频率的手段,但策略不同:防抖关注"最终状态",节流关注"固定频率"。


总结

状态设计回顾

本文实现的购物车涵盖了 Vuex 状态管理的核心场景,整体架构如下:

在这里插入图片描述

【代码注释】该图自左向右串起购物车的三层架构。蓝色「用户标识层」:UUID v4 生成 → localStorage 持久化 → 请求头注入,解决"购物车归属谁";它向数据层输出"携带标识的请求"。绿色「数据层(store/cart.js)」是核心:Actions 调接口后 commitMutationsMutations 改写 StateState 派生出 Getters(总价/件数),完整复刻了前文图一的单向流。灰色「视图层(Cart/index.vue)」:列表渲染派生出选中、删除等交互,总价展示直接订阅 Getters。三层之间只有两条跨层连线——标识层→Actions、State/Getters→视图层,说明各层职责单一、依赖方向清晰:标识层不碰业务数据,数据层不碰 DOM,视图层不碰接口。市面应用:这套"标识层 + Vuex/Pinia 数据层 + 组件视图层"的分层是中后台与电商前端的通用骨架,换成任何业务(订单、收藏、消息中心)都能照搬。

高频面试题速查

问题 核心答案
Vuex mutation 和 action 的区别 mutation 同步修改 state,action 处理异步后 commit mutation
为什么 mutation 必须同步 保证 devtools 能精确追踪每次状态变化,异步会导致变化顺序不确定
getters 的缓存机制 基于 Vue computed 实现,依赖 state 不变则不重新计算
localStorage vs sessionStorage 前者持久化(关闭不消失),后者会话级(关闭标签页消失)
乐观更新 vs 悲观更新 前者先改本地再等接口,后者等接口成功再改本地
UUID v4 的原理 基于密码学安全随机数生成 128 位唯一标识,碰撞概率极低
Vue 2 数组响应式的注意事项 直接通过下标修改或 length 修改不触发响应,用 splice/Vue.set 代替
防抖 vs 节流 防抖等最后一次触发,节流按固定频率执行

延伸设计思考

1. 购物车数据应该存在服务端还是客户端?

本文的设计中,购物车数据存在服务端,每次进入购物车页面都从接口拉取。这保证了多端同步(手机端加入的商品,PC 端也能看到)。另一种方案是存在 localStorage,但需要自行实现多端同步逻辑,复杂度更高。

2. 用户登录后如何合并匿名购物车?

标准方案:用户登录时,将当前 userTempId 对应的匿名购物车合并到账号购物车,然后清除本地 userTempId,后续请求改为携带 userIdtoken。这个"购物车合并"逻辑通常由服务端处理,前端只需要在登录接口中同时传入 userTempId 和登录凭证。

3. 购物车数量频繁修改的请求队列问题

当用户快速点击 + 按钮 10 次时,即使加了防抖,如果用户每次点击间隔都超过防抖时间,仍会发出 10 个请求。更健壮的方案是维护一个本地请求队列,记录最新的目标数量,新的修改请求会取消正在进行的旧请求(使用 Axios 的 CancelToken),始终只保证最新的请求在途。

4. 离线状态下的购物车操作

进阶方案是实现离线操作队列(offline queue):当网络断开时,用户的操作被记录到队列,网络恢复后按顺序同步到服务端。这在移动端场景(地铁、电梯中断网)尤为重要,是 PWA(Progressive Web App)的核心特性之一。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐