Skip to content

删除购物车商品与删除选中购物车商品(Lesson19 3/3) ​

本页对应 Lesson19,共 5 段视频,约 50 分钟。

学习目标 ​

  • 能够说明切换商品选中状态两种方案的差异,并实现方案二:调用接口后通过 mutation 修改本地仓库状态,减少重复请求。
  • 能够根据接口文档新增删除购物车商品 API,完成单个商品删除,并在购物车为空时展示空状态提示。
  • 能够获取选中商品 ID 列表,完成批量删除选中购物车商品,并处理请求方式、请求体格式、确认提示及删除后列表更新。
  • 能够使用 Vuex getter 或计算属性统计选中商品数量和总价,并根据统计结果控制删除选中商品、结算等区域的显示或可用状态。
  • 能够实现购物车商品数量加减,调用接口同步服务端数据,并使用防抖控制频繁请求。
  • 能够实现搜索页面点击“加入购物车”完成商品加入购物车,并跳转或更新到购物车页面。

知识点 ​

先封装删除购物车商品接口 ​

为什么需要:删除购物车不是只改页面显示,还要通知服务端把这条购物车记录删掉。因此先把接口统一放到 src/api/cart.js,组件和 store 不直接拼请求地址,后续维护更方便。

怎么想:看接口文档:删除单个购物车商品的请求方式是 delete,地址是 /cart/deleteCart/{skuId},路径参数就是商品 skuId。所以封装时只接收一个 skuId,用模板字符串拼到地址里。

怎么做:

js
// 删除购物车商品: /api/cart/deleteCart/{skuId} delete
export const deleteCartListById = (skuId)=>sphRequest.delete(`/cart/deleteCart/${skuId}`)

易错点:转写稿里提醒,调不通时先检查三处:请求方式、请求地址、传参。删除接口是 delete,不要写成 get 或 post;路径参数是 skuId,不要传成购物车行对象;如果接口需要请求头,也要检查请求头是否带上、名字是否写对。

在 Vuex 中删除并更新购物车列表 ​

为什么需要:页面只负责触发删除,真正的“调接口 + 更新仓库里的 cartList”放到 store 的 action 中,组件逻辑更薄,购物车数据也保持单一来源。

怎么想:删除成功后有两种更新思路。方案一是重新调用 getCartListAsync 拉取最新购物车列表;方案二是本地用 filter 过滤掉被删除的 skuId。转写稿里两种都演示了:方案一请求次数多但逻辑简单;方案二减少一次请求、降低服务器压力,但本地逻辑更多。

怎么做:

js
const mutations = {
	// 根据商品ID删除购物车中的商品
	DELETE_CART_LIST_BY_ID(state,skuId){
		state.cartList = state.cartList.filter(v=>v.skuId !== skuId);
	}
};
const actions = {
	async deleteCartListByIdAsync({dispatch,commit},skuId){
		// 根据商品ID删除购物车信息
		await deleteCartListById(skuId);
		// 方案一:更新购物车列表
		// await dispatch("getCartListAsync");
		// 方案二:
		commit("DELETE_CART_LIST_BY_ID",skuId);
	}
}

易错点:filter 里要保留“不等于当前 skuId”的商品,写反了会把要保留的删掉;action 必须 async/await 等接口成功后再更新本地,否则接口失败也可能先删页面数据;commit 调用 mutation,dispatch 调用 action,不要混用;如果 store 开启了命名空间,页面调用时要带 cart/。

页面删除按钮绑定 action ​

为什么需要:用户点击“删除”时要触发上面的 action,并把当前行的 item.skuId 传进去。否则 store 不知道删哪一条。

怎么想:在 src/pages/Cart/index.vue 的删除按钮上绑定点击事件,使用 @click.prevent 阻止 href="#none" 的默认跳转,再 dispatch('cart/deleteCartListByIdAsync', item.skuId)。

怎么做:

vue
<template>
	<li class="cart-list-con7">
    <a @click.prevent="$store.dispatch('cart/deleteCartListByIdAsync',item.skuId)" href="#none" class="sindelet">删除</a>
    <br>
    </li>
</template>

易错点:传参要传 item.skuId,不要传整个 item;不加 .prevent 可能触发链接默认行为;action 名要和 store 中定义一致;转写稿中点击删除后看到列表少一项,刷新后也正常,说明接口删除和本地更新都生效。若点击没反应,先看控制台报错和 Network 里的请求方式、地址、参数。

方案选择与调试小结 ​

为什么需要:同样能删除,方案一和方案二在请求次数、代码复杂度上不同,要按场景取舍。

怎么想:方案一“删除后重新拉列表”最稳,适合列表数据复杂、怕本地算漏的场景;方案二“本地过滤”少发一次请求,适合删除单条且本地数据结构清楚的情况。

怎么做:本节笔记保留两种写法,实际练习可先跑方案一确认接口通,再切方案二观察页面是否立即少一条;刷新页面仍没有该商品,才说明服务端也删成功。

易错点:不要把“页面少了”误当成“接口成功”,要结合 Network 和刷新验证;不要漏写 await;不要把 skuId 和商品 id 混淆;删除后若列表为空,后续可再补空购物车提示,这属于后续功能,不要在本节强行编造。

功能目标:一次删除多个勾选商品 ​

购物车中用户可能勾选多条商品,如果逐条调用删除接口,会产生多次请求。本节要实现的是“删除选中的商品”:把当前 cartList 中 isChecked === 1 的商品筛出来,取出它们的 skuId,组成数组,一次性交给批量删除接口。

这里要先明确方案。转写稿里提到两种思路:方案一是删除成功后重新调用购物车列表接口,用服务器最新数据替换仓库状态;方案二是在接口成功后直接修改本地仓库数据,减少一次请求。本节笔记采用的是方案一:删除后 dispatch("getCartListAsync") 重新拉取列表。这样逻辑简单,数据以服务端为准;缺点是会多一次请求。方案二代码量更多、逻辑更绕,但能减少 ajax 请求次数。初学阶段先按笔记实现方案一。

补 API:地址、方法、参数位置都要对 ​

为什么需要单独封装 API?因为页面和 store 不应该到处写请求地址,统一放到 src/api/cart.js 便于维护。怎么想?对照接口文档确认三件事:请求地址是 /cart/batchDeleteCart,请求方式是 post,参数是请求体中的 skuIdList 数组。怎么做?在 cart.js 中增加:

js
// 批量删除购物车  /api/cart/batchDeleteCart  post
// 参数:skuIdList  数组  代表修改的商品id列表     请求体参数
export const deleteCartListBatch = (data)=>sphRequest.post(`/cart/batchDeleteCart`,data)

易错点:转写稿调试时反复强调“请求方式、请求地址、参数”三处要检查。批量删除不是普通 delete,而是 post;数组要放在请求体 data 里,不是拼在 URL 上。转写里的“合载/荷载”指请求体 payload,“school ID、扣 ID、死 coI”都应纠正为 skuId。如果请求头、请求方式或参数名写错,就会出现删除无效、504 或请求失败。

写 store action:先过滤、再映射、后刷新 ​

为什么在 store 写?因为删除需要读取 state.cartList,删除后还要刷新列表,放在 cart 模块的 action 中最合适。怎么想?先让用户确认,避免误删;确认后从 cartList 中过滤出勾选项,映射成 skuId 数组;调用 API;成功后重新获取购物车列表。怎么做?

js
async deleteCartListBatchAsync({dispatch,state}){
    if(window.confirm("您确定要删除选中的数据吗")){
        // console.log(state.cartList.filter(v=>v.isChecked===1).map(item=>item.skuId));
        await deleteCartListBatch(state.cartList.filter(v=>v.isChecked===1).map(item=>item.skuId));
        await dispatch("getCartListAsync");
    }

},

调试时可以先取消注释那行 console.log,在控制台确认输出的是类似 [2, 20] 这样的 skuId 数组,而不是对象数组或字符串。易错点:过滤条件写错会删错商品;map 后必须取 skuId;删除后不刷新,页面可能还显示旧数据;window.confirm 返回 false 时不能继续执行。转写稿还提到,若改成方案二,可在删除成功后用 commit 修改本地 cartList,但本节笔记不展开。

页面绑定:点击“删除选中的商品”触发 action ​

为什么要在页面绑定?用户操作入口在购物车页的“删除选中的商品”链接上。怎么想?点击时直接派发 cart 模块的 action,把删除逻辑留在 store。怎么做?在 src/pages/Cart/index.vue 的 .option 中写:

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

易错点:模块名要写对,这里是 cart/deleteCartListBatchAsync;href="#none" 防止跳转;不要在模板里直接写过滤和请求逻辑,否则页面会臃肿。转写稿调试时先打印 cartList,确认勾选数据存在,再点击删除,通过浏览器网络面板查看请求方法、地址和请求体,最终确认删除成功后列表减少。若接口临时 504,要先判断是服务端问题还是参数问题,再对照文档修正请求方式和 data。

为什么需要统计选中商品数量和总价 ​

购物车底部需要明确告诉用户:当前选中了几件商品、这些商品合计多少钱。这个结果不能靠模板里临时拼字符串,也不建议在组件里反复遍历购物车列表。更合适的做法是把“统计选中商品”的逻辑放到 Vuex 的 getters 中:state 负责存购物车列表,getters 负责根据列表派生出统计结果。这样模板只负责展示,逻辑集中、可复用,也方便后续控制“结算”按钮是否可用。

转写稿里反复强调的思路是:计算属性可以解决统计问题,而 Vuex 中对应的就是 getters。最终得到的是一个对象,里面同时包含选中数量和选中总价。

怎么想:遍历一次,同时累加数量和金额 ​

统计规则很直接:遍历 cartList,只处理 isChecked === 1 的商品;每遇到一个选中商品,数量加一;金额累加 skuPrice * skuNum。注意这里判断的是数字 1,不是布尔值 true,这是购物车字段定义决定的,写错会导致统计恒为 0。

转写稿中的调试过程也说明了这一点:一开始在 getters 里输出 cartList,发现是空数组,统计结果自然是 0。原因不是 getters 写错,而是获取购物车列表的异步 action 还没执行完,数据尚未进入 state。后来在获取列表的 action 中补上 await,等接口返回并提交 mutation 后再计算,结果才正常。也就是说,统计逻辑依赖“数据已经就绪”,调试时要先看 cartList 有没有值,再看 isChecked 是否匹配。

怎么做:在 cart.js 中增加 getCountResult ​

在 src/store/cart.js 的 getters 中增加统计方法,保留本节笔记代码如下:

js
const getters = {
	getCountResult({cartList}){
		let checkedNum = 0;
		let checkedPrice = 0;
		console.log("cartList",cartList)
		cartList.forEach(item=>{
			if(item.isChecked===1){
				checkedNum++;
				checkedPrice+=item.skuPrice*item.skuNum
			}
		})
		console.log(checkedNum,checkedPrice);
		return {
			checkedNum,
			checkedPrice
		}
	}
}

这段代码的关键点有三个:第一,参数解构出 cartList,说明 getters 依赖 state 中的购物车列表;第二,checkedNum++ 和 checkedPrice += ... 在同一次遍历中完成,避免遍历两遍;第三,返回对象,模板通过 getCountResult.checkedNum、getCountResult.checkedPrice 读取。

在购物车页面展示统计结果 ​

在 src/pages/Cart/index.vue 中,底部统计区域直接使用 getters 的返回值。保留本节笔记模板代码如下:

vue
<template>
   <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>
</template>

这里 getCountResult 作为 getters 暴露到组件后,可以像计算属性一样在模板中使用。总价通过 currency(2,"¥") 过滤器格式化为两位小数并带人民币符号。转写稿中还提到可以用 mapGetters 简写,例如把 getCountResult 映射到组件中;无论哪种写法,模板读取的字段名都不能漏掉 .checkedNum 和 .checkedPrice。

易错点与调试建议 ​

第一,isChecked === 1 不要写成 true 或字符串 "1"。第二,金额是 skuPrice * skuNum,不要只加单价。第三,getters 只做派生计算,不要在 getters 里修改 cartList。第四,如果页面显示 0,先按转写稿的方式打印 cartList,确认异步数据是否已经回来;若为空,检查获取购物车列表的 action 是否 await 了接口调用。第五,模板中如果写成 getCountResult 而忘了取属性,展示的会是对象而不是数字。统计结果还可以继续用于交互控制:转写稿里提到当选中数量为 0 时,可以让“删除选中商品”或“结算”区域不显示或不可点击,这属于同一份统计结果的延伸用法。

作业目标:数量加减不是只改页面数字 ​

为什么需要:购物车里的数量加减是高频操作。页面上的“3”变成“4”只是视觉结果,服务端购物车数据也必须同步;否则刷新页面、结算时数量会不一致。转写稿里明确提到:点“加”让 3 变成 4 后,还要调接口让服务端数据发生改变。

怎么想:先改本地显示,让用户即时看到变化;再用防抖把连续点击合并,停止操作后再请求接口。接口选择上,转写稿说可以调“添加到购物车”接口,传入商品 ID 和最终数量,例如最终数量是 5 就传 5。

怎么做:在数量组件上绑定加、减、输入修改事件;每次触发先更新当前项数量;清掉上一次定时器,重新计时;定时器结束后读取最新数量,调用“添加到购物车”接口。字段名以项目接口文档为准,转写稿口述的“s ID、s name”需要和文档核对,不要凭记忆写。

易错点:每次点击都发请求;防抖回调里读到旧数量;数量减到 0 或负数;输入框为空或非数字;接口失败后页面数字没有回滚。

用防抖合并连续点击 ​

为什么需要:转写稿在作业部分特别提到“希望用一下防抖”。因为用户连点“加”会连续触发请求,既浪费请求,也给服务器增加压力。前面讲选中状态方案时也提到减少 axios 请求次数、降低服务器压力,这个思路同样适用于数量加减。

怎么想:防抖应该包住“发请求”这一动作,而不是包住“改本地数字”。本地数字要立即变,用户才觉得跟手;请求要等用户停手后再发。定时器要保存最后一次数量,不能在回调里读闭包旧值。

怎么做:按转写稿口述可整理成如下示意,具体函数名和字段以项目接口文档为准:

js
// 示意:字段名以接口文档为准
let timer = null

function onNumChange(item, num) {
  item.num = num // 先更新本地显示
  clearTimeout(timer)
  timer = setTimeout(() => {
    // 调“添加到购物车”接口,传商品ID和最终数量
    addToCart({ id: item.id, num })
  }, 300)
}

易错点:把 clearTimeout 写在更新数字之前导致逻辑混乱;防抖时间过长;组件卸载时没有清理定时器;输入框和加减按钮各写一套逻辑,最后数量不一致。

调接口后要处理成功与失败 ​

为什么需要:数量加减不是纯前端交互。转写稿说“同时服务端里面数据也得发生改变,得调接口”。如果只改本地,刷新后数量会回到旧值;如果接口失败却不提示,用户会以为已保存。

怎么想:请求成功后可以重新获取购物车列表,或通过 commit 更新仓库状态;请求失败要提示并回滚数量。转写稿在删除功能里调试过请求方式、地址、参数位置,数量接口同样要核对这三处,尤其注意 delete 和 post 的请求体位置不同:转写稿提到 delete 时第二个参数不一定是请求体,data 才是;后来接口用 post 才成功。这个调试经验可迁移到数量接口排查。

怎么做:封装数量更新方法;在防抖回调里调用;try/catch 或 .catch 处理失败;成功后触发列表更新或本地仓库更新;失败时把数量改回旧值并提示。

易错点:只看页面变化不看 Network;请求方式写错;参数放在 URL 或请求体位置错误;失败后不提示;成功后不更新仓库导致总价不刷新。

与选中统计、删除功能的联动 ​

为什么需要:转写稿后面实现了选中商品数量和总价统计。数量一变,总价就要变;如果数量只改 DOM 不改状态,计算属性拿不到新值。

怎么想:数量应保存在购物车列表项或仓库状态中,统计逻辑从状态里读,而不是从页面文本读。转写稿中用计算属性遍历 cartList,按选中状态累加数量和单价,这说明数量字段必须可响应。

怎么做:数量更新后确保列表项或仓库中的数量同步;统计逻辑依赖该字段;删除、批量删除不是本节作业核心,但调试时注意接口请求方式与请求体,避免把删除问题误判成数量问题。

易错点:直接改 DOM 文本;数量字段和统计字段不同步;把删除接口的调试结论硬套到数量接口。

自测清单 ​

加入购物车后进入购物车,连续点“加”多次,观察 Network 是否只在停手后发一次请求;刷新页面看数量是否保持;修改数量后看选中数量和总价是否更新;测试数量为 1 时继续减、输入空值、输入非数字;模拟接口失败看是否回滚并提示。转写稿最后布置的作业就是完成购物车中的数量加减,并要求使用防抖;按“先本地、再防抖、后接口、失败回滚”的顺序做,调试时优先核对请求方式、地址、参数位置和字段名。

一、先把本节目标说清楚 ​

这一节的核心任务有两个:一是把购物车里的删除、批量删除、选中商品统计做完整;二是完成作业——在搜索页点击“加入购物车”按钮,把商品加入购物车,并跳转到购物车页面。转写里反复出现的 school ID 实际是 skuId,co list、ca list 都是 cartList,get called list 是 getCartList,阅读时先统一这些叫法。

二、选中状态为什么有两种改法 ​

为什么需要:点击商品前的勾选框时,要同时改本地显示和服务端状态。如果只改本地,刷新后状态会丢;如果只改服务端,页面不会立即变化。

怎么想:方案一是“调接口改服务端 → 再调购物车列表接口 → 用新数据替换仓库”;方案二是“调接口改服务端 → 直接 commit 修改仓库里的选中状态”。

怎么做:方案二在 action 里先调接口,成功后提交 mutation,只改对应商品的 isChecked。这样不用重新拉列表。

易错点:方案二代码量多、逻辑多,要保证接口成功后再改本地;方案一请求次数多,但数据最稳。转写里明确说方案二“减少请求次数、降低服务器压力”,缺点就是“代码量较多、增加逻辑”。

三、删除购物车商品:API → store → 组件 ​

为什么需要:购物车列表里的删除按钮必须真正删掉服务端数据,否则刷新又回来。

怎么想:按“接口文档 → API 文件 → store action → 组件事件”的顺序改,三个地方都要动。

怎么做:先在 API 文件里加删除接口,请求方式按文档是 delete,参数只传商品 skuId;再在 store 里写 action:先调删除接口,再 commit 更新本地 cartList,或者重新调用 getCartList 刷新列表;最后在购物车组件里给删除按钮加 @click,把 item.skuId 传过去。

js
// store 中示意
async deleteCartById({ commit }, skuId) {
  await deleteCart(skuId)
  commit('DELETE_CART', skuId)
}
js
// mutation 中示意
DELETE_CART(state, skuId) {
  state.cartList = state.cartList.filter(item => item.skuId !== skuId)
}

易错点:转写里提醒得很具体——调不通时先检查“请求方式、请求地址、参数”这三处;获取不到购物车商品,要检查请求头有没有带、请求头名字有没有写对。删除按钮的 @click 不要漏写参数。

四、批量删除:先按文档写,再用调试纠正 ​

为什么需要:用户勾选多个商品后,要一次删掉,不能一个个点。

怎么想:批量删除接口接收的是商品 ID 数组,不是单个 ID。组件里先找出“被选中”的商品,映射成 skuId 数组,再传给接口。

怎么做:转写里先用 filter 找 isChecked 为真的项,再用 map 取出 skuId;API 请求方式一开始按 delete 写,调试发现服务端实际用 post,参数放在请求体里,最终把数组直接作为 body 传过去。

js
const skuIdList = cartList
  .filter(item => item.isChecked)
  .map(item => item.skuId)

易错点:转写里连续出现 504,最后对比可用的示例才发现请求方式应是 post,并且 post 的第二个参数才是请求体;delete 和 post 的参数位置不一样,照抄容易错。批量删除前最好加确认提示,例如“确定要删除选中的商品吗”。

五、选中商品数量与总价:用 getters 统计 ​

为什么需要:页面要显示“已选多少件、合计多少钱”,这个结果由 cartList 派生,不应该再存一份状态。

怎么想:用计算属性/getters 遍历 cartList,只统计 isChecked 为真的商品。

怎么做:在 getters 里定义 getCartResult,返回 { totalNum, totalPrice };组件里通过 mapGetters 取用,模板中显示 getCartResult.totalNum 和 getCartResult.totalPrice。

js
getCartResult(state) {
  let totalNum = 0
  let totalPrice = 0
  state.cartList.forEach(item => {
    if (item.isChecked) {
      totalNum += item.skuNum
      totalPrice += item.cartPrice * item.skuNum
    }
  })
  return { totalNum, totalPrice }
}

易错点:转写里一开始统计全是 0,原因是数据还没执行完就去取值;另外要确认 cartList 确实有数据、字段名是 skuNum 和 cartPrice,不要把单价和总价混在一起。没有选中商品时,可以把“删除选中商品”和结算按钮隐藏或禁用。

六、作业2:搜索页“加入购物车” ​

为什么需要:搜索页只展示商品,用户点“加入购物车”后要真正写入购物车,并让用户看到结果。

怎么想:点击按钮 → 调加入购物车接口 → 成功后跳转到购物车页面。转写里还特别提到:点完加入购物车后按钮应失效,防止连续点击发多次请求。

怎么做:给按钮绑定点击事件,传当前商品 skuId 和数量;请求期间用 loading 或 disabled 锁住按钮,成功后再跳转。

js
async addToCart() {
  if (this.loading) return
  this.loading = true
  try {
    await addCart({ skuId: this.skuId, skuNum: 1 })
    this.$router.push('/cart')
  } finally {
    this.loading = false
  }
}

易错点:不要忘记 finally 里解锁,否则请求失败后按钮一直不能点;跳转前要确认接口成功;搜索页传的是商品 skuId,不是购物车条目 ID。转写里还提到数量加减作业可以配合防抖,避免连续点“+”时频繁发请求。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例一:删除购物车中的单个商品(api + store + 组件) ​

js
/* ---------------- src/api/cart.js ---------------- */
// 删除购物车商品:DELETE /api/cart/deleteCart/{skuId}
export const deleteCartListById = (skuId) =>
  sphRequest.delete(`/cart/deleteCart/${skuId}`)

/* ---------------- src/store/modules/cart.js ---------------- */
import { deleteCartListById } from '@/api/cart'

const state = {
  cartList: []
}

const mutations = {
  // 根据商品ID删除购物车中的商品
  DELETE_CART_LIST_BY_ID(state, skuId) {
    state.cartList = state.cartList.filter(v => v.skuId !== skuId)
  }
}

const actions = {
  async deleteCartListByIdAsync({ dispatch, commit }, skuId) {
    // 1. 先调用接口,把数据库中的这条记录删掉
    await deleteCartListById(skuId)
    // 方案一:更新购物车列表(重新请求,用接口数据整体替换仓库)
    // await dispatch('getCartListAsync')
    // 方案二:本地删除(少发一次请求,但逻辑多一段)
    commit('DELETE_CART_LIST_BY_ID', skuId)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}
vue
<!-- src/pages/Cart/index.vue -->
<template>
  <li class="cart-list-con7">
    <a href="#none"
       class="sindelet"
       @click.prevent="$store.dispatch('cart/deleteCartListByIdAsync', item.skuId)">删除</a>
  </li>
</template>

要点解释:

  • 接口是 delete 方式,skuId 直接拼在路径上,不需要请求体。
  • @click.prevent 必须加,否则 <a href="#none"> 会跳转,页面一刷新就看不到删除效果。
  • dispatch 的第一个参数要带模块名 cart/,因为 store 模块开了 namespaced: true。
  • 方案一(dispatch('getCartListAsync'))和方案二(commit 本地 filter)的区别:方案一多发一次请求但数据最准,方案二减少请求但要多写逻辑;两种都可以,转写稿里演示的是方案二。
  • 必须先 await 接口成功,再 commit 改本地数据,否则接口失败而本地已删,页面和服务器不一致。
  • filter 返回的是新数组,必须重新赋值给 state.cartList,不能直接 splice/push 原数组。

示例二:批量删除选中的购物车商品 ​

js
/* ---------------- src/api/cart.js ---------------- */
// 批量删除购物车:POST /api/cart/batchDeleteCart
// 参数:skuIdList 数组,代表要删除的商品id列表(请求体参数)
export const deleteCartListBatch = (data) =>
  sphRequest.post('/cart/batchDeleteCart', data)

/* ---------------- src/store/modules/cart.js ---------------- */
async deleteCartListBatchAsync({ dispatch, state }) {
  if (window.confirm('您确定要删除选中的数据吗')) {
    // 先过滤出选中的商品,再映射出 skuId 数组
    const skuIdList = state.cartList
      .filter(v => v.isChecked === 1)
      .map(item => item.skuId)

    // 请求体直接就是这个数组
    await deleteCartListBatch(skuIdList)
    // 删除后重新拉取购物车列表
    await dispatch('getCartListAsync')
  }
}
vue
<!-- src/pages/Cart/index.vue -->
<template>
  <div class="option">
    <a href="#none"
       @click="$store.dispatch('cart/deleteCartListBatchAsync')">删除选中的商品</a>
  </div>
</template>

要点解释:

  • 接口文档写的是 skuIdList 数组,实际发送时请求体直接传数组(转写稿里最终就是 sphRequest.post(url, data),data 就是那个数组),不要再包一层 { skuIdList: [...] }。
  • 请求方式要看清:这里是 post,第二个参数就是请求体;如果接口是 delete,第二个参数是配置对象,要写成 sphRequest.delete(url, { data: skuIdList }),转写稿里 504/删不掉就是踩了这个坑。
  • filter(v => v.isChecked === 1).map(item => item.skuId) 两步顺序不能反:先筛出选中项,再取 skuId。
  • isChecked === 1 用严格相等,避免字符串 '1' 判断出错。
  • 删除完成后 dispatch('getCartListAsync') 刷新列表;如果不想再发请求,也可以像示例一那样 commit 一个本地删除的 mutation(转写稿里留给课后自己做)。
  • 选中数量为 0 时最好不要发请求,可以配合示例三的 getCountResult.checkedNum > 0 做 v-if 控制按钮显示。

示例三:统计选中商品数量与总价(getters + 页面渲染) ​

js
/* ---------------- src/store/modules/cart.js ---------------- */
const getters = {
  getCountResult({ cartList }) {
    let checkedNum = 0
    let checkedPrice = 0
    cartList.forEach(item => {
      if (item.isChecked === 1) {
        checkedNum++
        checkedPrice += item.skuPrice * item.skuNum
      }
    })
    return {
      checkedNum,
      checkedPrice
    }
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}
vue
<!-- src/pages/Cart/index.vue -->
<template>
  <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" v-if="getCountResult.checkedNum > 0">
      <a class="sum-btn" href="###" target="_blank">结算</a>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'CartMoneyBox',
  computed: {
    ...mapGetters('cart', ['getCountResult'])
  }
}
</script>

要点解释:

  • getters 的第一个参数就是 state,直接 { cartList } 解构出来用。
  • 数量用 checkedNum++,总价用 checkedPrice += item.skuPrice * item.skuNum,注意是单价 × 数量,不是只加单价。
  • getters 里只读不写,不要在 getters 中修改 state。
  • 页面里用 getCountResult(计算属性缓存),不要在模板里写方法做循环统计,那样每次渲染都会重新遍历。
  • mapGetters('cart', [...]) 的命名空间要和 store 模块名一致;不写 'cart' 会取不到值。
  • 转写稿中第一次显示 0 的原因:getCartListAsync 是异步的,列表还没回来就计算了;getters 依赖 state.cartList,数据回来后会自动重新计算,不需要手动处理。
  • v-if="getCountResult.checkedNum > 0" 用来控制结算按钮/删除按钮的显示,没选中商品时不显示。
  • | currency(2, "¥") 是项目里的过滤器,用于保留两位小数并加货币符号。

常见错误 ​

1. 点删除后控制台报 405 / 404,商品没被删

  • 现象:Network 里请求发出去了,但状态码是 404 或 405,或请求地址少了一段。
  • 原因:接口三要素没对齐——deleteCartListById 写成 post,或路径漏了 /cart/deleteCart/,或把 skuId 拼进了错误位置。
  • 修法:按文档逐项核对 method、url 模板、路径参数;删除单个是 delete + 路径参数,批量删除是文档指定的 method + 请求体。核对完再刷新页面重试,不要靠"改代码猜"。

2. 批量删除接口返回成功,但一条都没删掉

  • 现象:响应 200,刷新后数据还在;Network → Payload 里请求体是空的。
  • 原因:把数组直接当第二个参数传给 delete。delete(url, config) 的第二个参数是配置对象而不是请求体,数组被当成 config 丢掉了;如果接口是 post,则第二个参数才是请求体本身。
  • 修法:delete 传请求体要写成 sphRequest.delete(url, { data: skuIdList });post 直接 sphRequest.post(url, skuIdList)。改完在 Network 里确认 Payload 里确实有数组再收工。

3. 页面白屏或点击报 xxx is not a function / undefined

  • 现象:state.cartList.filter is not a function、commit is not defined、Cannot read property 'map' of undefined。
  • 原因:手写时拼错关键字(fifi、cot、ca list、commit 漏写),或解构 action 参数时漏了 commit,或 cartList 初始值不是数组。
  • 修法:先读控制台堆栈定位到行;action 里固定写成 async fn({ dispatch, commit, state }, payload),只解构真正用到的;store 里给 cartList 一个 [] 初值。

4. 删除成功后,底部"已选 N 件 / 总价"不刷新或一直是 0

  • 现象:列表少了一条,但合计数字没变;或首次进页面恒为 0。
  • 原因:① getter 依赖 state.cartList,但删除走的是"重新拉列表"分支且没 await,读到旧数据;② 模板里写成 getCountResult.checkedNum 但 getter 被定义成方法却没调用;③ isChecked 后端返回字符串 '1',用 === 1 判断恒为 false。
  • 修法:action 内所有异步调用前加 await,commit 后再让视图渲染;getter 返回对象就用 xxx.yyy 取值;判断选中统一写 Number(item.isChecked) === 1。

5. 删光所有商品后页面一片空白/结构错乱

  • 现象:购物车清空后列表区域只剩边框,或"结算"按钮还在。
  • 原因:模板没做空态分支,v-for 遍历空数组时外层容器仍然渲染。
  • 修法:列表区 v-if="cartList.length",空态用 v-else 放提示图和文字;批量删除、结算按钮加 v-if="getCountResult.checkedNum > 0",无选中时自然隐藏。

6. 连点"加入购物车"导致同一商品数量翻倍

  • 现象:手速快时点两下,购物车出现两条或数量 +2。
  • 原因:按钮没有请求期禁用,每次点击都发一次请求。
  • 修法:用一个 loading 标志位(或 pending 变量)在请求前置 true、finally 置 false,按钮绑 :disabled="loading";数量加减再叠加防抖。

练习 ​

  1. 数量加减:在购物车列表的 + / - 按钮上实现数量修改,本地立即改 state.cartList 中对应项的 skuNum,同时调"加入购物车"接口把最终数量同步到服务端;连点 + 要防抖,避免每点一次发一次请求。

  2. 搜索页加入购物车:在搜索页/商品详情页的"加入购物车"按钮上接入接口,请求期间禁用按钮防重复提交;成功后给出提示,并验证购物车角标或列表数据已更新。

  3. 批量删除体验增强:给列表加全选/反选;无选中商品时隐藏"删除选中的商品"与"结算"按钮(或置灰);删除前保留 window.confirm 二次确认,确认后调用批量删除接口并更新列表。

  4. 两种同步方案对比:把单个删除分别用"方案一:删完重新 dispatch('getCartListAsync')"和"方案二:commit('DELETE_CART_LIST_BY_ID', skuId)"各实现一遍,用 Network 面板数出请求次数差异,并写出各自在"代码量 / 服务器压力 / 数据一致性"上的取舍结论。

  5. 合计 getter 加固:重写 getCountResult,要求只统计 isChecked 为真的商品,价格按 skuPrice * skuNum 累加并保留两位小数(避免浮点误差);用 3 组手造数据(全选 / 部分选 / 全不选)在控制台验证返回值正确。

原视频对照 ​

#视频时长
1切换商品选中状态方案二01:59
2删除购物车商品08:59
3删除选中购物车商品23:26
4完成选中商品以及价格的统计14:12
5作业02:09

小结 ​

  1. 接一个新接口,先对齐 method、url 模板、参数位置三件事,删不动/改不动时优先打开 Network 看 Payload,而不是改业务代码。
  2. delete 的第二个参数是 config,请求体要包成 { data };post 的第二个参数才是请求体。这一个差别能吃掉半天时间。
  3. 接口调用后同步视图有两条路:重新拉列表(稳、多一次请求)或本地 commit 改 state(快、逻辑多)。选哪条取决于数据是否会被别人改动。
  4. mutation 只做纯同步的状态变更,异步与副作用一律放 action,这是 Vuex 里最不该破的边界。
  5. 派生数据(选中件数、总价)交给 getter,组件里只负责展示,避免多个组件各算一遍导致数字打架。
  6. 异步顺序是隐形坑:没 await 就去读 getter,拿到的永远是上一轮的值,调试时先看时序再看逻辑。
  7. 展示层的健壮性同样算功能:空态、按钮禁用、二次确认、防重复提交,缺一个都会被真实用户点出来。
  8. 命名与拼写保持一致(cartList / skuId / commit)是第一排查入口,报错信息基本都能指向具体某一行。