主题
完成全选功能与操作购物车数量(Lesson20 1/2)
本页对应 Lesson20,共 7 段视频,约 2 小时 47 分。
学习目标
- 能够使用 Vuex getter 计算购物车是否全选,并通过 action 调用接口完成全选切换,验证本地状态与远程数据一致。
- 能够根据接口参数规则实现购物车数量加减、删除、200 上限控制和输入校验,并通过网络面板验证请求参数与页面结果。
- 能够分别使用防抖处理数量输入变更、使用节流控制加减按钮请求频率,并说明两者在触发时机和适用场景上的差异。
- 能够根据网络请求中的空数组、参数缺失和接口异常定位问题,补充 code 判断、异常提示及数据清理逻辑。
- 能够进入详情页时查询购物车并回填当前商品数量,加入购物车时按“目标数量减当前数量”提交差值。
- 能够使用 element-ui 的基本组件完成交互提示、按钮禁用等界面效果,并验证样式与交互行为符合预期。
知识点
全选功能的整体思路
购物车里有多个商品时,用户既可能逐个勾选,也可能直接点“全选”。全选框是否勾上,不能单独存一个布尔值,而应由当前购物车列表推导出来:所有商品的 isChecked 都为 1,才是全选。点击全选时,又要同时修改服务端数据和页面数据,否则会出现“页面变了、刷新又回去”或“远程变了、本地没变”的问题。
所以这一节要分三层做:getter 负责“算”是否全选,action 负责“切换”全选并同步数据,api 负责封装批量选中接口,组件只负责渲染和派发。
在 store 中补 getter 与 action
为什么需要:全选状态是派生状态,不适合在组件里到处写 every;切换全选又要调接口并刷新列表,属于购物车模块自己的业务,应放在 store/cart.js。
怎么想:先用 getter 判断 cartList.every(v => v.isChecked === 1);再用 action 计算目标状态:当前已全选就改成 0,否则改成 1。接口调用完成后,重新 dispatch("getCartListAsync") 拉取最新购物车列表,让本地和服务端保持一致。
怎么做:在 src/store/cart.js 中补 getters 和 actions。原笔记代码如下:
js
// 购物车模块
import {
postBatchCheckCart
} from "@/api/cart";
const actions = {
// 切换全选状态
async postBatchCheckCartAsync({getters,state,dispatch}) {
// isChecked:要切换的状态.
const isChecked = getters.getIsAll ? 0 : 1;
// 方案一:抽取要修改的数据 skuIdList:要切换状态的商品ID,类型是数组,数组中的每一个元素是商品ID
// const skuIdList = state.cartList.filter(v=>v.isChecked===(getters.getIsAll?1:0)).map(v=>v.skuId);
// 方案二:抽取所有数据的id
const skuIdList = state.cartList.map(item=>item.skuId);
await postBatchCheckCart(isChecked,skuIdList);
await dispatch("getCartListAsync");
}
}
const getters = {
// 生成是否全选
getIsAll({cartList}) {
return cartList.every(v => v.isChecked === 1);
}
}
export default {
namespaced: true,
state,
mutations,
actions,
getters
}转写稿里调试时提到:点击全选没反应,先看网络请求,发现传给接口的 skuIdList 是空数组。排查 filter 条件时发现筛选条件写错,导致一个商品都筛不出来;后来改用“方案二:抽取所有商品 ID”,由接口统一改成目标状态。这个调试思路要记住:全选不生效时,先看请求有没有发、参数对不对,再看接口返回后有没有重新拉列表。
易错点:getters.getIsAll 是当前是否全选,目标状态要取反;await postBatchCheckCart 后再 await dispatch,不要漏掉等待;不要只改本地或只改远程。转写稿还提到空购物车问题:[].every(...) 会返回 true,所以空列表时全选框会误勾。原笔记代码保留,但实际可补 if (!cartList.length) return false。
在购物车组件里绑定全选
为什么需要:getter 只算出状态,页面上还要把复选框勾选状态和点击行为接起来。
怎么想:复选框的 checked 绑定 getIsAll;用户改变勾选时,派发 cart/postBatchCheckCartAsync。
怎么做:在 src/pages/Cart/index.vue 中写:
vue
<div class="select-all">
<input @change="$store.dispatch('cart/postBatchCheckCartAsync')" :checked="getIsAll" class="chooseAll" type="checkbox">
<span>全选</span>
</div>易错点:namespaced: true 后 action 要带模块名 cart/;组件里直接用 getIsAll 时,要确认已经通过 mapGetters 或计算属性拿到,否则应写 $store.getters["cart/getIsAll"]。另外 @change 里只派发 action,不要在组件里再手动改 checked。
封装批量选中接口
为什么需要:action 不应该直接写请求地址,接口统一放 api/cart.js,方便维护。
怎么想:接口地址是 /cart/batchCheckCart/{isChecked},请求方式 post,skuIdList 作为请求体参数。
怎么做:在 src/api/cart.js 中写:
js
// 批量选中购物车 /api/cart/batchCheckCart/{isChecked} post
// skuIdList 数组 代表修改的商品id列表 请求体参数
export const postBatchCheckCart = (isChecked,skuIdList)=>sphRequest.post(`/cart/batchCheckCart/${isChecked}`,skuIdList)易错点:isChecked 在路径里,skuIdList 在请求体里,参数顺序不要传反;post 第二个参数直接传数组即可。联调时先看 Network 里的 URL 和请求体,能快速定位是状态算错、ID 列表为空,还是接口地址写错。
目标与核心概念:数量操作不是“改绝对值”,而是“改增量”
为什么需要:购物车里的数量既要立刻显示,又要和服务端一致。加减按钮、输入框修改,最终都要调接口并刷新列表。
怎么想:先分清两个值:skuNum 是当前购物车数量,接口里的 skuNum 是“本次要增加/减少多少”。正数增加,负数减少。
怎么做:加号传 1,减号传 -1;输入框修改时传“新值 - 旧值”。
易错点:把增量当绝对值。比如原数量 1,再传 1 变 2;想变 3 应再传 1,想变 1 应传 -1。若原数量 2 想变 3 却传 3,结果会变 5。
模板结构:事件参数要带全
vue
<template>
<li class="cart-list-con5">
<a @click="upBuyNum(item.skuId,item.skuNum,-1)" href="javascript:void(0)" class="mins">-</a>
<input @change="changeBuyNum($event,item.skuId,item.skuNum)" autocomplete="off" type="text" :value="item.skuNum" minnum="1" class="itxt">
<a @click="upBuyNum(item.skuId,item.skuNum,1)"
href="javascript:void(0)"
:class="{'plus':true,'disabled':item.skuNum>=200}">+</a>
</li>
</template>为什么需要:方法里要计算边界和增量,必须知道 skuId、当前数量 skuNum、操作方向 num。输入框还要拿到事件对象 $event 才能取新值。
怎么想:@change 在值改变并失焦后触发,适合“输入完再请求”;若写成 @input,每敲一个字符都可能触发一次请求。
怎么做:@click 传 skuId, skuNum, ±1;@change 传 $event, skuId, skuNum;:value 绑定当前数量;加号在 >=200 时加 disabled 类。
易错点:@click 少传旧数量,方法里无法判断边界;输入框不传 $event,取不到新值;只加禁用样式但方法里不拦截。
加减操作 upBuyNum:先判边界,再发请求
vue
<script>
// 进行加减操作
// skuId:操作商品的ID
// skuNum:当前商品在购物车中的数量
// num:1 加1, -1 减1
async upBuyNum(skuId,skuNum,num){
if(skuNum+num>200) return;
if(skuNum+num<1){
// 小于1,根据商品ID删除购物车中的商品
this.$store.dispatch("cart/deleteCartListByIdAsync",skuId);
}else{
// 调接口
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
skuNum:num
})
await this.$store.dispatch("cart/getCartListAsync")
}
},
async changeBuyNum(e,skuId,skuNum){
// 修改后的数据.
const buyNum = e.target.value.trim()/1;
// skuNum:是修改前的数据
if(goodsNumReg.test(buyNum)){
// 调接口
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
// 更改后减去更改前
skuNum:buyNum-skuNum
})
await this.$store.dispatch("cart/getCartListAsync")
}else{
e.target.value =skuNum;
}
}
</script>为什么需要:数量有边界。小于 1 不应变成 0,而是删除该商品;大于 200 不应继续增加。
怎么想:先算 skuNum + num。大于 200 直接返回;小于 1 调删除 action;否则调 postAddToCartAsync,传增量 num,再调 getCartListAsync 刷新列表。
怎么做:删除用 deleteCartListByIdAsync;加调用 postAddToCartAsync;刷新用 getCartListAsync。
易错点:先调接口再判断边界;数量 1 点减仍传 -1,可能变 0 而不是删除;大于 200 只靠样式禁用,方法里没 return;漏掉刷新导致页面还是旧数量。
输入修改 changeBuyNum:新值减旧值才是增量
为什么需要:用户可能直接输入 20、60、120,不能把输入值直接传给接口。
怎么想:buyNum 是新值,skuNum 是旧值,增量是 buyNum - skuNum。旧 198 改 20,增量是 -178;旧 1 改 3,增量是 2。
怎么做:e.target.value.trim()/1 转数字;用 goodsNumReg 校验;通过则传 skuNum: buyNum - skuNum 并刷新;不通过则 e.target.value = skuNum 回填。
易错点:直接传 buyNum 导致越改越多;不 trim;不校验空值、负数、非数字;不刷新;goodsNumReg 需使用项目已有正则,不能随意编造。
与 store/API 配合及调试顺序
为什么需要:组件只负责取参和边界,真正请求在 store action 里。
怎么想:先看网络请求有没有发出,再看参数 skuId、skuNum 是否正确,最后看是否刷新列表。
怎么做:确认 postAddToCartAsync 内部调用加入购物车接口,skuNum 传增量;删除走 deleteCartListByIdAsync;每次成功后再 getCartListAsync。
易错点:action 名写错;参数名写错;只改远程不刷新本地,刷新页面后数量回退;只改本地不调接口,刷新后丢失。
优化:输入用防抖,加减用节流
为什么需要:输入 120 时若每敲一个字符就请求,会发多次;连续点加号也会发多次。
怎么想:防抖适合“停止输入后只执行一次”;节流适合“点击立即执行,但间隔内忽略后续点击”。
怎么做:输入修改可用 debounce,加减可用 throttle 或“锁定标记”:第一次点击置锁,请求完成解锁。
易错点:防抖回调写成箭头函数导致 this 不是组件实例;防抖时间过长体验差;节流首次不执行;连点时未等上一次请求完成就继续发。
拓展:详情页数量初始值控制
为什么需要:详情页默认数量为 1,但购物车可能已有该商品。若再加购,增量语义会让总数不符合预期。
怎么想:进详情页先拉购物车列表,按当前 skuId 查找;存在则把详情页数量初始值设为购物车中的实际数量,并记录“实际数量”;加入购物车时传“目标数量 - 实际数量”。
怎么做:调 getCartListAsync,在 then 里取 cartList,用 find 匹配 skuId,有则赋值给 buyNum 和记录值;无则保持默认。
易错点:购物车为空时未判断;接口失败未处理;该规则需产品确认,不能默认所有项目都这样处理。
需求与问题:为什么输入框改数量要防抖
购物车里的数量输入框,用户可能连续敲键盘:先删掉“1”,再输入“12”,中间会触发多次 input 事件。如果每次 input 都直接调接口,就会产生多次无意义的请求,既浪费性能,也可能让列表状态来回跳。这里的目标是:用户停止输入一段时间后,只拿最终数量提交一次。
怎么想:不要一触发 input 就立刻发请求,而是把“真正提交”的动作延迟;如果延迟期间又输入了,就重新计时。这个行为就是防抖。转写稿里也强调:输入框连续输入时,前几次往往不是最终值,应该等用户输入完再执行。
怎么做:在 Cart/index.vue 的输入框上绑定 @input,把处理函数用 debounce 包起来,延迟设为 1000ms。模板与代码保留如下:
vue
<template>
<input @input="inputBuyNum($event,item.skuId,item.skuNum)" autocomplete="off" type="text" :value="item.skuNum" minnum="1" class="itxt">
</template>
<script>
inputBuyNum:debounce(async function(e,skuId,skuNum){
// 修改后的数据.
const buyNum = e.target.value.trim()/1;
// skuNum:是修改前的数据
if(goodsNumReg.test(buyNum)){
// 调接口
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
// 更改后减去更改前
skuNum:buyNum-skuNum
})
await this.$store.dispatch("cart/getCartListAsync")
}else{
e.target.value =skuNum;
}
},1000),
</script>易错点:debounce 包装的函数不要随手改成箭头函数。转写稿里专门调试过 this:普通函数才能让内部 this 指向当前组件实例,才能访问 this.$store;箭头函数会丢失组件实例上下文。另一个易错点是延迟时间要合理,太短起不到防抖效果,太长用户会觉得“改了没反应”。
模板绑定:为什么传 $event、skuId、skuNum
为什么需要:处理函数既要拿到用户输入的新值,也要知道改的是哪个商品、改之前是多少。$event 用来读输入框当前值;skuId 定位购物车商品;skuNum 是修改前数量,用于校验失败时回填,也用于计算增量。
怎么想:模板里 item 是当前购物车项,所以 item.skuId 和 item.skuNum 可以直接传。输入框用 :value="item.skuNum" 展示原数量,不在模板里直接写死。
怎么做:保持笔记中的绑定方式,@input="inputBuyNum($event,item.skuId,item.skuNum)"。函数内先取新值:
js
const buyNum = e.target.value.trim()/1;这里 /1 是把字符串转成数字,trim() 去掉首尾空格。
易错点:不要只传 $event 而忘了 skuId,否则接口不知道改哪个商品;也不要把 skuNum 当成“新数量”,它是修改前数量。转写稿里反复强调接口传的是“增加/减少的差值”,不是最终绝对值,这一点下一节会用到。
防抖函数写法:为什么用 debounce(async function(){},1000)
为什么需要:input 事件触发频率高,直接 async 调接口会造成连续请求。用 debounce 后,1000ms 内只执行最后一次,符合“用户输入完再提交”的交互。
怎么想:把“读值、校验、调接口、刷新列表”整体放进被防抖包装的函数里。这样只有最后一次输入会走完整流程。转写稿中调试时也看到:不加防抖会连续触发;加上后等待 1 秒才执行,连续输入会重新计时。
怎么做:使用项目里已有的 debounce,第二个参数写 1000。函数内部保持 async,因为要 await 两个 action。
易错点:不要在 debounce 外面先读值再传进去,否则读到的不是防抖后的最终值;也不要把 await 写在 debounce 外层导致逻辑顺序混乱。若在控制台或网络面板调试,应观察“停止输入约 1 秒后”是否只发一次请求。
差值提交与刷新:为什么传 buyNum - skuNum
为什么需要:购物车接口 postAddToCartAsync 接收的 skuNum 是增量,不是最终数量。转写稿里用加入购物车举例:原来 1,再传 1 就变 2;传 -1 就减少。因此修改数量时要传“新值减旧值”。
怎么想:buyNum 是用户输入后的最终数量,skuNum 是修改前数量,二者相减就是本次要增加或减少的数量。比如原来 2,改成 5,应传 3;改成 1,应传 -1。
怎么做:按笔记写:
js
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
skuNum: buyNum - skuNum
})
await this.$store.dispatch("cart/getCartListAsync")第一步改远程,第二步重新拉购物车列表,让本地渲染与服务器一致。转写稿也讨论过“本地改还是远程改”的问题,结论是这里调完接口后重新获取列表,避免本地和远程不一致。
易错点:不要传 buyNum 绝对值,否则会把“增加”当成“设置为”,数量会错;也不要忘了刷新列表,否则页面可能还显示旧数量。若输入值与原值相同,差值为 0,是否提交要结合业务判断,至少不应造成数量异常。
校验与回填:非法输入为什么要恢复原值
为什么需要:输入框可能被输入非数字、空串、负数或超出范围的值。直接提交会污染购物车数据。笔记中用 goodsNumReg.test(buyNum) 做校验。
怎么想:先校验,再决定是否调接口;不合法就不提交,并把输入框恢复成修改前的 skuNum,让用户看到原数量。
怎么做:
js
if(goodsNumReg.test(buyNum)){
// 调接口
}else{
e.target.value = skuNum;
}易错点:e.target.value = skuNum 是直接改 DOM 值,适合这种“回填纠正”的场景;不要漏掉 trim() 和转数字,否则 " 2 " 或字符串比较可能让正则判断出错。转写稿里调试时也出现“删空后不符合规则”的情况,此时应回填而不是继续发请求。
调试与易错点汇总
调试时先看网络面板:连续输入时是否只发一次请求;再看请求参数里 skuNum 是否是差值;最后看刷新列表后数量是否与服务器一致。转写稿中通过控制台和网络定位过“传了空数组/参数不对”的问题,这里同样要重点检查 skuId 和 skuNum 是否传对。
易错点:debounce 内 this 丢失;把差值写成绝对值;校验失败没有回填;调完接口没有重新拉列表;输入框绑定事件写成 @change 导致触发时机不对。这里用 @input + 防抖,是为了在用户停止输入后提交最终数量;而加减按钮那种连续点击场景,转写稿建议用节流,不要和输入框防抖混用。
为什么加减按钮需要节流
购物车里的“+ / -”是典型的高频点击场景。转写稿里提到:连续点三下减号,如果每点一次都发一次请求,就会产生三次接口调用;请求还没返回时又点,页面数量和服务器数量就可能对不上。这里要区分防抖和节流:防抖适合输入框改数量,等用户停止输入后再请求;节流适合加减按钮,点击后先执行一次,在指定时间或请求未完成期间不允许再次执行,避免重复请求和竞态。
先看模板:加减按钮与输入框
模板里把 skuId、当前数量 skuNum、增量 num 传给 upBuyNum。注意 num 是“加一”或“减一”的增量,不是最终数量;+ 按钮在 skuNum>=200 时加 disabled 类。原代码如下:
vue
<template>
<li class="cart-list-con5">
<a @click="upBuyNum(item.skuId,item.skuNum,-1)" href="javascript:void(0)" class="mins">-</a>
<input @input="inputBuyNum($event,item.skuId,item.skuNum)" autocomplete="off" type="text" :value="item.skuNum" minnum="1" class="itxt">
<a @click="upBuyNum(item.skuId,item.skuNum,1)"
href="javascript:void(0)"
:class="{'plus':true,'disabled':item.skuNum>=200}">+</a>
</li>
</template>
<script>
// 通过节流二:
async upBuyNum(skuId,skuNum,num){
if(this.isLock) return;
this.isLock = true;
if(skuNum+num>200) return;
if(skuNum+num<1){
// 小于1,根据商品ID删除购物车中的商品
await this.$store.dispatch("cart/deleteCartListByIdAsync",skuId);
}else{
// 调接口
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
skuNum:num
})
await this.$store.dispatch("cart/getCartListAsync")
}
this.isLock = false;
},
// 通过节流一:
// upBuyNum:throttle(async function(skuId,skuNum,num){
// if(skuNum+num>200) return;
// if(skuNum+num<1){
// // 小于1,根据商品ID删除购物车中的商品
// this.$store.dispatch("cart/deleteCartListByIdAsync",skuId);
//
// }else{
// // 调接口
// await this.$store.dispatch("cart/postAddToCartAsync",{
// skuId,
// skuNum:num
// })
// await this.$store.dispatch("cart/getCartListAsync")
// }
// },2000,{
// // 时间间隔前是否执行
// // leading:false,
// // 时间间隔结束后是否执行
// // trailing:false
// }),
</script>方案一:用 throttle 包裹方法
throttle 的思路是把 upBuyNum 包成一个“指定时间内只执行一次”的函数。转写稿里说,第二个参数是时间间隔,第三个参数里 leading 表示时间间隔前是否执行,trailing 表示时间间隔结束后是否执行。加减按钮更适合“点一次先执行,冷却期内不执行”,所以节流比防抖合适。易错点:throttle 返回的是新函数,不能再按普通 async 方法重复定义;this 要用普通函数或提前绑定;如果 trailing:false,最后一次点击可能不执行,要确认业务是否接受。
方案二:用 isLock 手动锁
如果不引入 throttle,可以用请求生命周期做锁:进入方法先判断 this.isLock,未锁则立刻加锁;请求完成后再解锁。这样冷却时间等于接口请求时间,接口快则间隔短,接口慢则间隔长。原代码就是这一方案。易错点有三个:第一,if(skuNum+num>200) return; 发生在 this.isLock = true 之后,提前返回会导致锁不释放,后续点击都失效,应把边界判断放到加锁前,或在返回前解锁;第二,减到小于 1 走删除分支后,按本段代码没有再次拉取购物车列表,若页面需要同步要补刷新;第三,正常分支末尾才 this.isLock = false,异常或提前返回时锁不会释放,这是调试时要重点看的地方。
调试过程:先看请求,再看刷新
转写稿里的调试顺序很实用:点击加减后先打开 Network,看请求有没有发出、skuId 和 skuNum 参数对不对。如果接口成功但页面数量不变,就看 postAddToCartAsync 之后有没有 getCartListAsync 重新拉取列表;只改远程不更新本地,刷新后才会变。转写稿还出现过“传了空数组”的问题,原因是筛选条件少了括号,运算顺序错了,最终没有拿到要修改的 ID。加减操作同样要检查 skuNum+num 的边界:大于 200 不请求,小于 1 删除,接口传的是增量 num,不是最终数量。
需求背景与目标
这一节是扩展内容,不强制要求。原因是:详情页购买数量的初始值,按常理通常由后端或产品规则控制,而不是前端根据购物车反推。但作为练习,它可以帮我们理解“加入购物车接口传的是增量,不是最终数量”这个容易出错的地方。
先看目标:用户进入详情页时,如果购物车里已经有当前商品,那么详情页的数量输入框不要永远显示 1,而要显示购物车里该商品的实际数量;用户修改数量后再次加入购物车,最终购物车数量要等于用户输入的数量。
为什么需要这样做?假设购物车里当前商品已有 2 件,详情页仍显示 1。用户想买到 12 件,输入 12 后点“加入购物车”。如果直接把 12 传给接口,而接口把参数当作“增加的数量”,购物车就会变成 2 + 12 = 14,而不是 12。所以前端必须知道“购物车里原来有几件”,再传差值。
先想清楚:加购接口要的是“增量”还是“最终数量”
转写稿里反复强调:加入购物车接口传过去的 skuNum 是增加或减少的数值,正数代表增加,负数代表减少。不是最终数量。
因此思路是:
- 进入详情页后,先获取购物车列表;
- 在
cartList里查找skuId等于当前路由商品 id 的记录; - 如果找到,说明购物车已有该商品,把它的
skuNum记录到this.skuNum,同时把buyNum也改成这个值; - 点“加入购物车”时,接口参数传
this.buyNum - this.skuNum; - 如果购物车没有该商品,
skuNum保持 0,传buyNum即可。
这里的关键是分清两个变量:buyNum 是页面上用户看到、可以修改的数量;skuNum 是“加入购物车前,购物车里已经有的数量”,用来做差值计算。
代码落地
笔记中的代码如下,保留原样:
js
export default {
name: "Detail",
data() {
return {
// 购买的初始数量
buyNum: 1,
// 1-增加该属性记录该商品在购物车中的数量
skuNum:0
}
},
methods: {
// 加入购物车
async addCart(){
sessionStorage.setItem("addCartInfo",JSON.stringify({
buyNum:this.buyNum,
attrList:this.spuSaleAttrList,
...this.skuInfo
}));
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId:this.$route.params.id,
// 3- 计算更新购物车的数量
skuNum:this.buyNum-this.skuNum
})
this.$router.push("/addCartSuccess")
}
},
mounted() {
// 2-获取购物车信息
this.$store.dispatch("cart/getCartListAsync").then(()=>{
const {cartList} = this.$store.state.cart;
// 查看购物车是否有当前商品
const info = cartList.find(v=>v.skuId === this.$route.params.id/1);
if(info){
// this.skuNum记录该商品在购物车中的实际数量
this.buyNum =this.skuNum= info.skuNum;
}
})
}
}mounted 里先派发获取购物车列表的 action,等它完成后再从 this.$store.state.cart.cartList 中取数据。this.$route.params.id/1 是为了把路由参数字符串转成数字,再和购物车里的数字 skuId 比较。找到后,this.buyNum = this.skuNum = info.skuNum 同时完成两件事:页面显示购物车实际数量,记录差值基准。
调试与验证
调试时不要一上来就怀疑接口。转写稿里的做法是先打印 cartList,确认购物车列表有没有拿到;再确认 find 能不能找到当前商品;找到后看 info.skuNum 是不是预期值。
例如购物车里当前商品是 2 件,进入详情页后 buyNum 应该变成 2。用户把数量改成 12,点加入购物车,传给接口的 skuNum 应该是 12 - 2 = 10,购物车最终变成 12。若直接传 12,就会变成 14,这是最典型的错误。
如果购物车里没有当前商品,info 为 undefined,不要报错,也不要改 buyNum,保持默认 1、skuNum 保持 0 即可。转写稿里也提到,如果 cartList 为空,要避免在空值上继续取属性。
易错点清单
第一,buyNum 和 skuNum 不要混。buyNum 是用户输入框里的最终数量,skuNum 是购物车里原来的数量。
第二,路由参数类型要对。this.$route.params.id 是字符串,购物车里的 skuId 通常是数字,所以代码里用了 /1 转数字。
第三,mounted 里获取购物车是异步的,必须放在 then 或 await 之后读 cartList,不能在请求刚发出时就读。
第四,addCart 里要 await 派发加购 action,等接口完成后再跳转成功页;否则可能还没加购成功就跳走了。
第五,这是扩展练习,不符合常理的部分要明确:实际项目中数量初始值、上限、是否允许超过购物车上限,通常应由后端或产品规则决定。转写稿也提到可以咨询产品经理,或参考京东等由后台控制数量的做法。本节只作为理解“差值加购”的练习,不强制要求。
搜索页加入购物车要解决什么问题
为什么需要:搜索页列表里通常只有 skuId,但“加入购物车成功页”要展示商品图片、名称、价格、销售属性等信息;同时还要把商品真正写入购物车。只在按钮上写一个跳转链接不够,必须先把详情取回来,再完成加购和跳转。
怎么想:按“取详情 → 存成功页展示数据 → 调加购接口 → 路由跳转”的顺序做。转写稿里讲师反复强调:遇到“点了没反应”先看网络请求和参数,不要先猜样式问题;接口参数、空数组、空对象都是常见排查点。
怎么做:在 src/pages/Search/index.vue 中给按钮绑定 addCart(item.id),并用 @click.prevent 阻止 <a> 的默认跳转:
vue
<tempalte>
<a @click.prevent="addCart(item.id)" href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
</tempalte>
<script>
// 加入购物车
async addCart(skuId){
// 获取商品详情
await this.$store.dispatch("product/getProductInfoByIdAsync",skuId)
// 将商品详情放置storage中
sessionStorage.setItem("addCartInfo",JSON.stringify({
buyNum:1,
attrList:this.$store.state.product.productInfo.spuSaleAttrList,
...this.$store.state.product.productInfo.skuInfo
}));
// 加入购物车
await this.$store.dispatch("cart/postAddToCartAsync",{
skuId,
skuNum:1
})
// 跳转至加入购物车成功页面
this.$router.push("/addCartSuccess")
},
</script>易错点:笔记里 <tempalte> 是拼写错误,实际应为 template;@click.prevent 不能漏,否则会直接打开 success-cart.html;sessionStorage 只能存字符串,所以必须 JSON.stringify;productInfo 未取到时会读空对象,先确认 getProductInfoByIdAsync 是否成功;加购 action 的参数名是 skuId、skuNum,不要写成别的字段。
成功页数据为什么要放 sessionStorage
为什么需要:成功页需要展示刚加入购物车的商品信息,但路由跳转不适合携带大对象;sessionStorage 在同一次会话内可跨页面读取,刷新成功页也能拿到数据。
怎么想:把成功页真正需要的字段组装成一个对象:buyNum 表示本次购买数量,attrList 是销售属性列表,...skuInfo 展开 SKU 基础信息。转写稿里提到“本地和远程都要改”的思路,这里同样要分清:sessionStorage 是本地展示数据,真正购物车数据由 cart/postAddToCartAsync 写远程。
怎么做:先 await 详情 action,再 setItem("addCartInfo", JSON.stringify({...})),最后跳 /addCartSuccess。成功页读取时用 JSON.parse(sessionStorage.getItem("addCartInfo"))。
易错点:不要先跳转再存数据;不要只存 skuId,否则成功页没有展示内容;spuSaleAttrList 与 skuInfo 的字段名要按接口返回核对,不能凭感觉写。
加购 action 与路由跳转的调试顺序
为什么需要:搜索页加购依赖两个 action:商品详情和购物车写入。任何一个失败,页面都可能“看起来没反应”。
怎么想:按转写稿的调试习惯,先看 Network 里请求地址、请求方式、参数是否正确;再看返回 code 是否成功;最后看本地 sessionStorage 和路由是否执行。转写稿里出现“空数组”“空对象”时,讲师都是先回溯参数,而不是直接改模板。
怎么做:确认 cart/postAddToCartAsync 已接收 { skuId, skuNum: 1 };加购成功后再 this.$router.push("/addCartSuccess")。如果加购失败,应停在当前页并提示,不要盲目跳成功页。
易错点:await 顺序不能乱;skuNum 是“增加数量”而不是“最终数量”时,要按接口文档理解;按钮上的 href 和 target 只是原笔记保留写法,实际以 @click.prevent + router.push 为准。
本节范围与转写稿说明
本节笔记主题是 element-ui 的基本使用,明确包含官网、安装命令、入口文件注册、index.vue 中按钮组件和 $message 提示的代码。需要说明的是,提供的转写稿实际在讲购物车全选切换、数量增减、防抖节流、详情页数量初始值等内容,与“element-ui 的基本使用”不是同一主题。为避免编造,下面不把购物车业务硬套进本节,只依据笔记中明确出现的 element-ui 安装、引入、注册和组件调用展开;转写稿中反复体现的“先跑通再优化、出错先看控制台和网络”的排错顺序,可作为接入第三方 UI 库时的通用检查思路。本节笔记未提供图片,因此不插入图片。
官网与依赖安装
为什么需要:element-ui 不是 Vue 自带组件库,项目要先安装依赖,才能从 element-ui 包中引入组件和样式。笔记给出的官网是 https://element.eleme.cn/#/zh-CN,查组件用法、属性、事件时应以官网文档为准。
怎么想:先确认项目已经是一个可运行的 Vue 项目,再在项目根目录安装依赖。笔记使用 cnpm,说明课堂环境可能使用 cnpm 加速安装;实际项目用 npm、yarn、pnpm 也可以,但不能把命令和包名写错。
怎么做:执行笔记中的安装命令:
shell
cnpm i element-ui易错点:包名是 element-ui,不要写成其他相似名称;安装命令要在项目根目录执行,否则依赖可能装到错误位置;安装后要确认 package.json 中出现依赖记录,再继续做入口注册。
入口文件中全局注册
为什么需要:如果每个页面都单独引入按钮、行布局、消息提示等组件,代码重复且容易漏样式。element-ui 支持在入口文件统一注册,让页面模板可以直接使用 el-button、el-row 等组件,并让 this.$message 这类全局方法可用。
怎么想:入口文件负责创建 Vue 实例和挂载插件。笔记指定入口是 src -> index.js。在这个文件里先引入 element-ui 的组件库,再引入它的主题样式,最后通过 Vue.use(ElementUI) 注册。顺序上应先引入、再注册,注册要发生在创建 Vue 实例之前。
怎么做:按笔记写入:
js
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);易错点:只写 Vue.use(ElementUI) 而漏掉 CSS,按钮可能没有预期样式;import 'element-ui/lib/theme-chalk/index.css'; 路径要照笔记写完整;Vue.use(ElementUI) 要放在入口文件合适位置,确保后续页面能使用;实际项目入口文件名可能是 main.js,但本节笔记写的是 src/index.js,学习时以自己项目结构为准,不要盲目改路径。
在 index.vue 中使用按钮组件
为什么需要:安装和注册完成后,要通过页面模板验证组件是否真正可用。按钮是最直观的验证方式,也能展示 element-ui 的 type 类型差异。
怎么想:模板外层用一个 div 包裹,标题用普通 h3;按钮放在 el-row 中,使用多个 el-button,分别设置默认、primary、success、info、warning、danger 类型。第一个按钮绑定点击事件 fn,用来验证事件和全局消息方法。
怎么做:保留笔记代码如下:
vue
<template>
<div>
<h3>注册界面</h3>
<el-row>
<el-button @click="fn">默认按钮</el-button>
<el-button type="primary">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="info">信息按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
</el-row>
</div>
</template>
<script>
export default {
name: "Register",
methods:{
fn(){
this.$message.error('这是一条消息提示');
}
}
}
</script>易错点:组件标签要写成 el-button、el-row,不要写成原生 button 或拼错;type 的值要使用文档支持的类型;@click="fn" 绑定的是方法名,不要写成 fn();如果页面显示原生未知标签或样式异常,应回到入口文件检查是否注册成功、CSS 是否引入。
使用 $message 做全局提示
为什么需要:注册、提交、校验等场景需要轻量提示。element-ui 注册后提供 this.$message,可以在组件方法中直接调用,减少自己维护弹层状态的成本。
怎么想:在 methods 中定义 fn,通过 this.$message.error('这是一条消息提示') 调用错误类型提示。this 指向当前组件实例,因此前提是入口已经 Vue.use(ElementUI)。
怎么做:点击“默认按钮”时执行 fn,触发消息提示。代码已在上面的 index.vue 中保留。
易错点:$message 是注册后的全局方法,入口未注册时调用会报错;方法名和模板中的 @click 要一致;提示类型 error 要写对。调试时按转写稿体现的顺序:先看控制台报错,再看入口引入和样式,最后检查页面标签与事件绑定,不要一上来就改业务代码。
小结
本节核心链路是:安装 element-ui → 在入口文件引入组件库和主题 CSS → Vue.use(ElementUI) → 在 index.vue 中使用 el-row、el-button → 通过 this.$message.error 验证全局方法。把这条链路跑通,再继续扩展页面功能,比直接堆业务代码更稳妥。
课堂画面






代码示例
js
// src/api/cart.js
// 沿用项目已有 sphRequest 引入
export const postBatchCheckCart = (isChecked, skuIdList) =>
sphRequest.post(`/cart/batchCheckCart/${isChecked}`, skuIdList);
// src/store/cart.js
// state、mutations 沿用已有购物车模块
import { postBatchCheckCart } from "@/api/cart";
const getters = {
// 生成是否全选
getIsAll({ cartList }) {
// 易错点:空数组 every 会返回 true,需要单独处理
if (cartList.length === 0) return false;
return cartList.every(v => v.isChecked === 1);
}
};
const actions = {
// 切换全选状态
async postBatchCheckCartAsync({ getters, state, dispatch }) {
// isChecked:要切换成的状态
const isChecked = getters.getIsAll ? 0 : 1;
// 方案二:抽取所有商品 id
const skuIdList = state.cartList.map(item => item.skuId);
await postBatchCheckCart(isChecked, skuIdList);
// 远程修改后重新拉取列表,保证本地数据同步
await dispatch("getCartListAsync");
}
};
export default {
namespaced: true,
state,
mutations,
actions,
getters
};要点:
- 关键行:
getIsAll用cartList.every(v => v.isChecked === 1)判断是否全部选中。 - 易错点:空购物车时
every返回true,必须加cartList.length === 0返回false。 - 关键行:
const isChecked = getters.getIsAll ? 0 : 1;当前全选则取消,当前未全选则全选。 - 易错点:
postBatchCheckCart第二个参数是数组skuIdList,不是对象;全选后要dispatch("getCartListAsync")重新拉取,不能只改本地。
vue
<!-- src/pages/Cart/index.vue -->
<template>
<div>
<div class="select-all">
<input
@change="$store.dispatch('cart/postBatchCheckCartAsync')"
:checked="getIsAll"
class="chooseAll"
type="checkbox"
>
<span>全选</span>
</div>
<ul>
<li v-for="item in cartList" :key="item.skuId" class="cart-list-con5">
<a @click="upBuyNum(item.skuId, item.skuNum, -1)" href="javascript:void(0)" class="mins">-</a>
<input
@change="changeBuyNum($event, item.skuId, item.skuNum)"
autocomplete="off"
type="text"
:value="item.skuNum"
minnum="1"
class="itxt"
>
<a
@click="upBuyNum(item.skuId, item.skuNum, 1)"
href="javascript:void(0)"
:class="{ plus: true, disabled: item.skuNum >= 200 }"
>+</a>
</li>
</ul>
</div>
</template>
<script>
// goodsNumReg 沿用项目已有校验正则
export default {
data() {
return {
// 节流锁
isLock: false
};
},
computed: {
getIsAll() {
return this.$store.getters["cart/getIsAll"];
},
cartList() {
return this.$store.state.cart.cartList;
}
},
methods: {
// 加减操作:num 为 1 加 1,为 -1 减 1
async upBuyNum(skuId, skuNum, num) {
if (this.isLock) return;
this.isLock = true;
if (skuNum + num > 200) {
// 易错点:超过 200 直接 return 前必须解锁,否则后续点击全部失效
this.isLock = false;
return;
}
if (skuNum + num < 1) {
// 小于 1,根据商品 ID 删除购物车中的商品
await this.$store.dispatch("cart/deleteCartListByIdAsync", skuId);
} else {
await this.$store.dispatch("cart/postAddToCartAsync", {
skuId,
skuNum: num
});
await this.$store.dispatch("cart/getCartListAsync");
}
this.isLock = false;
},
async changeBuyNum(e, skuId, skuNum) {
// 修改后的数量
const buyNum = e.target.value.trim() / 1;
if (goodsNumReg.test(buyNum)) {
await this.$store.dispatch("cart/postAddToCartAsync", {
skuId,
// 易错点:接口 skuNum 是增量,不是目标值,要“修改后 - 修改前”
skuNum: buyNum - skuNum
});
await this.$store.dispatch("cart/getCartListAsync");
} else {
e.target.value = skuNum;
}
}
}
};
</script>要点:
- 关键行:全选复选框
:checked="getIsAll",@change直接派发cart/postBatchCheckCartAsync。 - 关键行:加减按钮把
item.skuId、item.skuNum、1/-1传给upBuyNum。 - 易错点:
upBuyNum用isLock做节流,所有提前return的分支都要先解锁,否则按钮会“锁死”。 - 易错点:输入框修改数量时,
postAddToCartAsync的skuNum要传buyNum - skuNum,不是直接传buyNum。 - 易错点:输入框若改用防抖,不能写成箭头函数,否则
this不是组件实例;笔记写法是debounce(async function(e, skuId, skuNum){...}, 1000)。
js
// src/pages/Detail/index.vue
// 模板沿用详情页已有结构,这里只展示 script 中数量初始值控制
export default {
name: "Detail",
data() {
return {
// 购买的初始数量
buyNum: 1,
// 记录该商品在购物车中的实际数量
skuNum: 0
};
},
methods: {
async addCart() {
sessionStorage.setItem("addCartInfo", JSON.stringify({
buyNum: this.buyNum,
attrList: this.spuSaleAttrList,
...this.skuInfo
}));
await this.$store.dispatch("cart/postAddToCartAsync", {
skuId: this.$route.params.id,
// 易错点:传增量,用“最终购买数量 - 购物车中已有数量”
skuNum: this.buyNum - this.skuNum
});
this.$router.push("/addCartSuccess");
}
},
mounted() {
this.$store.dispatch("cart/getCartListAsync").then(() => {
const { cartList } = this.$store.state.cart;
// 查看购物车是否有当前商品
const info = cartList.find(v => v.skuId === this.$route.params.id / 1);
if (info) {
// buyNum 用于输入框展示,skuNum 记录购物车实际数量
this.buyNum = this.skuNum = info.skuNum;
}
});
}
};要点:
- 关键行:
skuNum: 0用来记录该商品在购物车中的实际数量,初始值不能省。 - 关键行:
mounted中先getCartListAsync,再用cartList.find(v => v.skuId === this.$route.params.id / 1)查找当前商品。 - 易错点:
this.$route.params.id是字符串,购物车里的skuId通常是数字,比较时要/ 1转数字。 - 易错点:加入购物车传的是
this.buyNum - this.skuNum,不是直接传this.buyNum,否则会把“目标数量”当成“增量”重复累加。
常见错误
1. 空购物车时"全选"仍然是勾选状态
- 现象:把购物车商品全删光,全选框依然打勾,刷新也不消失。
- 原因:
cartList.every(v => v.isChecked === 1)对空数组返回true(真空真),而空态下这个"全选"没有任何意义。 - 修法:getter 里先判空再算 ——
if (!cartList.length) return false;,同时购物车页面补一个空态提示。
2. 点全选后接口发出去了,但购物车状态没变
- 现象:Network 里
batchCheckCart返回 200,请求体却是[],页面纹丝不动。 - 原因:
v.isChecked === getters.getIsAll ? 1 : 0少写了括号,被解析成(v.isChecked === getters.getIsAll) ? 1 : 0,一个都筛不出来。 - 修法:
v.isChecked === (getters.getIsAll ? 1 : 0);或者干脆把所有 skuId 一次性传给服务端,把筛选交给后端(请求压力换代码简洁)。
3. 数量越改越大,本来 3 改 4 变成 7
- 现象:输入框从 3 改成 4,接口成功后购物车显示 7。
- 原因:
postAddToCart的skuNum是增量语义(正数加、负数减),却把"最终值 4"直接传了进去。 - 修法:一律传差值
buyNum - skuNum;加减号直接传±1;详情页加购则要减去该商品在购物车中已有的数量。
4. 加减按钮点几下之后彻底失效
- 现象:连点
+或-,某次之后按钮再也点不动,只能刷新页面。 - 原因:
isLock = true之后还有多个return(超过 200 直接 return、小于 1 走删除分支),锁没被释放,后续点击全被拦掉。 - 修法:边界判断放到加锁之前;或把解锁写进
finally;解锁语句必须在所有分支都会走到。
5. 防抖/节流包裹后 this 丢失
- 现象:控制台报
Cannot read property '$store' of undefined。 - 原因:
debounce(() => {...}, 1000)用了箭头函数,this指向定义时的模块作用域,不是组件实例。 - 修法:用普通函数
debounce(function () { ... }, 1000),lodash 会把this绑定到调用者。
6. 购物车页点 + 数字不变,详情页加购却正常
- 现象:购物车页点加号,接口 200,页面数字不动。
- 原因:加购 action 只负责调接口,不负责同步列表;详情页加完就跳转所以看不出问题,购物车页原地停留就暴露了。
- 修法:购物车页的动作里,接口返回后追加一次
dispatch('getCartListAsync')重拉列表(也可在 action 内部统一处理,但要注意详情页不需要)。
7. 详情页初始数量不对,加购后数量翻倍
- 现象:购物车已有 5 件,进详情页默认还是 1,点加购后变成 6。
- 原因:进页面没有回填购物车里的实际数量,提交时又把输入框的值当成增量传。
- 修法:
mounted里先拉一次购物车列表,找到当前 skuId 记录实际数量skuNum,提交时传buyNum - skuNum。
8. 输入框清空或输入字母后失焦,数量变 NaN
- 现象:把输入框内容删光再失焦,接口报错或商品数量变成空。
- 原因:没有对输入值做合法性校验就发请求。
- 修法:正则校验(正整数且 ≤200),不合法就把
e.target.value回填成原数量,不调接口。
练习
练习 1|锁的健壮性 改造 upBuyNum:把加锁/解锁逻辑用 try/finally 重写。分别验证以下四种情况按钮都不会卡死:①数量 200 时点 +;②数量 1 时点 -(走删除分支);③接口报错时点 +;④快速连点 10 次。
练习 2|throttle 的 leading/trailing 组合 用手写 isLock 和 lodash throttle 两种方案各实现一遍加减按钮,在"快速连点 10 次"场景下对比:请求次数分别是多少?最后一次点击是否被丢弃?把 leading/trailing 的四种组合都跑一遍,说明哪种组合最适合"加减数量"这个业务。
练习 3|输入框的四种边界 针对数量输入框,写出从 198 开始依次执行以下操作时,各自应有的行为和回填逻辑:①删成 19;②删成空串;③输入 201;④输入 abc。要求:非法值不调接口,且输入框内容必须被还原。
练习 4|全选切换的失败回滚 给全选切换加异常处理:接口返回非 200 时,把 UI 的勾选状态还原成切换前的样子,并弹出提示。思考:因为勾选状态来自 getter(派生状态)而不是本地 data,回滚该怎么做才最自然?
练习 5|详情页初始数量方案切换 现在的实现是"详情页初始值 = 购物车已有数量,加购时传差值"。如果产品改成"详情页初始值固定为 1,加购时在原数量上累加(超 200 由后端截断)",请说明需要改动哪几处代码、删掉哪些逻辑,并对比两种方案在用户体验上的差别。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 全选切换一 | 30:43 |
| 2 | 全选切换二 | 17:45 |
| 3 | 接口问题 | 22:41 |
| 4 | 操作购物车数量 | 37:28 |
| 5 | 通过防抖更改数量 | 16:46 |
| 6 | 通过节流实现加减 | 18:47 |
| 7 | 详情页数量的初始值控制 | 23:44 |
小结
- 全选是派生状态,用 getter 算出来,不要额外塞进 state 再手动维护,否则一定会有同步漏掉的地方。
- 本地与远程的两份数据,只改一边都会出问题:只改远程刷新才生效,只改本地刷新被打回原形。"调接口 + 重拉列表"是最省心的解法,代价是多一次请求。
- 加购接口的
skuNum是增量语义,不是最终值。凡是"修改数量"的地方,第一步永远是换算成差值,这是最容易翻车的一处。 - 防抖适合"最终值"场景(输入框改数量),节流适合"连续动作"场景(加减号连点)。防抖在连续操作期间一次请求都不发,用在加减号上用户会觉得按钮坏了。
- 节流把间隔写死(2000ms)是拍脑袋,用"锁 + 请求完成再解锁"能让间隔自适应接口耗时,体验更好且不用调参。
- 所有边界(≤0、>200、非法输入)必须在调接口之前拦掉,而且拦截逻辑不能破坏锁的状态——这是"按钮点几下就死"最常见的根因。
- 详情页初始数量属于扩展需求,实现前先确认是否符合产品与后台约定,别把课堂练习当成行业规范。
- 排查顺序固定下来:先看 Network(地址、方式、参数对不对)→ 再看数据链路(cartList → filter → 传参)→ 最后才怀疑状态同步。绝大多数"功能没反应"的问题,答案都在请求参数里。