Vue 3 响应式系统深度探索:构建购物车应用 - 精通 `watch` 和 `computed` 的响应式数据
引言
欢迎再次回到 Vue 3 + 现代前端工程化 系列技术博客! 在昨天的第二篇博客中,我们深入学习了 Vue 3 的组件化开发,并构建了一个实用的待办事项列表应用,掌握了组件创建、数据传递和事件通信等关键技能。 今天,我们将继续深入 Vue 3 的核心,聚焦于响应式系统的更高级用法,特别是 watch
监听器和 computed
计算属性。
响应式系统是 Vue 3 的灵魂,它赋予了 Vue 3 数据驱动视图的能力。 理解响应式系统不仅仅是掌握 ref
和 reactive
两个 API,更重要的是学会如何利用它来构建复杂的数据逻辑和用户界面。 在本篇博客中,我们将通过构建一个 简易购物车应用,深入探索 watch
和 computed
的应用场景和最佳实践,进一步提升您对 Vue 3 响应式系统的理解和运用能力。
通过这个项目,您将学习到:
- Vue 3 响应式系统进阶: 深入理解
watch
监听器和computed
计算属性的作用和使用场景。 watch
监听器: 掌握watch
的基本用法和高级选项,学会监听数据变化并执行副作用操作。computed
计算属性: 掌握computed
的基本用法和缓存机制,学会派生计算新的响应式数据。- 构建更复杂 UI: 体验如何使用
watch
和computed
构建包含复杂数据逻辑和动态计算的用户界面。 - 组件内状态管理: 学习在组件内部使用响应式系统管理组件状态,构建更智能化的组件。
- 技能跃迁: 从响应式数据基础应用进阶到
watch
和computed
的灵活运用,提升您的 Vue 3 状态管理和数据处理能力。
项目简介: 简易购物车应用
我们的简易购物车应用将围绕以下核心功能展开:
- 商品列表展示: 展示商品列表,包括商品名称和价格。
- 添加商品到购物车: 用户可以点击按钮将商品添加到购物车。
- 购物车展示: 展示购物车列表,包括商品名称、数量和小计。
- 购物车总价计算: 实时计算购物车中所有商品的总价。
- 从购物车移除商品: 用户可以从购物车中移除已添加的商品。
通过构建购物车应用,我们将深入实践:
ref
和reactive
: 继续使用ref
和reactive
创建响应式数据,管理商品列表和购物车数据。computed
计算属性: 使用computed
计算购物车总价,实现动态计算和缓存优化。watch
监听器: 使用watch
监听购物车数据变化,模拟商品数量更新或其他副作用操作 (例如,日志记录)。- 列表渲染和事件处理: 继续练习使用
v-for
指令和@click
事件监听器构建交互式用户界面。
Vue 3 响应式系统核心概念进阶回顾
在开始构建购物车应用之前,让我们回顾并拓展 Vue 3 响应式系统的核心概念,重点理解 watch
监听器和 computed
计算属性的作用和区别。
- 响应式数据 (Ref 和 Reactive): 回顾
ref
和reactive
的基本用法,它们是创建响应式数据的核心 API,用于追踪数据变化并驱动视图更新。ref
适用于基本类型,reactive
适用于复杂类型 (对象和数组)。 watch
监听器:watch
允许我们监听一个或多个响应式数据源,并在数据源发生变化时执行自定义的回调函数 (副作用)。watch
主要用于执行副作用操作,例如:- 数据请求: 当监听的数据变化时,触发 API 请求。
- 日志记录: 监听数据的变化,记录日志信息。
- DOM 操作: 监听数据变化,手动操作 DOM 元素 (通常不推荐直接操作 DOM,但某些特殊场景下可能需要)。
- 更新计算属性依赖的数据: 虽然不常见,
watch
也可以用来间接更新computed
属性依赖的数据。
computed
计算属性:computed
用于声明一个依赖于其他响应式数据的计算属性。 计算属性会根据其依赖的响应式数据自动进行缓存和更新。computed
主要用于派生新的响应式数据,例如:- 列表过滤/排序: 基于原始列表数据,计算出过滤或排序后的新列表。
- 数据格式化: 将原始数据格式化成更易于展示的形式。
- 动态计算属性值: 根据其他响应式数据动态计算出新的属性值 (例如,购物车总价)。
- 缓存机制:
computed
具有缓存机制。 只要其依赖的响应式数据没有发生变化,多次访问computed
属性都会直接返回缓存的结果,而不会重新执行计算函数,从而提高性能。
watch
和 computed
的重要性
watch
和 computed
是 Vue 3 响应式系统中至关重要的两个补充 API,它们赋予了我们更精细化和更强大的状态管理能力。 理解和掌握 watch
和 computed
,能够帮助我们:
- 处理更复杂的数据逻辑: 对于需要监听数据变化并执行副作用操作的场景,
watch
是最佳选择。 对于需要派生新的响应式数据的场景,computed
能够简化代码并提高性能。 - 提高代码可读性和可维护性:
watch
和computed
将复杂的逻辑从模板中抽离出来,使得组件的代码更加清晰、易于理解和维护。 代码逻辑更加模块化,易于测试和复用。 - 优化应用性能:
computed
的缓存机制能够避免不必要的计算,提高应用性能。watch
能够更精确地监听数据变化,避免过度触发副作用操作。 - 构建更智能化的组件: 通过
watch
和computed
,我们可以构建更加智能化的组件,组件能够根据数据的变化自动响应,并执行相应的逻辑,提升用户体验。
实战步骤: 构建购物车应用
接下来,我们将一步步使用 Vue 3 响应式系统 (重点是 watch
和 computed
) 构建我们的简易购物车应用。
步骤 1: 项目初始化 (沿用 Day 1 项目)
我们将沿用 Day 1 创建的 vue3-counter-app
项目。 如果您还没有创建项目,请参考 Day 1 博客中的步骤进行初始化。 无需创建新的项目,我们将在已有的项目基础上进行开发。
步骤 2: 定义商品数据
首先,我们需要在 App.vue
组件的 <script setup>
中定义一些商品数据,用于模拟商品列表。 我们将使用 reactive
创建一个响应式对象,包含商品列表数据。
修改 src/App.vue
文件,在 <script setup>
中添加以下代码:
import { ref, reactive, computed, watch } from 'vue';
// 商品数据 (使用 reactive 创建响应式对象)
const products = reactive([
{ id: 1, name: 'Vue.js 3 核心教程', price: 59.9, image: 'image/vuejs3.jpg' }, // 可选:添加商品图片
{ id: 2, name: 'JavaScript 高级编程', price: 89.0, image: 'image/javascript.jpg' },
{ id: 3, name: 'CSS 揭秘', price: 75.5, image: 'image/css.jpg' },
{ id: 4, name: 'HTML5 权威指南', price: 68.8, image: 'image/html5.jpg' },
]);
// 购物车数据 (使用 reactive 创建响应式对象)
const cart = reactive({
items: [] // 购物车商品列表,数组
});
代码解释:
import { ref, reactive, computed, watch } from 'vue';
: 从vue
模块中导入ref
,reactive
,computed
,watch
函数,以便在组件中使用。const products = reactive([...]);
: 使用reactive
创建一个名为products
的响应式对象,用于存储商品列表数据。products
是一个数组,每个元素都是一个商品对象,包含id
,name
,price
和image
属性 (可选的商品图片,本篇博客暂不使用图片)。 我们使用reactive
而不是ref
,因为products
是一个复杂对象 (数组)。const cart = reactive({ items: [] });
: 使用reactive
创建一个名为cart
的响应式对象,用于存储购物车数据。cart
对象包含一个items
属性,items
是一个数组,用于存储购物车中的商品。 同样,我们使用reactive
因为cart
也是一个复杂对象。
步骤 3: 展示商品列表
接下来,我们需要在模板中展示商品列表。 使用 v-for
指令遍历 products
响应式数据,并渲染商品信息。
修改 App.vue
文件的 <template>
部分,添加商品列表展示代码:
<template>
<div class="container">
<h1>简易购物车</h1>
<h2>商品列表</h2>
<ul class="product-list">
<li v-for="product in products" :key="product.id" class="product-item">
<div class="product-info">
<h3>{{ product.name }}</h3>
<p>价格: ¥{{ product.price.toFixed(2) }}</p>
</div>
<button @click="addToCart(product)">加入购物车</button>
</li>
</ul>
<h2>购物车</h2>
<div v-if="cart.items.length === 0">
<p>购物车空空如也</p>
</div>
<ul v-else class="cart-list">
<li v-for="(item, index) in cart.items" :key="index" class="cart-item">
<div class="cart-item-info">
<p>{{ item.product.name }} x {{ item.quantity }}</p>
</div>
<button @click="removeFromCart(index)">移除</button>
</li>
<li class="cart-total">
<p>总价: ¥{{ cartTotal }}</p>
</li>
</ul>
</div>
</template>
<script setup>
// ... (之前的 <script setup> 代码,包括商品数据和购物车数据)
// 添加商品到购物车的方法
const addToCart = (product) => {
// TODO: 步骤 4 实现
};
// 从购物车移除商品的方法
const removeFromCart = (index) => {
// TODO: 步骤 6 实现
};
// 计算购物车总价 (computed)
const cartTotal = computed(() => {
// TODO: 步骤 5 实现
return 0; // 占位符,待实现
});
// 监听购物车变化 (watch)
watch(
() => cart.items, // 监听的数据源:购物车商品列表
(newCartItems, oldCartItems) => { // 回调函数
console.log('购物车商品列表发生变化:', newCartItems, oldCartItems); // 打印日志
// TODO: 步骤 7 可选实现其他副作用操作,例如更新本地存储、发送统计数据等
},
{ deep: true } // 深度监听,因为监听的是数组内部元素的变化
);
</script>
<style scoped>
/* 组件样式省略,与之前的类似,可以根据需要自行添加样式 */
.container {
font-family: sans-serif;
display: flex;
flex-direction: column;
align-items: center;
}
.product-list, .cart-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 600px;
margin-bottom: 20px;
}
.product-item, .cart-item, .cart-total {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.product-info, .cart-item-info {
flex-grow: 1;
}
.product-item button, .cart-item button {
padding: 8px 15px;
cursor: pointer;
}
.cart-total {
font-weight: bold;
border-top: 2px solid #ccc;
border-bottom: none;
text-align: right;
}
</style>
代码解释:
-
商品列表展示:
<h2>商品列表</h2>
: 设置商品列表标题。<ul class="product-list">
: 商品列表容器。<li v-for="product in products" ... class="product-item">
: 使用v-for
指令遍历products
响应式数据,为每个商品渲染一个<li>
元素。:key="product.id"
: 绑定key
属性,用于 Vue 3 追踪列表项的身份。<div class="product-info">
: 商品信息容器,包含商品名称和价格。<h3>{{ product.name }}</h3>
: 显示商品名称,使用插值语法绑定product.name
。<p>价格: ¥{{ product.price.toFixed(2) }}</p>
: 显示商品价格,使用插值语法绑定product.price
,并使用toFixed(2)
方法格式化价格,保留两位小数。<button @click="addToCart(product)">加入购物车</button>
: “加入购物车” 按钮,点击时触发addToCart(product)
方法,并将当前商品product
作为参数传递。addToCart
方法将在步骤 4 中实现。
-
购物车展示:
<h2>购物车</h2>
: 设置购物车标题。<div v-if="cart.items.length === 0">
: 使用v-if
指令判断购物车是否为空,如果为空,显示提示信息 “购物车空空如也”。<ul v-else class="cart-list">
: 购物车列表容器,当购物车不为空时显示。<li v-for="(item, index) in cart.items" ... class="cart-item">
: 使用v-for
指令遍历cart.items
响应式数据 (购物车商品列表),为每个购物车商品渲染一个<li>
元素。:key="index"
: 绑定key
属性,使用索引index
作为 key,因为购物车中的商品可能重复。<div class="cart-item-info">
: 购物车商品信息容器,包含商品名称和数量。<p>{{ item.product.name }} x {{ item.quantity }}</p>
: 显示购物车商品名称和数量,使用插值语法绑定item.product.name
和item.quantity
。<button @click="removeFromCart(index)">移除</button>
: “移除” 按钮,点击时触发removeFromCart(index)
方法,并将当前购物车商品在cart.items
数组中的索引index
作为参数传递。removeFromCart
方法将在步骤 6 中实现。
<li class="cart-total">
: 购物车总价显示容器。<p>总价: ¥{{ cartTotal }}</p>
: 显示购物车总价,使用插值语法绑定cartTotal
计算属性。cartTotal
计算属性将在步骤 5 中实现。
-
addToCart
,removeFromCart
,cartTotal
,watch
: 在<script setup>
中定义了addToCart
,removeFromCart
方法、cartTotal
计算属性和watch
监听器,但其具体实现将在后续步骤中完成 (以TODO:
注释标记)。
步骤 4: 实现 “加入购物车” 功能 (addToCart
方法)
现在,我们需要实现 addToCart
方法,将商品添加到购物车。 在 addToCart
方法中,我们将检查商品是否已在购物车中,如果已在则增加数量,否则添加新的购物车商品项。
修改 App.vue
文件的 <script setup>
部分,完善 addToCart
方法:
const addToCart = (product) => {
const cartItem = cart.items.find(item => item.product.id === product.id); // 查找购物车中是否已存在该商品
if (cartItem) {
// 如果已存在,增加商品数量
cartItem.quantity++;
} else {
// 如果不存在,添加新的购物车商品项
cart.items.push({ product: product, quantity: 1 });
}
};
代码解释:
const cartItem = cart.items.find(item => item.product.id === product.id);
: 使用find
方法在cart.items
数组中查找是否已存在相同product.id
的购物车商品项。find
方法返回找到的第一个符合条件的元素,如果未找到则返回undefined
。if (cartItem) { ... } else { ... }
: 判断cartItem
是否存在 (是否为undefined
),根据判断结果执行不同的逻辑。if (cartItem)
: 如果cartItem
存在 (购物车中已存在该商品),则执行增加商品数量的逻辑。cartItem.quantity++;
: 将找到的cartItem
的quantity
属性值加 1,增加商品数量。 由于cart
和cart.items
都是响应式数据,修改cartItem.quantity
会自动触发视图更新,购物车列表中对应商品的数量会实时更新。
else
: 如果cartItem
不存在 (购物车中不存在该商品),则执行添加新的购物车商品项的逻辑。cart.items.push({ product: product, quantity: 1 });
: 使用push
方法向cart.items
数组中添加一个新的购物车商品项。 新的购物车商品项是一个对象,包含product
(当前点击的商品对象) 和quantity: 1
(初始数量为 1)。 由于cart
和cart.items
都是响应式数据,向cart.items
数组添加新的元素会自动触发视图更新,购物车列表中会显示新增的商品项。
步骤 5: 实现购物车总价计算 (cartTotal
计算属性)
接下来,我们需要实现 cartTotal
计算属性,用于动态计算购物车中所有商品的总价。 cartTotal
计算属性将依赖于 cart.items
响应式数据,当 cart.items
发生变化时,cartTotal
会自动重新计算。
修改 App.vue
文件的 <script setup>
部分,完善 cartTotal
计算属性:
const cartTotal = computed(() => {
let total = 0;
for (const item of cart.items) {
total += item.product.price * item.quantity;
}
return total.toFixed(2); // 保留两位小数
});
代码解释:
const cartTotal = computed(() => { ... });
: 使用computed
函数创建一个名为cartTotal
的计算属性。computed
接收一个回调函数作为参数,回调函数的返回值就是计算属性的值。computed
会自动追踪回调函数中使用的响应式数据依赖。 在本例中,回调函数使用了cart.items
响应式数据,因此cartTotal
计算属性依赖于cart.items
。let total = 0;
: 在回调函数内部,初始化一个total
变量,用于累加购物车总价。for (const item of cart.items) { ... }
: 使用for...of
循环遍历cart.items
数组。total += item.product.price * item.quantity;
: 对于每个购物车商品项item
,将其商品价格item.product.price
乘以商品数量item.quantity
,累加到total
变量中。
return total.toFixed(2);
: 循环结束后,返回计算出的总价total
,并使用toFixed(2)
方法格式化价格,保留两位小数。computed
会将该返回值作为cartTotal
计算属性的值。
步骤 6: 实现 “从购物车移除商品” 功能 (removeFromCart
方法)
接下来,我们需要实现 removeFromCart
方法,用于从购物车中移除指定索引的商品。
修改 App.vue
文件的 <script setup>
部分,完善 removeFromCart
方法:
const removeFromCart = (index) => {
cart.items.splice(index, 1); // 从购物车商品列表中移除指定索引的商品项
};
代码解释:
const removeFromCart = (index) => { ... };
: 定义removeFromCart
方法,接收一个参数index
,表示要移除的购物车商品项在cart.items
数组中的索引。cart.items.splice(index, 1);
: 使用splice
方法从cart.items
数组中删除指定索引index
的元素,删除数量为 1。 由于cart
和cart.items
都是响应式数据,修改cart.items
数组会自动触发视图更新,购物车列表中被移除的商品项会实时消失。
步骤 7: 使用 watch
监听购物车变化 (可选)
最后,我们使用 watch
监听购物车数据 cart.items
的变化,并在控制台打印日志信息,演示 watch
监听器的基本用法。 您也可以根据实际需求,在 watch
的回调函数中执行其他副作用操作,例如更新本地存储、发送统计数据等 (本篇博客仅演示日志记录)。
修改 App.vue
文件的 <script setup>
部分,完善 watch
监听器 (已在步骤 3 中添加了基本结构,此处只需完善回调函数内部逻辑):
watch(
() => cart.items, // 监听的数据源:购物车商品列表
(newCartItems, oldCartItems) => { // 回调函数
console.log('购物车商品列表发生变化:', newCartItems, oldCartItems); // 打印日志
// TODO: 可选实现其他副作用操作,例如更新本地存储、发送统计数据等
},
{ deep: true } // 深度监听,因为监听的是数组内部元素的变化
);
代码解释:
watch( ... )
: 使用watch
函数创建一个监听器。() => cart.items
: 第一个参数是监听的数据源。 我们使用一个箭头函数返回cart.items
,表示监听cart.items
响应式数据。 当cart.items
数组本身或数组内部的元素发生变化时,watch
监听器会触发。(newCartItems, oldCartItems) => { ... }
: 第二个参数是回调函数。 当监听的数据源发生变化时,回调函数会被执行。 回调函数接收两个参数:newCartItems
: 变化后的新的购物车商品列表。oldCartItems
: 变化前的旧的购物车商品列表 (首次执行回调函数时,oldCartItems
为undefined
)。console.log('购物车商品列表发生变化:', newCartItems, oldCartItems);
: 在回调函数内部,使用console.log
打印日志信息,显示购物车商品列表的变化情况。 您可以根据实际需求,在此处执行其他副作用操作。
{ deep: true }
: 第三个参数是可选的配置项对象。deep: true
表示开启深度监听。 由于我们监听的是cart.items
数组,并且需要监听数组内部元素的变化 (例如,修改购物车商品项的数量),因此需要开启深度监听。 如果监听的数据源是基本类型或浅层对象,则不需要开启深度监听。
步骤 8: 运行应用并查看效果
保存 App.vue
文件后,Vite 开发服务器会自动热更新。 切换到浏览器,您将看到一个简易购物车应用。
尝试点击商品列表中的 “加入购物车” 按钮,商品会被添加到购物车中。 购物车列表会实时更新,显示新添加的商品,并且购物车总价也会自动计算并显示。 您可以多次点击 “加入购物车” 按钮,添加多个商品,或者添加相同商品多次,观察购物车列表和总价的变化。 打开浏览器的开发者工具控制台 (Console),您会看到 watch
监听器打印的日志信息,显示购物车商品列表的变化。 点击购物车商品项后面的 “移除” 按钮,可以从购物车中移除商品,购物车列表和总价也会实时更新。
Vue 3 响应式系统进阶优势展现 - watch
和 computed
的强大力量
通过构建购物车应用,我们深入体验了 Vue 3 响应式系统 watch
和 computed
的强大优势:
- 数据驱动: 购物车应用的各个部分 (商品列表、购物车列表、购物车总价) 都由响应式数据驱动。 当数据发生变化时,视图会自动更新,无需手动操作 DOM。
- 自动化计算: 使用
computed
计算属性cartTotal
,实现了购物车总价的动态计算和自动更新。 只要购物车商品列表cart.items
发生变化,cartTotal
就会自动重新计算,并更新视图,保证总价始终与购物车商品列表同步。computed
的缓存机制也提高了性能,避免了不必要的重复计算。 - 副作用处理: 使用
watch
监听器监听购物车商品列表cart.items
的变化,并在回调函数中打印日志信息,演示了watch
处理副作用操作的能力。 在实际项目中,watch
可以用于更复杂的副作用操作,例如数据持久化、远程数据同步、复杂的业务逻辑处理等。 - 更清晰的代码逻辑:
computed
和watch
将复杂的计算逻辑和副作用操作从模板中抽离出来,使得组件的代码结构更加清晰,逻辑更加内聚,易于理解和维护。
总结与反思
在今天的博客中,我们一起完成了 Vue 3 系列的第三篇学习实践,并成功构建了一个简易购物车应用。 在这个过程中,我们:
- 深入理解了 Vue 3 响应式系统的进阶用法,重点学习了
watch
监听器和computed
计算属性的作用和使用场景。 - 掌握了
watch
监听器的基本用法和深度监听选项,学会了监听数据变化并执行副作用操作。 - 掌握了
computed
计算属性的基本用法和缓存机制,学会了派生计算新的响应式数据。 - 体验了如何使用
watch
和computed
构建包含复杂数据逻辑和动态计算的用户界面。
明天,我们将继续深入 Vue 3 的学习,开始探索 Vue 3 的路由管理。 我们将为博客添加多页面导航,学习 Vue Router 的配置和使用,实现页面跳转和参数传递,构建更完善的单页应用 (SPA)。