【Vue2手录09】购物车实战
(完整示例代码见底部)
一、购物车实战基础(需求与结构)
1.1 核心需求拆解
购物车是 Vue 数据驱动思想的典型应用,需实现以下核心功能:
- 商品列表展示(名称、单价、数量、选中状态)
- 商品数量 加减操作(数量 ≥ 1)
- 单个商品 选中/取消 切换
- 全选/取消全选 批量操作
- 已选商品 总价计算 与 数量统计
- 结算按钮 禁用状态控制(无选中商品时禁用)
- 商品 删除功能(删除后同步更新统计数据)
1.2 核心数据结构
购物车数据分为「商品列表数据」和「汇总统计数据」,需设计为 响应式数据(确保数据变化自动更新视图)。
1.2.1 商品列表数据(products
)
数组类型,每个元素为商品对象,包含以下属性:
属性名 | 类型 | 含义 | 默认值 | 响应式说明 |
---|---|---|---|---|
select | Boolean | 商品选中状态 | false | 点击切换时取反 |
name | String | 商品名称 | - | 静态数据(从后端获取) |
price | Number | 商品单价(元) | - | 静态数据(从后端获取) |
count | Number | 商品数量 | 1 | 加减操作时修改 |
示例代码:
data() {return {products: [{ select: false, name: "iPhone14", price: 10000, count: 1 },{ select: false, name: "键盘", price: 400, count: 1 },{ select: false, name: "粉底液", price: 300, count: 1 },{ select: false, name: "PS5", price: 5000, count: 1 }]};
}
1.2.2 汇总统计数据(total
相关)
无需单独定义 total
对象,可通过 计算属性 动态生成(避免冗余数据),核心统计项:
- 已选商品数量:
selectedCount
(选中商品数组的长度) - 已选商品总价:
totalPrice
(选中商品count × price
累加) - 全选状态:
isAllSelected
(所有商品select
为true
时为true
) - 结算按钮禁用状态:
isBtnDisabled
(无选中商品时为true
)
1.3 页面结构实现(表格布局仅作为示例)
购物车需展示结构化数据,表格布局 比 div+CSS
更高效(自带行列对齐,减少样式代码),核心结构分为「商品列表区」和「底部汇总区」。
1.3.1 商品列表渲染(v-for
核心应用)
通过 v-for
循环生成商品行,动态绑定选中图标、商品信息与操作按钮:
<table class="cart-table"><!-- 表头 --><thead><tr><th>选择</th><th>商品名称</th><th>单价(元)</th><th>数量</th><th>操作</th></tr></thead><!-- 商品列表(v-for 循环行) --><tbody><tr v-for="(item, index) in products" :key="index"><!-- 1. 选中状态图标(动态切换) --><td><img @click="toggleSelect(index)" :src="item.select ? './img/selected.png' : './img/unselect.png'" alt="选中状态"></td><!-- 2. 商品信息(直接绑定数据) --><td>{{ item.name }}</td><td>{{ item.price }}</td><!-- 3. 数量加减操作 --><td><button @click="changeCount(false, index)">-</button><span>{{ item.count }}</span><button @click="changeCount(true, index)">+</button></td><!-- 4. 删除按钮 --><td><img @click="deleteProduct(index)" src="./img/delete.png" alt="删除"></td></tr></tbody>
</table>
1.3.2 底部汇总区域
展示全选、已选数量、总价与结算按钮,所有数据通过 计算属性动态绑定:
<div class="cart-footer"><!-- 全选图标 --><img @click="toggleAllSelect" :src="isAllSelected ? './img/selected.png' : './img/unselect.png'" alt="全选"><!-- 已选数量与总价 --><span>已选({{ selectedCount }})</span><span>总价:¥{{ totalPrice }}</span><!-- 结算按钮(禁用状态动态控制) --><button :disabled="isBtnDisabled">结算</button>
</div>
1.3.3 基础样式(确保布局美观)
.cart-table {width: 800px;margin: 20px auto;border-collapse: collapse; /* 合并边框(避免双线) */
}
.cart-table th, .cart-table td {border: 1px solid #ddd;padding: 10px;text-align: center;
}
.cart-footer {width: 800px;margin: 0 auto;display: flex;justify-content: space-between;align-items: center;
}
.cart-footer button:disabled {background: #ccc;cursor: not-allowed;
}
二、核心功能实现(数据驱动视图)
所有功能遵循 「修改数据即更新视图」 原则,无需手动操作 DOM(Vue 响应式机制自动处理视图更新)。
2.1 商品数量加减(changeCount
方法)
2.1.1 核心逻辑
- 通过 一个方法+参数 合并「加」和「减」操作(避免重复代码)
- 参数
flag
:true
表示加,false
表示减 - 参数
index
:定位当前操作的商品(通过v-for
的index
传递) - 边界处理:减操作时确保
count ≥ 1
(避免负数)
2.1.2 代码实现
methods: {changeCount(flag, index) {const currentProduct = this.products[index];if (flag) {// 加操作:直接修改 count(响应式)currentProduct.count++;} else {// 减操作:仅当 count > 1 时允许if (currentProduct.count > 1) {currentProduct.count--;}}}
}
2.1.3 响应式原理
Vue 对 对象属性修改(如 currentProduct.count++
)是响应式的——修改后会触发依赖追踪,自动更新视图中使用 count
的位置(如数量显示、总价计算)。
2.2 单个商品选中切换(toggleSelect
方法)
2.2.1 核心逻辑
- 商品选中状态由
item.select
控制(true
选中,false
未选中) - 点击图标时 取反
select
属性(!item.select
) - 通过
index
定位当前操作的商品
2.2.2 代码实现
methods: {toggleSelect(index) {// 取反选中状态(响应式更新)this.products[index].select = !this.products[index].select;}
}
2.2.3 常见错误
- 忘记传递
index
:无法定位商品,导致所有商品选中状态同步变化 - 直接赋值
true
:this.products[index].select = true
,无法取消选中
2.3 已选商品统计与总价计算(计算属性)
2.3.1 核心思想
总价和已选数量 依赖商品的 select
和 count
,适合用 计算属性(computed
) 实现——计算属性会缓存结果,仅当依赖项变化时重新计算(性能优于方法 methods
)。
2.3.2 代码实现
computed: {// 1. 已选商品数组(筛选 select: true 的商品)selectedProducts() {return this.products.filter(item => item.select);},// 2. 已选商品数量(已选数组的长度)selectedCount() {return this.selectedProducts.length;},// 3. 已选商品总价(已选商品 count × price 累加)totalPrice() {// 初始总价为 0,遍历已选商品累加return this.selectedProducts.reduce((total, item) => {return total + item.count * item.price;}, 0);},// 4. 结算按钮禁用状态(无选中商品时禁用)isBtnDisabled() {return this.selectedProducts.length === 0;},// 5. 全选状态(所有商品选中则为 true)isAllSelected() {// every:所有元素满足条件才返回 true(空数组时返回 true,需特殊处理)return this.products.length > 0 && this.products.every(item => item.select);}
}
2.3.3 计算属性 vs 方法
对比维度 | 计算属性(computed ) | 方法(methods ) |
---|---|---|
核心目的 | 获取一个依赖其他数据的 结果值 | 执行一个 操作过程(如修改数据) |
缓存机制 | 有缓存(依赖不变则复用结果) | 无缓存(每次调用都重新执行) |
调用方式 | 像变量一样使用({{ totalPrice }} ) | 需主动调用({{ getTotalPrice() }} ) |
适用场景 | 总价计算、状态判断(如全选) | 事件处理、数据修改(如加减数量) |
2.4 全选/取消全选(toggleAllSelect
方法)
2.4.1 核心逻辑
- 全选状态由
isAllSelected
(计算属性)判断:- 若
isAllSelected
为true
(已全选),点击后 取消所有选中(select: false
) - 若
isAllSelected
为false
(未全选),点击后 选中所有商品(select: true
)
- 若
- 通过
forEach
批量修改所有商品的select
属性
2.4.2 代码实现
methods: {toggleAllSelect() {// 1. 确定目标状态:已全选则改为 false,否则改为 trueconst targetStatus = !this.isAllSelected;// 2. 批量修改所有商品的 select 属性this.products.forEach(item => {item.select = targetStatus;});}
}
2.4.3 优化点
避免在 if/else
中重复写循环逻辑(如原笔记中的冗余代码),通过 targetStatus
统一赋值,代码更简洁、可维护。
2.5 商品删除功能(deleteProduct
方法)
2.5.1 核心逻辑
- 从
products
数组中删除指定索引的商品 - 使用 Vue 响应式数组方法
splice(index, 1)
(直接修改数组索引无法触发响应式) - 删除后自动更新已选数量、总价、全选状态(依赖计算属性)
2.5.2 代码实现
methods: {deleteProduct(index) {// splice(起始索引, 删除个数):响应式删除数组元素this.products.splice(index, 1);}
}
2.5.3 响应式数组方法说明
Vue 对数组的 直接索引修改(如 this.products[index] = null
)不触发响应式,需使用以下内置方法:
方法名 | 作用 | 示例 |
---|---|---|
splice | 删除/插入/替换元素 | this.products.splice(0, 1) |
push | 尾部添加元素 | this.products.push(newItem) |
pop | 尾部删除元素 | this.products.pop() |
shift | 头部删除元素 | this.products.shift() |
unshift | 头部添加元素 | this.products.unshift(newItem) |
三、代码优化(优雅与性能)
3.1 事件合并:减少重复代码
「代码越少越好」,将相似功能合并为一个方法(如数量加减),避免分别定义 plusCount
和 minusCount
两个方法,减少冗余。
3.2 计算属性替代手动状态更新
3.2.1 问题场景
原笔记初期手动定义 total.select
存储全选状态,但需在 toggleSelect
、toggleAllSelect
、deleteProduct
等多个方法中手动更新,易遗漏导致状态同步错误。
3.2.2 优化方案
将 total.select
改为计算属性 isAllSelected
(见 2.3.2 节),自动响应所有商品的 select
变化,无需手动维护状态,减少错误。
3.3 逻辑简化:使用布尔值与数组方法
3.3.1 全选逻辑优化
通过 filter
筛选已选商品后比较长度,优化后使用 every
方法直接判断所有商品是否选中,代码更简洁:
// 优化前(冗余)
const selectedArr = this.products.filter(item => item.select);
const isAll = selectedArr.length === this.products.length;// 优化后(简洁)
const isAll = this.products.every(item => item.select);
3.3.2 总价计算优化
使用 reduce
方法替代 forEach
累加,代码更紧凑:
// 优化前(forEach)
let total = 0;
this.selectedProducts.forEach(item => {total += item.count * item.price;
});
return total;// 优化后(reduce)
return this.selectedProducts.reduce((total, item) => total + item.count * item.price, 0);
3.4 命名规范:避免关键字冲突
- 事件名/方法名避免使用 JavaScript 关键字(如
delete
是删除对象属性的关键字,方法名改用deleteProduct
或removeProduct
) - 禁用状态变量避免使用
disabled
(HTML 关键字),改用isBtnDisabled
等语义化命名
四、Vue vs jQuery 开发思想差异(核心对比)
购物车实战是体现 Vue 数据驱动思想的典型案例,与 jQuery 的 DOM 操作思想有本质区别:
对比维度 | Vue(数据驱动) | jQuery(DOM 操作) |
---|---|---|
核心思路 | 先定义数据,视图绑定数据,修改数据即更新视图 | 直接操作 DOM 元素(如 $('.count').text(5) ) |
代码关注点 | 业务逻辑(数据修改) | 视图细节(DOM 查找、样式修改) |
响应式实现 | 自动(Vue 响应式机制) | 手动(修改 DOM 后需重新计算总价) |
代码量 | 少(无需重复操作 DOM) | 多(需手动更新所有关联视图) |
可维护性 | 高(数据与视图分离) | 低(DOM 操作散落在代码中) |
示例对比:商品数量修改
- jQuery 写法(需手动更新数量显示和总价):
// 1. 查找 DOM 元素修改数量
$('.count').eq(index).text(count);
// 2. 重新计算总价并更新 DOM
let total = 0;
$('.product:checked').each(() => {total += parseInt($(this).find('.price').text()) * parseInt($(this).find('.count').text());
});
$('.total-price').text(total);
- Vue 写法(仅修改数据,视图自动更新):
this.products[index].count++; // 仅修改数据,总价和数量显示自动更新
五、开发调试技巧(实战必备)
- 保持控制台开启:控制台是发现错误的第一道防线,Vue 会在控制台打印响应式错误(如未定义变量、Props 类型错误)。
- 分步验证功能:开发时先验证基础逻辑(如
console.log(index)
确认商品定位正确),再实现完整功能,避免一次性写大量代码导致调试困难。 - 数据变更优先:若视图未更新,先检查数据是否正确修改(如
console.log(this.products[index].select)
),再排查视图绑定问题。 - 边界测试:验证极端场景(如删除最后一个商品、全选后取消一个商品、数量减到 1 时再点击减按钮),确保功能健壮。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>购物车实战</title><script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {background-color: #f5f5f5;}li {list-style: none;}.wrapper {width: 640px;margin: 0 auto;}.title {border-bottom: 1px solid #e1e1e1;padding: 10px;}.product {display: flex;align-items: center;border-bottom: 1px solid #e1e1e1;padding: 8px;position: relative;}.btn {width: 20px;height: 20px;cursor: pointer;}.select {margin: 0 10px;}.del {position: absolute;right: 10px;top: 10px;}.operations {position: absolute;display: flex;width: 112px;height: 36px;right: 10px;bottom: 4px;}.operation {width: 36px;height: 27px;border: 1px solid #dbdbdb;text-align: center;background: none;color: #7a7979;}.operations>input {border-left: 0px solid #dbdbdb;border-right: 0px solid #dbdbdb;background-color: #fff;color: #000;pointer-events: none;}.operations>button {cursor: pointer;}.sales {width: 72px;height: 72px;margin-right: 10px;}.pro-desc {height: 72px;}.price {margin-top: 20px;color: #e46b7e;}.footer {display: flex;align-items: center;justify-content: space-between;position: absolute;bottom: 0px;background-color: #fff;height: 50px;width: 640px;}.footer-content {display: flex;align-items: center;flex-grow: 1;justify-content: space-between;padding: 0 10px;}.all-selected {color: #797979;font-size: 18px;}.total-price {font-size: 20px;color: #f41638;font-weight: bolder;}.submit {background: #ed535f;height: 50px;width: 100px;border: 0px;color: #fff;font-size: 18px;}.submit-forbidden {background: grey;color: #e1e1e1;}</style>
</head>
<body><div id="app"><div class="wrapper"><p class="title">我的购物车</p><ul><li v-for="(item, index) in products" :key="item.id"class="product"><img v-show="!item.isSelected" src="./images/unselected.png" class="btn select" @click="toggleSelect(index)"/><img v-show="item.isSelected" src="./images/selected.png" class="btn select" @click="toggleSelect(index)"/><img src="./images/sales.png" class="sales"/><div class="pro-desc"><p>{{ item.name }}</p><p class="price">{{ `¥ ${item.price}` }}</p></div><img src="./images/del.png" class="btn del" @click="deleteProduct(index)"/><div class="operations"><button class="operation" @click="changeCount(false, index)">-</button><input type="text" :value="item.count" class="operation"/><button class="operation" @click="changeCount(true, index)">+</button></div></li></ul><div class="footer"><img v-show="!isAllSelected" src="./images/unselected.png" class="btn select" @click="toggleSelectAll()"/><img v-show="isAllSelected" src="./images/selected.png" class="btn select" @click="toggleSelectAll()"/><div class="footer-content"><p class="all-selected">{{ footerText }}</p><p class="total-price">{{ `¥ ${totalPrice}` }}</p></div><button class="submit" :class="isSubmitDisabled?'submit-forbidden':''">结算</button></div></div></div>
<script>/*购物车需求分析1. 商品列表展示(商品名称、单价、数量、选中状态)2. 商品数量 +、- 操作3. 单个商品 选中、取消状态切换4. 全选、取消全选 批量操作5. 已选商品 总价计算 与 数量统计6. 结算按钮 禁用状态控制(无选中商品时禁用)7. 商品删除功能(删除商品后同步更新统计数据)*/new Vue({el: '#app',data: {// 商品列表信息products: [{ id: 1, name: 'iPhone14', price: 10000, count: 1, isSelected: false },{ id: 2, name: '高斯键盘3模款', price: 488, count: 1, isSelected: false },{ id: 3, name: 'DW粉底液', price: 800, count: 1, isSelected: false },{ id: 4, name: 'PS5', price: 5000, count: 1, isSelected: false },{ id: 5, name: '《javaScript 高级开发教程》', price: 98, count: 1, isSelected: false },]},computed: {// 已选商品数组selectedProducts(){return this.products.filter(item => item.isSelected)},// 已选商品数量selectCount(){return this.selectedProducts.length},// 已选商品总价totalPrice(){let sum = this.selectedProducts.reduce((acc, item) => {return acc+item.price*item.count},0)return sum},// 全选状态isAllSelected(){if(this.selectedProducts.length == this.products.length){return true}else{return false}},// 结算按钮禁用状态isSubmitDisabled(){if(this.selectedProducts.length == 0){// 当前没有选择商品,则禁用return true}else{// 当前选择了商品,则启用return false}},// 底部选择文本footerText(){if(this.selectedProducts.length == 0){return '未选择商品'}else{return `已选(${this.selectedProducts.length})`}}},methods: {// 商品数量加减changeCount(flag, index){if(flag){// +this.products[index].count++}else{// -if(this.products[index].count == 0){return}this.products[index].count--}},// 切换选中状态-单个商品toggleSelect(index){this.products[index].isSelected = !this.products[index].isSelected},// 切换选中状态-全选toggleSelectAll(){if(this.isAllSelected){this.products.map(item => item.isSelected = false)}else{this.products.map(item => item.isSelected = true)}},// 删除商品deleteProduct(index){this.products.splice(index,1)}}})
</script>
</body>
</html>
效果图:
文件夹结构: