Vue购物车功能完整实现
Vue 购物车状态管理完整实现:从用户标识到结算总价
本文以一个完整的电商购物车功能为主线,深入剖析 Vuex 模块化状态管理的工程实践。从 UUID 用户标识的生成与持久化,到选中状态的联动逻辑,从乐观更新与悲观更新的权衡,到 getters 缓存计算总价的底层原理,每一个技术决策背后都有明确的工程动机。
目录
- 零、导读与学习价值
- 一、购物车的核心状态设计
- 二、用户标识:UUID 与 localStorage 持久化
- 三、购物车数据的 Vuex 管理
- 四、选中状态管理:全选与单选联动
- 五、删除操作:单删与批量删
- 六、总价计算:getters 的价值
- 七、购物车数量同步策略
- 总结
零、导读与学习价值
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 接收 dispatch、await 接口后 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,状态变更不可追踪 | 一律走 dispatch → action → commit |
排查"购物车列表渲染不出来"时,按"接口是否返回 → 是否取了 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 文件。
【实战要点】
购物车数据为空的排查顺序:
- 检查是否真的往购物车里添加了商品(接口
/cart/addToCart是否调用成功) - 检查请求头是否携带了
userTempId(打开 Network 面板查看 Request Headers) - 检查
userTempId是否在不同请求间保持一致(如果每次请求都生成新的 UUID,服务端会认为是不同用户) - 检查接口返回的数据结构,确认正确访问
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)有两个致命问题:
- URL 长度有限制,商品图片 URL 本身就可能超过限制
- 用户刷新页面时,路由参数会触发组件重新创建,但如果是从 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 前端预测实践),有三个坑必须提前设计:
- 快照与回滚(Snapshot & Rollback):在
commit翻转本地状态前,先把旧值存进局部变量;接口失败时用这个快照还原。“回滚是乐观 UI 的心脏”——没有快照就没有正确的回滚。 - 响应乱序(Out-of-order Responses):用户快速连点复选框,会并发多个请求,它们可能乱序返回。若用先返回的旧响应去回滚,UI 会被还原成一个早已过期、不代表用户当前意图的值。业界标准对策是请求标识(request identity):每次乐观变更分配一个递增 id,只允许"最新一次请求"提交成功或回滚,过期响应直接丢弃。
- 接口幂等(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_ID 用 find 拿到目标商品的对象引用,直接改它的 isChecked——因为该属性在加入响应式系统时已被 defineProperty 处理,改它会触发 setter、自动刷新视图(这正是面试高频题:改已存在属性能响应、新增属性不能)。action 里只有接口 code === 200 才 commit,失败则什么都不做、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)进行了拦截,调用它们会触发视图更新:push、pop、shift、unshift、splice、sort、reverse。这些方法都会修改原数组。而 filter、map、slice 等返回新数组的方法,本身不会触发更新,但将返回的新数组赋值给响应式属性(如 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 的优势:
- 集中管理:计算逻辑只写一次,多个组件共享
- 缓存优化:依赖的 state 不变则不重新计算,避免重复遍历大数组
- 测试友好:可以单独对 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=1、skuNum=10、skuNum=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.buyNum 为 undefined,这是高频踩坑点。三个方案共用 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.buyNum是undefined。- 点击加减按钮不需要防抖,因为按钮点击有自然的物理间隔,不会像键盘输入那样每秒触发十几次。但如果用户疯狂连续点击,可以考虑在按钮点击 action 中实现节流或请求队列。
【实战要点】
防抖 vs 节流在购物车场景的选择
| 场景 | 策略 | 原因 |
|---|---|---|
| 数量输入框(键盘输入) | 防抖 1000ms | 用户打字期间不发请求,停止输入后发一次 |
| 加减按钮(点击事件) | 无需防抖 | 每次点击都是有意的操作,不应该被合并 |
| 全局搜索框 | 防抖 300-500ms | 平衡实时性和请求频率 |
| 窗口 resize | 节流 | 固定频率重新计算布局 |
【本章小结】
购物车数量同步的最佳实践是:防抖在组件层处理(用 lodash debounce 装饰事件处理函数),Vuex action 层只负责接口调用和 state 更新,不关心防抖逻辑。这样职责清晰:组件负责用户交互的频率控制,store 负责数据的一致性保证。
【面试考点】
Q:防抖和节流有什么区别?各自适用什么场景?
A:
- 防抖:事件触发后等待固定时间再执行,期间再触发则重新计时。本质是"最后一次触发才执行"。适合场景:搜索框输入、表单验证、窗口 resize 后重新布局。
- 节流:固定时间间隔内只执行一次,无论触发多少次。本质是"定时执行"。适合场景:页面滚动、mousemove 追踪、按钮防重复点击。
两者都是减少函数执行频率的手段,但策略不同:防抖关注"最终状态",节流关注"固定频率"。
总结
状态设计回顾
本文实现的购物车涵盖了 Vuex 状态管理的核心场景,整体架构如下:

【代码注释】该图自左向右串起购物车的三层架构。蓝色「用户标识层」:UUID v4 生成 → localStorage 持久化 → 请求头注入,解决"购物车归属谁";它向数据层输出"携带标识的请求"。绿色「数据层(store/cart.js)」是核心:Actions 调接口后 commit 给 Mutations,Mutations 改写 State,State 派生出 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,后续请求改为携带 userId 或 token。这个"购物车合并"逻辑通常由服务端处理,前端只需要在登录接口中同时传入 userTempId 和登录凭证。
3. 购物车数量频繁修改的请求队列问题
当用户快速点击 + 按钮 10 次时,即使加了防抖,如果用户每次点击间隔都超过防抖时间,仍会发出 10 个请求。更健壮的方案是维护一个本地请求队列,记录最新的目标数量,新的修改请求会取消正在进行的旧请求(使用 Axios 的 CancelToken),始终只保证最新的请求在途。
4. 离线状态下的购物车操作
进阶方案是实现离线操作队列(offline queue):当网络断开时,用户的操作被记录到队列,网络恢复后按顺序同步到服务端。这在移动端场景(地铁、电梯中断网)尤为重要,是 PWA(Progressive Web App)的核心特性之一。
更多推荐



所有评论(0)