Skip to content

购买数量加减与加入购物车成功界面(Lesson19 1/3) ​

本页对应 Lesson19,共 6 段视频,约 1 小时 28 分。

学习目标 ​

  • 能够实现商品详情页购买数量的加减操作,并在数量为 1 或 200 时禁用对应按钮、处理边界状态。
  • 能够对购买数量输入进行数字校验与越界纠正,并使用防抖或失焦等方式完成数量更新。
  • 能够为“加入购物车成功”界面配置路由,并实现返回上一页、查看商品详情等导航跳转。
  • 能够完成商品颜色、版本等配置项的切换,正确更新选中状态并同步展示当前配置。
  • 能够将加入购物车商品的标题、图片、数量、价格和配置信息保存到本地存储,并在成功界面渲染呈现。
  • 能够完成购物车界面的全选、件数统计、总价计算和数量修改等基础功能,并说明相关操作调用接口的实现思路。

知识点 ​

购买数量加减要解决什么问题 ​

为什么需要:商品详情页在“加入购物车”前,必须让用户确认购买数量。数量不能无限加,也不能减到 0,通常限制在 1–200。用户既可能点“+ / -”,也可能直接在输入框里输入,所以按钮和输入框都要做校验。

怎么想:先把交互规则定清楚:数量初始为 1;减到 1 不能再减;加到 200 不能再加;输入必须是数字且范围 1–200,不合法就恢复成上一次成功的数量;输入过程中不要每敲一个字符就立刻纠正,否则会打断用户输入,因此需要防抖。

怎么做:在 src/pages/Detail/index.vue 中,用 data 保存 buyNum,模板里放输入框和加减按钮,方法里分别处理按钮点击、输入框校验、原生防抖和 lodash 防抖。

易错点:输入框不要直接 v-model 双向绑定,因为这里还要校验和纠正;a 标签要阻止默认跳转;按钮的 disabled 样式只是视觉提示,方法里仍然要写边界 return 兜底。

模板结构:输入框、加号、减号 ​

为什么需要:模板要同时承载“输入数量”和“点击加减”两种操作。加号到 200、减号到 1 时,要通过动态 class 给出禁用样式。

怎么想:输入框用 :value="buyNum" 显示当前数量,用 @input 或 @change 触发校验;加号、减号用同一个 changeBuyNum,通过事件对象判断点的是哪一个。

怎么做:笔记中保留了三套输入框方案,最终启用的是 @input="upBuyNum3":

vue
<template>
<div class="controls">
    <!--   1- @change -->
    <!--                                <input @change="upBuyNum" autocomplete="off" :value="buyNum" class="itxt">-->
    <!--   2- @input防抖1-原生 -->
    <!--                                <input @input="upBuyNum2" autocomplete="off" :value="buyNum" class="itxt">-->
    <!--   3- @input防抖2-lodash-->
    <input @input="upBuyNum3" autocomplete="off" :value="buyNum" class="itxt">
    <a href="###" :class="['plus',{disabled:buyNum===200}]" @click.prevent="changeBuyNum">+</a>
    <a href="###" :class="['mins',{disabled:buyNum===1}]" @click.prevent="changeBuyNum">-</a>
    </div>
</template>

易错点::class 里的 disabled 只是样式;@click.prevent 不能漏,否则 href="###" 可能引起页面跳转;输入框用 :value 而不是 v-model,是为了让非法输入能够回退。

按钮加减:changeBuyNum ​

为什么需要:用户点“+”或“-”时,数量要按 1 递增或递减,并且到边界后不能继续变化。

怎么想:先判断点击的是减号还是加号,再判断当前数量是否已经到边界。减号看 buyNum === 1,加号看 buyNum === 200,到边界直接 return。

怎么做:用 e.target.classList.contains("mins") 和 contains("plus") 判断点击目标:

js
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++;
    }
}

易错点:不要靠按钮文字判断加减;样式禁用后仍然可能触发点击,所以方法里的边界判断必须有;减到 1、加到 200 要同时配合模板的动态 class,给用户明确反馈。

输入框校验:@change 方案 ​

为什么需要:用户可能直接在输入框里输入数量,必须保证最终值在 1–200 之间。

怎么想:@change 在输入框失焦或回车时触发,适合“输入完成后再校验”的方案。合法就更新 buyNum,不合法就把输入框恢复成上一次成功的 buyNum。

怎么做:先用 trim() 去空格,再 /1 转数字,用 goodsNumReg 校验:

js
upBuyNum(e){
    // 输入的内容必须是数字,范围1---200
    const num = e.target.value.trim()/1;
    if(goodsNumReg.test(num)){
        this.buyNum = num;
    }else{
        e.target.value = this.buyNum;
    }
}

易错点:goodsNumReg 要能覆盖 1–200 的数字校验,笔记中未展开具体正则;空字符串、字母、小数等非法值都要走恢复逻辑;恢复时用的是 this.buyNum,不是用户刚输入的非法值。

原生防抖:@input + setTimeout ​

为什么需要:如果输入框用 @input,用户每敲一个字符都会触发校验。立即纠正会打断输入,所以需要防抖:等用户停止输入一段时间后再校验。

怎么想:每次输入先清除上一次定时器,再重新计时;1 秒内没有再次输入,才执行校验。转写稿里也强调“指定时间到达后执行一次,期间再次触发就重新计时”。

怎么做:把定时器挂在 this.timer 上:

js
upBuyNum2(e){
    // 防抖:在指定的时间到达后函数执行一次,如果指定时间没有到,事件再次触发,重新计时。
    if(this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(()=>{
        console.log("111")
        // 输入的内容必须是数字,范围1---200
        const num = e.target.value.trim()/1;
        if(goodsNumReg.test(num)){
            this.buyNum = num;
        }else{
            e.target.value = this.buyNum;
        }
    },1000)
}

易错点:每次触发要先 clearTimeout 旧定时器;timer 要保存在 this 上;防抖结束后仍然要执行同样的合法校验和非法恢复。

lodash 防抖:upBuyNum3 ​

为什么需要:手写防抖逻辑重复,项目里常用 lodash 的 debounce,写法更简洁。

怎么想:把输入校验逻辑包进 debounce,等待 1000ms;用户停止输入 1 秒后才执行校验。

怎么做:笔记中给出三步:下载 cnpm install lodash;引入 import {debounce} from "lodash";在 methods 中使用:

js
upBuyNum3:debounce(function(e){
    const num = e.target.value.trim()/1;
    if(goodsNumReg.test(num)){
        this.buyNum = num;
    }else{
        e.target.value = this.buyNum;
    }
},1000)

易错点:注意 this 指向,笔记中使用 function(e) 而不是箭头函数;debounce 返回的是新函数,不要在一次执行里反复创建;校验逻辑要和 @change 方案保持一致。

调试顺序与验证 ​

为什么需要:数量加减涉及模板、数据、事件对象、防抖和样式,按顺序验证更容易定位问题。

怎么想:先验证按钮加减的 1 和 200 边界,再验证输入框非法值恢复,最后切换 @change、原生防抖、lodash 防抖三种方案观察差异。

怎么做:先点减号到 1,确认不能再减;再点到 200,确认不能再加;输入 201 或字母,等待防抖结束后看是否恢复为最后一次成功值;把输入框事件从 @change 换成 @input,观察是否要等 1 秒才纠正。

易错点:转写稿中特别提到“样式改成不可用,但实际还能点”,所以逻辑判断不能省;用 classList.contains 判断加减时,要确认 class 名与模板一致;输入非法时检查 e.target.value 是否被恢复成 this.buyNum。

先做数量加减:把边界和禁用状态一起考虑 ​

为什么需要:加入购物车成功页要展示购买数量,但数量不能无限加减。转写稿里明确要求:只能输入数字,不能小于 1,也不能大于 200;到边界后按钮要有不可用样式和提示。
怎么想:先做最基础的 + / -,再补边界判断。按钮“变灰”只是样式,不能代替逻辑判断;否则样式不可用但实际还能点。京东的交互可以作为参考:到 200 后加号不可点,到 1 后减号不可点。
怎么做:用一个 buyNum 保存数量,加减时先判断边界:

js
data() {
  return { buyNum: 1 }
},
methods: {
  changeNum(type) {
    if (type === 'add') {
      if (this.buyNum >= 200) return
      this.buyNum++
    } else {
      if (this.buyNum <= 1) return
      this.buyNum--
    }
  }
}

模板里根据数量加禁用样式:

html
<button @click="changeNum('sub')" :class="{ disabled: buyNum <= 1 }">-</button>
<input v-model.number="buyNum" />
<button @click="changeNum('add')" :class="{ disabled: buyNum >= 200 }">+</button>

易错点:只加 disabled 样式不够,点击事件里仍要 return;输入框要转数字;不要用 v-model 后完全不管非法值。转写稿还提到可参考 lodash 做防抖,但核心仍是“指定时间内只执行一次”,边界判断不能省。

搭建加入购物车成功页路由 ​

为什么需要:点击“加入购物车”后要进入成功界面,而不是停在详情页。
怎么想:先把静态页面中的中间内容拿出来,做成 AddCart 组件,再在路由里注册地址,最后让详情页按钮跳转。
怎么做:新建组件,复制静态页中间结构和对应样式;在路由中增加:

js
{ path: '/addCart', component: AddCart }

详情页按钮:

js
addCart() {
  this.$router.push('/addCart')
}

易错点:不要整页复制头部和底部;图片路径要改成项目里的真实路径;按钮最好用 a 或 button 并绑定点击,因为后面还要加业务逻辑。转写稿里还处理了“查看商品详情”返回,可用 this.$router.go(-1)。

切换商品配置:先找一级,再找二级 ​

为什么需要:用户选“透明版/陶瓷黑”“8G/16G”等配置后,成功页要显示最终选中的配置。
怎么想:配置在 Vuex 的 product.spu 里,一级是颜色/版本组,二级是具体项。点击时传两个 ID:一级 ID 和二级 ID;先找到一级,再在一级的 attrList 里找二级。
怎么做:

js
changeAttr(a1Id, a2Id) {
  const info1 = this.$store.state.product.spu.find(item => item.id === a1Id)
  if (!info1) return
  const info2 = info1.attrList.find(item => item.id === a2Id)
  if (!info2 || info2.est === '1') return
  info1.attrList.forEach(item => { item.est = '0' })
  info2.est = '1'
}

易错点:转写稿调试时通过打印 info1/info2 定位问题,发现二级 est 的判断会影响是否继续执行;另外“先清除之前选中,再设置当前”会导致重复点击已选中项时先清自身再设自身,优化方式是先判断当前 est === '1' 就直接 return。

保存商品信息并在成功页呈现 ​

为什么需要:Vuex 刷新会丢数据,路由传对象也不方便;成功页刷新后仍要能看到商品标题、图片、数量、价格和配置。
怎么想:点击加入购物车时,把成功页需要的最小信息整理出来,存到 localStorage,成功页读取并 JSON.parse。
怎么做:

js
localStorage.setItem('cartItem', JSON.stringify({
  title: this.skuInfo.title,
  image: this.skuInfo.image,
  price: this.skuInfo.price,
  buyNum: this.buyNum,
  attrList: this.skuInfo.attrList
}))

成功页:

js
data() {
  return {
    cartInfo: JSON.parse(localStorage.getItem('cartItem')) || {}
  }
}

渲染图片可设宽高 60×60,配置用 v-for 遍历 attrList,再找 est === '1' 的项显示值。
易错点:localStorage 只能存字符串,必须 JSON.stringify;读取要容错;Vuex 刷新丢失是正常现象,所以关键展示信息要落本地存储。

先明确目标:成功页要有独立路由 ​

为什么需要:点击“加入购物车”后,不能只弹一个提示,通常要进入“加入购物车成功”页面,让用户确认商品信息,并引导“查看商品详情”或“去购物车结算”。所以本节先完成页面骨架和路由,商品信息传递放到下一步。

怎么想:先把静态资料里的成功页中间内容拿过来,做成 Vue 组件;再到 src/router/index.js 注册路径 /addCartSuccess。转写稿里也是这个顺序:先在 src/pages 下创建 AddCartSuccess,再从静态页面复制中间部分,最后加路由配置。

怎么做:创建 src/pages/AddCartSuccess/index.vue,模板如下。注意图片保留,alt 改成中文,方便说明图片内容。

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 src="../../assets/images/gocart.jpg" alt="加入购物车商品图片">
                    </div>
                    <div class="right-info">
                        <p class="title">美的(Midea)电饭煲WFZ5099IH IH电磁加热 1250W大火力 钛金釜5L电饭锅</p>
                        <p class="attr">颜色:WFZ5099IH/5L钛金釜内胆 数量:1</p>
                    </div>
                </div>
                <div class="right-gocart">
                    <a href="#" class="sui-btn btn-xlarge">查看商品详情</a>
                    <a href="cart.html" class="sui-btn btn-xlarge btn-danger " target="_blank">去购物车结算 &gt; </a>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name: "AddCartSuccess"
}
</script>

易错点:不要整页复制静态页面,否则会把头部、底部也带进来;图片路径要从当前组件位置算,../../assets/images/gocart.jpg 对应到 src/assets/images/gocart.jpg;href="#" 的“查看商品详情”如果不处理,点击会回到页面顶部,后面应改成后退逻辑。

路由配置:路径与组件绑定 ​

为什么需要:Vue 项目里页面组件不会自动变成可访问地址,必须在路由表里注册,浏览器访问 /addCartSuccess 时才能渲染该组件。

怎么想:在 src/router/index.js 的 routes 数组里加一个对象:path 写成功页地址,component 指向刚创建的组件。笔记给出的核心配置是:

js
{
    // 成功加入购物车界面
    path:"/addCartSuccess",
    component:AddCartSuccess
}

怎么做:在路由文件顶部导入组件,例如按项目现有风格写 import AddCartSuccess from '@/pages/AddCartSuccess/index.vue',再把上面的对象放进 routes。转写稿里强调“打开 src/router/index.js,给它增加一个路由配置项”。

易错点:忘记导入组件会报 AddCartSuccess is not defined;path 和后面跳转的地址要完全一致;组件名大小写要统一;如果项目使用 history 模式,开发环境刷新深链接可能需要服务端配置,先确认项目已有配置再判断。

从详情页跳转:点击加入购物车进入成功页 ​

为什么需要:成功页不能靠手输地址访问,正常流程是用户在商品详情页点击“加入购物车”后进入。

怎么想:详情页按钮绑定点击方法,在方法里调用 this.$router.push('/addCartSuccess')。转写稿中就是在详情页找到“加入购物车”按钮,给它加点击事件,再执行路由跳转。

怎么做:如果按钮是 a 标签,建议加 @click.prevent="addCart",方法里写 this.$router.push('/addCartSuccess')。如果暂时不传商品信息,先只验证路由能否跳通。

易错点:直接写 href="/addCartSuccess" 会触发整页刷新,不是单页路由跳转;push 的路径前要有 /;点击 href="#" 不阻止默认行为会跳回顶部。

成功页两个按钮:查看详情与去购物车结算 ​

为什么需要:成功页要提供下一步操作。转写稿里明确提到,“查看商品详情”可以让它后退,“去购物车结算”保留跳购物车。

怎么想:“查看商品详情”回到上一页最自然,可用 this.$router.go(-1) 或 router.back();“去购物车结算”笔记里是 href="cart.html" target="_blank",先按静态购物车页处理。

怎么做:把“查看商品详情”改成 @click="$router.go(-1)" 或绑定方法;去购物车结算保留原链接。验证时从详情页点加入购物车,成功页出现后点“查看商品详情”,应回到详情页。

易错点:go(-1) 依赖历史记录,直接打开成功页时可能没有上一页;target="_blank" 会开新窗口,和当前路由跳转不是一回事;按钮样式类不要丢。

调试与验证:先跑通再谈数据 ​

为什么需要:本节只搭页面和路由,必须先用固定数据验证页面能显示、路由能跳转,再进入下一步传商品信息。

怎么想:按转写稿流程,启动服务,进入商品详情,点击加入购物车,观察地址是否变为 /addCartSuccess,页面是否出现成功提示和商品图。

怎么做:若图片报错,先检查 src/assets/images/gocart.jpg 是否存在、文件名大小写是否一致;若路由不跳,检查 router.push 路径和 router/index.js 配置是否一致;若页面空白,检查组件是否导入、模板根节点是否正常。

易错点:转写稿里出现过图片路径报错,本质是资源路径或文件名不对;不要把“路由配置好了”误认为“点击按钮就会跳”,详情页的点击方法也要补上。完成本节后,成功页只是静态展示,商品标题、图片、数量等动态信息要在后续“呈现成功加入购物车的商品信息”中再处理。

为什么需要切换商品配置 ​

商品详情页里通常有颜色、版本等销售属性。用户点“透明”“陶瓷黑”“8G”“16G”时,页面要立刻显示当前选中项;后续点击“加入购物车”时,也要把用户最终选中的配置保存下来。所以切换配置不是单纯改样式,而是修改 Vuex 中商品数据里的 isChecked 状态。
怎么想:数据结构是两级的——spuSaleAttrList 表示一级属性(如颜色、版本),每项里的 spuSaleAttrValueList 表示二级可选值。同一级下只能有一个值被选中,因此点击新值时,要先把同组旧选中项清掉,再把新项设为选中。
怎么做:在 src/store/product.js 增加 mutation,在详情页把一级 id 和二级 id 传进去。
易错点:不要把选中状态只写在组件本地;不要直接改接口原始数据;isChecked 在数据里是字符串 "1"/"0",比较时要注意类型。

在 Vuex 中实现互斥选中 ​

为什么需要:详情页多个组件都可能读取选中配置,放到 Vuex 便于统一维护。
怎么想:mutation 接收 {a1Id,a2Id}。先按 a1Id 找到一级项 info1;再在一级项里找之前 isChecked === "1" 的二级项并清成 "0";然后找当前点击的 info2,如果它已经是 "1" 就直接返回,否则设为 "1"。
怎么做:保留笔记代码如下:

js
// 更改选中的商品标签
UP_ATTR_LIST_BY_ID(state,{a1Id,a2Id}){
    // 找到一级信息
    const info1 =  state.productInfo.spuSaleAttrList.find(v=>v.id === a1Id);
    if(info1){
        // 将之前为1的清除
        const info = info1.spuSaleAttrValueList.find(v=>v.isChecked === "1");
        info.isChecked = "0";

        // 更改选中项
        const info2 = info1.spuSaleAttrValueList.find(v=>v.id === a2Id);
        // 如果当前值为1,说明你点击的是已经选中的配置
        if(info2.isChecked === "1") return;
        info2.isChecked = "1";
    }
},

易错点:find 可能找不到,实际项目里应判空;a1Id、a2Id 与数据里的 id 类型要一致;点击已选中项时,最好先判断再清除,避免“先把自己清掉再设回来”的多余逻辑。

页面绑定与点击传参 ​

为什么需要:模板要知道渲染哪些属性、哪个值高亮、点击时传哪两个 id。
怎么想:外层 v-for 遍历一级属性,内层遍历二级值;点击时把 item.id 作为 a1Id,把 info.id 作为 a2Id,提交到 product/UP_ATTR_LIST_BY_ID。
怎么做:保留笔记模板代码:

vue
<div class="chooseArea">
    <div class="choosed"></div>
    <dl v-for="item in spuSaleAttrList" :key="item.id">
        <dt class="title">选择{{ item.saleAttrName }}</dt>
        <dd @click="$store.commit('product/UP_ATTR_LIST_BY_ID',{a1Id:item.id,a2Id:info.id})" :class="{active:info.isChecked/1===1}" v-for="info in item.spuSaleAttrValueList"
            :key="info.id" changepirce="0">{{ info.saleAttrValueName }}
        </dd>
    </dl>
</div>

易错点:commit 要带命名空间 product/;v-for 的 key 要用唯一 id;info.isChecked/1===1 是字符串转数字的写法,更直观可写成 info.isChecked === '1';changepirce 是原笔记拼写,实际应为 changeprice,不要误以为它参与选中逻辑;dd 建议加 cursor:pointer,让用户知道可点。

调试过程与易错点 ​

为什么需要:切换配置涉及两级查找和字符串状态,页面不更新时不能只盯样式。
怎么想:按“传参是否对 → 一级是否找到 → 二级是否找到 → isChecked 当前值 → 是否 commit 成功”的顺序排查。转写稿中讲师通过打印 info1、info2 来确认点击“透明”时传过去的 a2Id 能匹配到二级项,并查看该项 isChecked 是 "0" 还是 "1"。
怎么做:在 mutation 里临时打印 a1Id、a2Id、info1、info2;确认 info2.isChecked 为 "0" 时,代码应把它改成 "1";若页面 class 没变,再检查模板是否绑定了 active、Vuex 是否响应式更新、字段名大小写是否一致。
易错点:把 "1" 写成数字 1 导致比较失败;点击已选中项时先清除再判断,逻辑顺序不优;只改样式不改状态;find 结果未判空;误把一级 id 和二级 id 传反。

可选优化 ​

为什么需要:用户重复点击已选中的配置时,没必要再提交 mutation。
怎么想:可以在模板点击时先判断 info.isChecked === '1',是则直接返回;也可以把提交逻辑封装成方法,在方法里判断后再 commit。
怎么做:例如 @click="info.isChecked === '1' ? null : $store.commit(...)",或写 changeAttr(item.id, info) 统一处理。
易错点:优化不能破坏“同组互斥”原则;切换完成后,后续加入购物车读取的应是最终唯一选中的配置。

先明确目标:成功页要显示什么 ​

为什么需要:点击“加入购物车”后不能只弹提示,还要进入成功页,让用户确认刚加入的商品:图片、标题、数量、价格、颜色/版本等配置。转写稿里反复强调:成功页要“呈现你购买了多少个商品”“有名字、数量、颜色”。
怎么想:详情页已经拿到 skuInfo、buyNum、spuSaleAttrList,成功页只需要读取这些数据并渲染。难点是跨页面传数据后刷新不能丢。转写稿里先考虑 Vuex(转写稿同音“物忧叉”),但刷新后 Vuex 数据会空,所以改用 sessionStorage(转写稿同音“责有任/死0号认职”)。
怎么做:详情页加入购物车时,把需要展示的数据序列化写入 sessionStorage,再跳转 /addCartSuccess。
易错点:sessionStorage 只能存字符串;键名必须前后一致;不要直接把整个大对象无整理地塞进去,成功页需要什么就存什么。

详情页:写入购物车信息并跳转 ​

为什么需要:成功页是另一个路由组件,不能直接复用详情页的 data。
怎么想:把 buyNum、配置项 attrList 和 skuInfo 合并成一个对象。...this.skuInfo 放在后面,能把图片、标题、价格等字段一起带过去。
怎么做:在 src/pages/Detail/index.vue 的 addCart 中写:

vue
<script>
    // 加入购物车
    addCart(){
        sessionStorage.setItem("addCartInfo",JSON.stringify({
            buyNum:this.buyNum,
            attrList:this.spuSaleAttrList,
            ...this.skuInfo
        }));

        this.$router.push("/addCartSuccess")
    },
</script>

易错点:必须 JSON.stringify;跳转路径要和路由配置一致;attrList 存的是 spuSaleAttrList,成功页遍历的就是它。

配置成功页路由并进入 ​

为什么需要:成功页是一个独立页面,必须有路由才能被 $router.push 打开。转写稿里说先在 src/pages/AddCartSuccess 建组件,再到 router/index 增加配置项。
怎么想:成功页不需要商品分类等复杂布局,直接使用静态页中间内容即可。
怎么做:创建 src/pages/AddCartSuccess/index.vue,在路由中配置 /addCartSuccess 指向该组件;详情页按钮触发 addCart。
易错点:路由路径大小写、组件引入路径要正确;如果页面图片 404,检查静态资源路径,转写稿里就出现过图片路径问题。

成功页:读取并渲染商品信息 ​

为什么需要:成功页刷新后仍要能显示商品,所以不能只依赖 Vuex。
怎么想:在 data 初始化时读取 sessionStorage,有值就 JSON.parse,没有就给空对象,避免模板访问 undefined。
怎么做:src/pages/AddCartSuccess/index.vue 代码如下:

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="addCartInfo.skuDefaultImg">
                    </div>
                    <div class="right-info">
                        <p class="title">{{addCartInfo.skuName}}</p>
                        <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 btn-xlarge">查看商品详情</a>
                    <a href="cart.html" class="sui-btn btn-xlarge btn-danger " target="_blank">去购物车结算 &gt; </a>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name: "AddCartSuccess",
    data(){
        return {
            addCartInfo:sessionStorage.getItem("addCartInfo")?JSON.parse(sessionStorage.getItem("addCartInfo")):{}
        }
    }
}
</script>

易错点:JSON.parse 前先判断有没有值;键名必须是 addCartInfo;图片字段是 skuDefaultImg;数量是 buyNum;价格来自 skuInfo.price。

配置项渲染与调试要点 ​

为什么需要:用户要看清楚选中的颜色、版本,否则成功页信息不完整。
怎么想:attrList 是数组,每一项里有 saleAttrName 和 spuSaleAttrValueList;选中项通过 isChecked === '1' 判断。转写稿调试时输出过 info 和 isChecked,发现它是字符串 '1',所以不能写数字 1。
怎么做:模板里 v-for 遍历 addCartInfo.attrList,用 find 找到选中值再显示 saleAttrValueName。
易错点:find 可能找不到而返回 undefined,模板直接取属性会报错,要保证加入购物车前已有选中项;isChecked 用全等时注意类型;sessionStorage 与 localStorage 的选择要看产品规划:关闭浏览器是否清空。

说明:本节笔记未附图片与完整代码块,以下代码按转写稿明确口述整理;原笔记若含图片,应按原位置保留并将 alt 改为中文。

加入购物车:先定产品路径,再定数据落点 ​

为什么需要:加入购物车不是单纯跳页面,它牵涉“未登录能不能加”“数据放内存还是放存储”“跨设备是否同步”。转写稿明确对比了两种产品方案:未登录也允许加入,用于引导登录;或必须登录才能加入。实现前必须先定方案。

怎么想:点击加入购物车后,第一步调用接口,把购买商品告诉后端;第二步把成功页要展示的商品基本信息保存到 store/storage,再跳转成功页。转写稿特别提醒:只放 Vuex/内存,刷新就没了;用 localStorage 还是 sessionStorage 要看产品规划,关闭浏览器是否清空不同。

怎么做:详情页点击时整理标题、图片、数量、价格、选中配置,序列化后保存,再跳路由:

js
addCart() {
  localStorage.setItem('cartItem', JSON.stringify({
    title: this.productInfo.title,
    image: this.productInfo.image,
    buyNum: this.buyNum,
    price: this.price,
    attrList: this.attrList
  }))
  this.$router.push('/addCart')
}

易错点:不能把对象直接塞进 storage,否则会变成 [object Object];写入和读取的 key 必须一致;成功页要兜底空对象,避免刷新或直接访问时报错。

购买数量加减:边界、禁用样式与输入校正 ​

为什么需要:数量不能无限加减。转写稿要求必须输入数字,不能小于 1,也不能大于 200;小于 1 纠正为 1,大于 200 纠正为 200。到边界后按钮要禁用并给友好提示。

怎么想:加号到 200 后不能再加,减号到 1 后不能再减。只改样式不够,点击事件里仍要拦截。输入框还要处理非数字和超限,转写稿用京东案例说明:输入 201 会自动纠正,输入字母会等一会儿再校正,这涉及防抖;也可以用失焦时校正。

怎么做:统一用 changeBuyNum(step) 判断:

html
<input :value="buyNum" />
<button :class="{ disabled: buyNum === 1 }" @click="changeBuyNum(-1)">-</button>
<button :class="{ disabled: buyNum === 200 }" @click="changeBuyNum(1)">+</button>
js
changeBuyNum(step) {
  if (step < 0 && this.buyNum <= 1) return
  if (step > 0 && this.buyNum >= 200) return
  this.buyNum += step
}

易错点:只加 disabled 样式但没在方法里 return,按钮实际仍可点;输入框只做 v-model 不校验,会出现负数、小数、字母;超限后没有纠正到最后成功值。

成功页路由:新增页面并让“查看详情”可返回 ​

为什么需要:点击加入购物车后要进入成功页,成功页还要有“查看商品详情”“去购物车结算”等入口,所以必须先有路由。

怎么想:把静态资料里的成功页中间内容抽成组件,去掉多余头部底部;在路由表新增 /addCart;详情页按钮用 router.push 进入;查看详情用 router.go(-1) 或 history.back() 返回上一页。

怎么做:

js
{ path: '/addCart', component: AddCart }
js
this.$router.push('/addCart')
this.$router.go(-1)

易错点:路由 path 与跳转地址不一致;成功页图片路径写错导致 404,转写稿中就通过控制台发现图片没出来并改路径;返回按钮误写成固定详情地址,丢失来源页。

配置切换:先判断当前项,再清除旧选中 ​

为什么需要:用户选“透明/陶瓷黑”“8G/16G”后,成功页和购物车要显示最终选中的颜色、版本。转写稿的数据结构是 attrList 里再嵌数组,每项有选中状态。

怎么想:点击某个配置时,传一级 ID 和二级 ID;先找到一级配置,再找二级配置;若当前点击项已经选中,直接 return;否则先把同组旧的选中项清掉,再把当前项置为选中。

怎么做:调试时先打印 info1、info2。转写稿中点击透明版后目标项仍是 0,问题出在“已选中判断”位置和条件上,导致该改的没改。修正逻辑:

js
updateAttr(a1Id, a2Id) {
  // 找到一级、二级配置
  // 若当前项 selected === '1',直接 return
  // 先清除同组 selected === '1' 的旧项
  // 再把当前项 selected 置为 '1'
}

易错点:先把自己改成 0 再找唯一项,会把自身也清掉;判断已选中时用了错误的全等/字符串比较;只改数据不更新视图,或只改样式不改数据。

成功页展示与去购物车:读存储、渲染配置、每项操作调接口 ​

为什么需要:成功页要告诉用户买了什么:标题、图片、数量、价格、颜色、版本;购物车还要支持全选、计算件数、总价、改数量。转写稿强调购物车每项操作都要调接口,否则换电脑不同步。

怎么想:成功页从 storage 读取并 JSON.parse;图片按 60×60 展示;attrList 用 v-for 渲染,找选中项显示值;购物车不能只写内存。

怎么做:

js
const cartItem = JSON.parse(localStorage.getItem('cartItem') || '{}')
html
<img :src="cartItem.image" width="60" height="60" />
<span v-for="item in cartItem.attrList" :key="item.id">
  {{ item.name }}:{{ item.value }}
</span>

易错点:存储键读写不一致;刷新后 Vuex 清空却误以为 storage 还在;购物车只改本地状态不调接口;成功页没有空数据兜底。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:购买数量加减(src/pages/Detail/index.vue) ​

vue
<template>
  <div class="controls">
    <!-- 1- @change -->
    <!-- <input @change="upBuyNum" autocomplete="off" :value="buyNum" class="itxt"> -->
    <!-- 2- @input 防抖1-原生 -->
    <!-- <input @input="upBuyNum2" autocomplete="off" :value="buyNum" class="itxt"> -->
    <!-- 3- @input 防抖2-lodash -->
    <input @input="upBuyNum3" autocomplete="off" :value="buyNum" class="itxt">
    <a href="###" :class="['plus',{disabled:buyNum===200}]" @click.prevent="changeBuyNum">+</a>
    <a href="###" :class="['mins',{disabled:buyNum===1}]" @click.prevent="changeBuyNum">-</a>
  </div>
</template>

<script>
import { debounce } from "lodash";
// 注意:goodsNumReg 在笔记中直接使用但未给出定义,需要自己声明(校验 1~200 的整数)

export default {
  name: "Detail",
  data() {
    return {
      // 购买的初始数量
      buyNum: 1
    };
  },
  methods: {
    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++;
      }
    },
    upBuyNum(e) {
      // 输入的内容必须是数字,范围 1---200
      const num = e.target.value.trim() / 1;
      if (goodsNumReg.test(num)) {
        this.buyNum = num;
      } else {
        e.target.value = this.buyNum;
      }
    },
    upBuyNum2(e) {
      // 防抖:在指定的时间到达后函数执行一次,如果指定时间没有到,事件再次触发,重新计时。
      if (this.timer) clearTimeout(this.timer);
      this.timer = setTimeout(() => {
        // 输入的内容必须是数字,范围 1---200
        const num = e.target.value.trim() / 1;
        if (goodsNumReg.test(num)) {
          this.buyNum = num;
        } else {
          e.target.value = this.buyNum;
        }
      }, 1000);
    },
    // 如何使用 lodash?
    // 1- 下载 cnpm install lodash
    // 2- 引入 import {debounce} from "lodash"
    // 3- 使用
    upBuyNum3: debounce(function (e) {
      const num = e.target.value.trim() / 1;
      if (goodsNumReg.test(num)) {
        this.buyNum = num;
      } else {
        e.target.value = this.buyNum;
      }
    }, 1000)
  }
};
</script>

关键行与易错点

  • :class="['plus',{disabled:buyNum===200}]":数组写法里第二项是对象,条件为真才加 disabled 类;disabled 只改样式,不会真的禁用 <a>,所以 changeBuyNum 里必须再做 buyNum===200 / ===1 的 return 判断。
  • @click.prevent:<a href="###"> 必须阻止默认跳转,否则点一下页面就跳到锚点(转写稿里"点一下页面跳了一下"就是这个原因)。
  • e.target.classList.contains("mins"):用一个方法同时处理加减,靠 class 判断是加还是减;若 <a> 内部还有子元素,e.target 会变成子元素,应改用 e.currentTarget。
  • e.target.value = this.buyNum::value 是单向绑定,输入非法时 buyNum 没变,Vue 不会重新渲染,所以必须手动回填 DOM 的 value。
  • upBuyNum2 里的 this.timer 直接挂在实例上即可,不需要放进 data(放进去会变成响应式,没有意义)。
  • upBuyNum3: debounce(function(e){...},1000):必须用普通函数,不能用箭头函数,否则 this 不是组件实例;另外它写在 methods 对象上,是所有实例共享的一个 debounce 函数,多个组件实例会共用同一个计时器。

示例 2:加入购物车成功界面 + 路由(src/pages/AddCartSuccess/index.vue) ​

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="addCartInfo.skuDefaultImg">
          </div>
          <div class="right-info">
            <p class="title">{{ addCartInfo.skuName }}</p>
            <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 btn-xlarge">查看商品详情</a>
          <a href="cart.html" class="sui-btn btn-xlarge btn-danger" target="_blank">去购物车结算 &gt;</a>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "AddCartSuccess",
  data() {
    return {
      addCartInfo: sessionStorage.getItem("addCartInfo")
        ? JSON.parse(sessionStorage.getItem("addCartInfo"))
        : {}
    };
  }
};
</script>
js
// src/router/index.js
import AddCartSuccess from "@/pages/AddCartSuccess";

const routes = [
  // 成功加入购物车界面
  {
    path: "/addCartSuccess",
    component: AddCartSuccess
  }
];

关键行与易错点

  • sessionStorage.getItem("addCartInfo") ? JSON.parse(...) : {}:存储里拿到的是字符串,必须先 JSON.parse;取不到时给 {} 兜底,否则模板里 addCartInfo.skuName 会报错。
  • v.isChecked === '1':接口里的 isChecked 是字符串,用 === 比较时不能写数字 1(这是转写稿里排查很久的那个坑)。
  • @click.prevent="$router.go(-1)":"查看商品详情"用返回上一页,而不是写死 to,因为详情页地址带参数(skuId),拿不到确定地址。
  • 页面数据来自 sessionStorage 而不是 vuex:刷新后 vuex 会被清空,成功页刷新就白屏了。
  • width="60" height="60" 直接写在 img 标签上,和静态页保持一致,避免图片撑破布局。

示例 3:切换商品配置 + 加入购物车(src/store/product.js + src/pages/Detail/index.vue) ​

js
// src/store/product.js —— mutations 中的一段
// 更改选中的商品标签
UP_ATTR_LIST_BY_ID(state, { a1Id, a2Id }) {
  // 找到一级信息
  const info1 = state.productInfo.spuSaleAttrList.find(v => v.id === a1Id);
  if (info1) {
    // 将之前为 1 的清除
    const info = info1.spuSaleAttrValueList.find(v => v.isChecked === "1");
    info.isChecked = "0";

    // 更改选中项
    const info2 = info1.spuSaleAttrValueList.find(v => v.id === a2Id);
    // 如果当前值为 1,说明你点击的是已经选中的配置
    if (info2.isChecked === "1") return;
    info2.isChecked = "1";
  }
}
vue
<!-- src/pages/Detail/index.vue —— 模板部分 -->
<div class="chooseArea">
  <div class="choosed"></div>
  <dl v-for="item in spuSaleAttrList" :key="item.id">
    <dt class="title">选择{{ item.saleAttrName }}</dt>
    <dd
      @click="$store.commit('product/UP_ATTR_LIST_BY_ID',{a1Id:item.id,a2Id:info.id})"
      :class="{active:info.isChecked/1===1}"
      v-for="info in item.spuSaleAttrValueList"
      :key="info.id"
      changepirce="0"
    >{{ info.saleAttrValueName }}</dd>
  </dl>
</div>
js
// src/pages/Detail/index.vue —— 方法部分
addCart() {
  sessionStorage.setItem("addCartInfo", JSON.stringify({
    buyNum: this.buyNum,
    attrList: this.spuSaleAttrList,
    ...this.skuInfo
  }));

  this.$router.push("/addCartSuccess");
}

关键行与易错点

  • {a1Id:item.id, a2Id:info.id}:一次传两个 id,a1Id 定位是一级(颜色/版本),a2Id 定位具体选项,只传一个 id 没法在一级数组里反查。
  • state.productInfo.spuSaleAttrList:数据来自 vuex 的 product 模块,Detail 里的 spuSaleAttrList 是它的计算属性;skuInfo 同样来自接口数据,不是自己造的。
  • 顺序坑:笔记里先 info.isChecked = "0" 再判断 info2.isChecked === "1",如果点的正好是当前已选中项,它已经被置 0,return 永远不触发(转写稿视频 6 专门讨论过)。更稳的写法是先把 info2 找出来判断 === "1" 直接 return,再清 0、再置 1。
  • info 可能为 undefined:如果某项初始没有任何 isChecked === "1",info.isChecked = "0" 会直接报错,需要判空。
  • :class="{active:info.isChecked/1===1}":用 /1 把字符串转成数字再比较,或者干脆写 info.isChecked==='1'。
  • addCart 里用 sessionStorage + JSON.stringify 存对象,再用 ...this.skuInfo 展开把图片、标题、价格一起带过去,成功页才能渲染出来;存 sessionStorage 而不是 localStorage,是"关浏览器就清空"的产品需求决定的。

常见错误 ​

  1. 点 +/− 页面跳动、URL 尾部多出 ###

    • 现象:每次点加减,页面轻微跳一下,地址栏变成 xxx#。
    • 原因:<a href="###"> 的默认跳转行为没拦。
    • 修法:@click.prevent="changeBuyNum";或把 a 换成 button/span,从根上不产生默认行为。
  2. 边界只做了"变灰",逻辑上照样越界

    • 现象:buyNum 到 1 后减号已是灰色,但继续点还能减到 0 甚至负数。
    • 原因::class="{disabled: buyNum===1}" 只是视觉开关,方法体里没拦。
    • 修法:方法内 if (this.buyNum === 1) return; / if (this.buyNum === 200) return;,样式和逻辑两道锁。
  3. 输入非法值后,输入框和数据"各说各话"

    • 现象:输入 0、300、abc,框里显示的就是非法值,buyNum 没变;再点一次加号又跳回旧值。
    • 原因::value 是单向绑定,校验失败时没有把合法值写回 DOM。
    • 修法:else 分支 e.target.value = this.buyNum; 强制回显;或先算出合法值,再同时赋给 data 与输入框。
  4. 防抖回调里取 e.target.value 取错或报 undefined

    • 现象:快速输入后最终校验的是错乱的数字;或报 Cannot read property 'value' of undefined。
    • 原因:把整个事件对象丢进 setTimeout,异步执行时它已不是当时的输入状态。
    • 修法:同步阶段先 const val = e.target.value.trim();,只把 val 传进防抖回调;组件销毁时 clearTimeout(this.timer),lodash 版调 debounceFn.cancel()。
  5. 用 v-model 直接绑 buyNum 做数量输入

    • 现象:输入框能敲字母、能留空,data 被污染成 "" 或 "a"。
    • 原因:v-model 是双向直通,中间态全写进 data,没有校验环节。
    • 修法:改 :value + @input/@change,校验通过才写回 data。
  6. 成功页刷新后白屏 / 满屏 undefined

    • 现象:/addCartSuccess 一刷新就报 Cannot read property 'skuName' of undefined。
    • 原因:直接 JSON.parse(sessionStorage.getItem(...)),key 不存在时 parse 出 null;或存的时候漏了 JSON.stringify,取出是 "[object Object]"。
    • 修法:const raw = sessionStorage.getItem('addCartInfo'); return { addCartInfo: raw ? JSON.parse(raw) : {} };,模板加 v-if="addCartInfo.skuName";存时只挑展示需要的字段。
  7. 规格切换点不动,或点"当前已选中项"反而把选中态点没了

    • 现象:点"透明版"没反应;点已选中的"陶瓷黑"后选中态消失。
    • 原因:mutation 里先"清除旧选中"再"判断新项",顺序反了,自己先被清零;另外接口给的是字符串 "1",用 === 1 比较恒为 false。
    • 修法:进 mutation 第一件事就 if (info2.isChecked === '1') return;,之后才清旧置新;比较前统一类型。
  8. 只传一个 id 定位不到规格项

    • 现象:mutation 里 find 出来是 undefined,什么都不改。
    • 原因:spuSaleAttrList 是二级嵌套结构,单靠二级 id 无法确定归属。
    • 修法:同时传 a1Id + a2Id,先按 a1Id 定位一级,再在其 spuSaleAttrValueList 里按 a2Id 定位二级;并且在组件里一律 commit,不要直接改 state。

练习 ​

  1. 给数量输入框补齐"自动纠正":输入 0 → 变 1,输入 300 → 变 200,输入 abc → 还原成上一次合法值。写完后回答:为什么这里不能用 v-model?
  2. 用 lodash/debounce 把输入校验改成 800ms 防抖,并保证离开该页面时未执行的调用被取消(提示:created 里创建、beforeDestroy 里 cancel)。手写版和 lodash 版各写一遍,对比代码量。
  3. 把加号/减号的边界禁用用对象语法写成 :class="['plus', { disabled: buyNum === 200 }],并额外实现"到边界后点击不生效"。然后故意只保留样式、删掉方法里的 return,验证能否被绕过。
  4. 在 store 里加一个 getters,返回 { 颜色: '陶瓷黑', 版本: '8G+128G' } 这样已选规格的键值对;让成功页直接遍历渲染,不再在模板里写 find(v => v.isChecked === '1')。
  5. 优化规格切换:让"点击当前已选中项"根本不触发 commit。分别用模板层拦截(把 isChecked 传进方法里判断)和mutation 内部提前 return 实现,并说明各自适用场景。

原视频对照 ​

#视频时长
1加减运算31:03
2购买数量加减mp400:00
3搭建成功界面路由10:59
4切换商品配置26:07
5呈现成功加入购物车的商品信息14:13
6问题05:53

小结 ​

  • 凡是"用户可自由输入 + 有业务边界"的字段(数量、价格、手机号),一律 :value + @input 手动校验,不要交给 v-model 直通。
  • 边界禁用必须"样式 + 逻辑"双保险:class 只负责告诉用户"不能点",return 才负责让它真的不能点。
  • 手写防抖的三个必备动作——清旧定时器、同步阶段取事件值、组件销毁时取消;少一个就会在真实交互里露馅。
  • 跨页传"展示用快照",sessionStorage + JSON.stringify/parse 够用,但取值处必须做空值兜底,否则刷新即白屏。
  • 返回上一页用 $router.go(-1) 而不是重新 push 详情页,既保留原页面状态又避免重复请求。
  • Vuex 的 mutation 参数要带"足够定位"的信息;嵌套结构(一级/二级规格)单靠一个 id 是定位不到的。
  • mutation 内部的顺序很关键:任何写操作之前先做"是否已选中/是否越界"的判断,否则会先破坏数据再判断,出现"点自己反而取消自己"。
  • 接口数据里 "1" 和 1 混用是本项目最高频的坑,比较前先统一类型,=== 才不会骗你。