跳到主要内容
预计阅读 59 分钟

模板语法与响应式基础

想象你正在经营一家网上商城。货架上的商品价格会随促销活动实时变动,购物车里的数量随用户操作即时更新,结算页的总价跟着每一次增减自动重算。这一切”数据变了,界面跟着变”的能力,正是 Vue 模板语法与响应式系统赋予我们的核心武器。

上一章我们搭建好了 Vue 3 开发环境,跑通了第一个项目。这一章,我们将用一个在线商城贯穿始终,从零构建商品展示、购物车交互、价格计算等真实功能,彻底掌握模板语法与响应式数据的方方面面。


📋 自测清单

在正式开始之前,请先尝试回答以下三个问题。如果你能自信地给出答案,可以快速浏览本章;如果感到模糊,请带着问题仔细阅读:

  1. ref()reactive() 分别适合包装什么类型的数据?在 <script setup> 中修改 ref 值时为什么需要 .value,而在 <template> 中却不需要?
  2. computed 和在模板中直接写表达式相比,最大的优势是什么?
  3. v-ifv-show 的底层实现有什么区别?在高频切换场景下应该选哪个?

一、模板语法基础——让数据在页面上”活”起来

我们在生产环境中遇到的第一个需求往往很朴素:把后端返回的商品信息渲染到页面上。Vue 的模板语法就是为此而生。

1.1 插值表达式 {{ }}

双花括号是 Vue 模板中最基础的数据绑定方式,它会将 JavaScript 表达式的结果以纯文本形式输出到 DOM 中。

<template>
  <div class="product-card">
    <h2>{{ productName }}</h2>
    <p>价格:¥{{ unitPrice.toFixed(2) }}</p>
    <p>库存:{{ inventory > 0 ? '有货' : '暂时缺货' }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const productName = ref('机械键盘 Pro')
const unitPrice = ref(599.00)
const inventory = ref(128)
</script>

几个关键点:

  • {{ }} 内部可以是任意合法的 JavaScript 表达式,如三元运算、方法调用、数学运算等,但不能写语句(如 ifforlet)。
  • 输出会自动进行 HTML 转义,防止 XSS 攻击。如果确实需要渲染 HTML,需要使用 v-html 指令(但要注意安全性)。

1.2 属性绑定 v-bind

{{ }} 只能用于文本内容。当我们需要动态绑定 HTML 属性时,就要用到 v-bind(简写为 :)。

<template>
  <img :src="productImage" :alt="productName" />
  <a :href="detailLink" :title="'查看' + productName + '详情'">
    了解更多
  </a>
  <button :disabled="inventory <= 0">加入购物车</button>
</template>

<script setup>
import { ref } from 'vue'

const productName = ref('无线鼠标 X1')
const productImage = ref('/images/mouse-x1.png')
const detailLink = ref('/products/mouse-x1')
const inventory = ref(0)
</script>

inventory 为 0 时,按钮的 disabled 属性会被自动添加,用户无法点击——这就是响应式绑定的魅力。

1.3 事件绑定 v-on

用户点击”加入购物车”按钮时,我们需要响应这个动作。v-on(简写为 @)负责监听 DOM 事件。

<template>
  <div class="product-card">
    <h3>{{ itemName }}</h3>
    <p>数量:{{ quantity }}</p>
    <button @click="addToCart">加入购物车</button>
    <button @click="quantity > 0 && quantity--">减少数量</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const itemName = ref('蓝牙耳机 S2')
const quantity = ref(0)

const addToCart = () => {
  quantity.value++
  console.log(`已将 ${itemName.value} 加入购物车,当前数量:${quantity.value}`)
}
</script>

注意第二个按钮使用了内联表达式——对于简单逻辑可以这样写,但复杂逻辑务必抽成方法,否则模板会变得难以维护。

1.4 双向绑定 v-model

在商城的搜索框、数量输入框等场景中,我们既要把数据展示到表单控件上,又要在用户输入时把新值同步回来。v-model 就是这种”双向数据流”的语法糖。

<template>
  <div class="search-bar">
    <input v-model="searchKeyword" placeholder="搜索商品..." />
    <p>当前搜索:{{ searchKeyword }}</p>
  </div>

  <div class="quantity-input">
    <label>购买数量:</label>
    <input v-model.number="purchaseCount" type="number" min="1" max="99" />
    <p>小计:¥{{ (purchaseCount * pricePerUnit).toFixed(2) }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const searchKeyword = ref('')
const purchaseCount = ref(1)
const pricePerUnit = ref(299.00)
</script>

v-model 有几个常用修饰符:

修饰符作用典型场景
.number自动将输入转为数字类型(若无法解析则返回原始值)数量输入、价格输入
.trim自动去除首尾空格搜索框、表单填写
.lazyinput 事件改为 change 事件触发不需要实时搜索的表单

🤔 思考题 1v-model 本质上是哪两个绑定的组合?如果不用 v-model,你能用 v-bindv-on 手动实现同样的双向绑定效果吗?


二、条件与列表渲染——商品列表的动态展示

一个商城页面上,商品要按条件筛选、以列表形式展示。这正是条件渲染和列表渲染的用武之地。

2.1 条件渲染:v-if / v-else-if / v-elsev-show

假设我们要根据商品的库存状态显示不同的标签:

<template>
  <div v-for="goods in goodsList" :key="goods.id" class="goods-item">
    <h3>{{ goods.name }}</h3>
    <span v-if="goods.stock > 10" class="tag tag-plenty">库存充足</span>
    <span v-else-if="goods.stock > 0" class="tag tag-low">仅剩 {{ goods.stock }} 件</span>
    <span v-else class="tag tag-out">已售罄</span>

    <div v-show="goods.onPromotion" class="promo-badge">促销中</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const goodsList = ref([
  { id: 1, name: '游戏手柄 Z3', stock: 56, onPromotion: true },
  { id: 2, name: '高清摄像头 C1', stock: 3, onPromotion: false },
  { id: 3, name: '智能手表 W5', stock: 0, onPromotion: true },
])
</script>

v-ifv-show 的核心区别

特性v-ifv-show
DOM 操作条件为 false 时,元素不会被渲染到 DOM元素始终存在于 DOM,通过 display: none 切换
切换开销较高(涉及组件创建/销毁)较低(仅 CSS 切换)
初始渲染条件为 false 时不渲染,节省初始开销无论条件如何都会渲染
适用场景条件很少改变需要高频切换显示/隐藏

在上面的例子中,库存状态标签用 v-if 是合适的——它不会频繁切换;而促销徽章可能随运营策略频繁开关,用 v-show 更好。

2.2 列表渲染:v-forkey

v-for 是商城开发中使用频率最高的指令之一。无论是商品列表、购物车条目还是订单明细,都需要它。

<template>
  <table class="cart-table">
    <thead>
      <tr>
        <th>商品</th>
        <th>单价</th>
        <th>数量</th>
        <th>小计</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(cartItem, idx) in cartItems" :key="cartItem.skuId">
        <td>{{ idx + 1 }}. {{ cartItem.title }}</td>
        <td>¥{{ cartItem.price.toFixed(2) }}</td>
        <td>{{ cartItem.qty }}</td>
        <td>¥{{ (cartItem.price * cartItem.qty).toFixed(2) }}</td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
import { ref } from 'vue'

const cartItems = ref([
  { skuId: 'SKU-1001', title: '机械键盘 Pro', price: 599.00, qty: 1 },
  { skuId: 'SKU-2045', title: '无线鼠标 X1', price: 199.00, qty: 2 },
  { skuId: 'SKU-3078', title: '显示器支架 A3', price: 89.00, qty: 1 },
])
</script>

为什么 key 如此重要?

Vue 的虚拟 DOM diff 算法在更新列表时,通过 key 来识别每个节点的”身份”。如果不提供 key(或使用数组索引作为 key),当列表项的顺序发生变化时,Vue 会采用”就地复用”策略——这在元素包含状态(如输入框中的内容、组件内部状态)时会导致严重的 bug。

最佳实践:永远使用业务数据中具有唯一性的字段(如 idskuId)作为 key,而不是数组索引。

🤔 思考题 2:如果购物车列表中用户删除了中间一项商品,使用数组索引 idx 作为 key 和使用 skuId 作为 key,Vue 的更新行为有什么不同?哪种方式的 DOM 操作更少?


三、响应式数据——Vue 的心脏

前面的示例中我们已经在使用 ref() 了。现在让我们深入理解 Vue 3 响应式系统的两大核心 API。

3.1 ref():包装任意类型的响应式引用

ref() 接受一个任意类型的值(基本类型或对象),返回一个响应式的引用对象。这个对象只有一个属性 .value,指向内部的值。当传入对象时,.value 内部会自动使用 reactive() 进行深层响应式转换。

<script setup>
import { ref } from 'vue'

// 基本类型
const shopName = ref('极速数码商城')
const totalSales = ref(0)
const isStoreOpen = ref(true)

// 修改值必须通过 .value
totalSales.value = 1500
isStoreOpen.value = false

// 也可以包装对象
const currentOrder = ref({ orderId: '', amount: 0 })
currentOrder.value.amount = 299
</script>

<template>
  <!-- 模板中自动解包,无需 .value -->
  <h1>{{ shopName }}</h1>
  <p>今日销售额:¥{{ totalSales }}</p>
  <p>营业状态:{{ isStoreOpen ? '营业中' : '已打烊' }}</p>
</template>

为什么 ref 需要 .value

JavaScript 中基本类型(stringnumberboolean)是按值传递的,无法直接被 Proxy 拦截。ref() 将值包装在 { value: ... } 对象中,利用对象的 getter/setter 实现响应式追踪。这就好比把一件散装商品放进快递盒——快递系统只能追踪盒子,追踪不了散装的东西。

而在 <template> 中,Vue 的编译器会自动检测 ref 类型并帮你加上 .value,所以模板中可以直接写变量名。

3.2 reactive():包装对象与复杂结构

reactive() 接受一个对象(普通对象或数组),返回该对象的响应式代理。

<script setup>
import { reactive } from 'vue'

const shoppingCart = reactive({
  items: [],
  couponCode: '',
  discount: 0,
})

// 直接修改属性即可,无需 .value
shoppingCart.couponCode = 'SUMMER2026'
shoppingCart.discount = 0.15

shoppingCart.items.push({
  skuId: 'SKU-4012',
  title: '降噪耳机 N7',
  price: 459.00,
  qty: 1,
})
</script>

<template>
  <p>优惠券:{{ shoppingCart.couponCode }}</p>
  <p>折扣:{{ (shoppingCart.discount * 100).toFixed(0) }}% OFF</p>
  <p>购物车商品数:{{ shoppingCart.items.length }}</p>
</template>

3.3 refreactive 的选择策略

维度ref()reactive()
适用数据类型任意类型(基本类型、对象均可)仅对象、数组、嵌套结构
访问/修改方式需要 .value直接访问属性
解构是否保持响应式是(整个 ref 传递时),解构会丢失响应式
重新赋值ref.value = newValue 可以不能整体替换,只能修改属性

生产环境建议

  • ref() 是 Vue 官方推荐的声明响应式状态的首选方式,适用于绝大多数场景
  • 具有多个关联字段的数据模型(商品信息、购物车、用户档案)→ 用 reactive() 也可以,省去 .value 的书写
  • 如果团队希望保持风格统一,全部使用 ref() 是最安全的选择

reactive 的一个经典陷阱——解构丢失响应式:

<!-- ❌ 错误!解构后 totalAmount 不再是响应式的 -->
<script setup>
import { reactive } from 'vue'

const orderInfo = reactive({
  orderId: 'ORD-20260318',
  totalAmount: 758.00,
})

const { totalAmount } = orderInfo
// totalAmount 现在只是一个普通数字,修改 orderInfo.totalAmount 不会触发更新
</script>
<!-- ✅ 正确做法:使用 toRefs 保持响应式 -->
<script setup>
import { reactive, toRefs } from 'vue'

const orderInfo = reactive({
  orderId: 'ORD-20260318',
  totalAmount: 758.00,
})

const { orderId, totalAmount: amount } = toRefs(orderInfo)
// 此时 orderId.value、amount.value 仍然是响应式的
</script>

如果你只需要转换其中一两个属性,可以使用更轻量的 toRef

import { reactive, toRef } from 'vue'

const orderInfo = reactive({ orderId: 'ORD-20260318', totalAmount: 758.00 })
const amount = toRef(orderInfo, 'totalAmount')
// amount.value 是响应式的,且与 orderInfo.totalAmount 保持同步

四、计算属性与侦听器——让数据自动联动

4.1 computed:带缓存的派生数据

在商城中,购物车总价就是一个典型的派生数据——它由所有商品的单价和数量决定。我们当然可以在模板中写一大段表达式,但这样既不清晰也无法复用。computed 就是为这种场景设计的。

<template>
  <div class="cart-summary">
    <div v-for="entry in cartEntries" :key="entry.skuId" class="cart-row">
      <span>{{ entry.title }}</span>
      <span>¥{{ entry.price }} x {{ entry.qty }}</span>
    </div>
    <hr />
    <p>商品总数:{{ totalQuantity }} 件</p>
    <p>总价:¥{{ cartTotal.toFixed(2) }}</p>
    <p>折后价:¥{{ finalPrice.toFixed(2) }}</p>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'

const cartEntries = reactive([
  { skuId: 'SKU-1001', title: '机械键盘 Pro', price: 599, qty: 1 },
  { skuId: 'SKU-2045', title: '无线鼠标 X1', price: 199, qty: 2 },
  { skuId: 'SKU-3078', title: '显示器支架 A3', price: 89, qty: 1 },
])

const discountRate = ref(0.9) // 九折

const totalQuantity = computed(() => {
  return cartEntries.reduce((sum, entry) => sum + entry.qty, 0)
})

const cartTotal = computed(() => {
  return cartEntries.reduce((sum, entry) => sum + entry.price * entry.qty, 0)
})

const finalPrice = computed(() => {
  return cartTotal.value * discountRate.value
})
</script>

computed 的缓存机制是它最核心的优势:

  • 只要依赖的响应式数据没有变化,多次访问 cartTotal 不会重复执行计算函数,直接返回缓存值。
  • 如果改用普通方法 getCartTotal(),每次模板重新渲染都会重新执行——在商品列表很长或计算逻辑复杂时,性能差距非常明显。

可写的 computed(高级用法):

<script setup>
import { ref, computed } from 'vue'

const priceInCents = ref(59900) // 以分为单位存储

const priceInYuan = computed({
  get: () => priceInCents.value / 100,
  set: (newVal) => {
    priceInCents.value = Math.round(newVal * 100)
  },
})

// priceInYuan.value = 699 → priceInCents 变为 69900
</script>

4.2 watch:精准监听特定数据

当我们需要在数据变化时执行副作用(如发送网络请求、操作本地存储、弹出提示),watch 是首选。

<script setup>
import { ref, watch } from 'vue'

const searchTerm = ref('')

// 监听搜索关键词变化,执行搜索请求
watch(searchTerm, (newTerm, oldTerm) => {
  console.log(`搜索词从「${oldTerm}」变为「${newTerm}」`)
  if (newTerm.length >= 2) {
    fetchSearchResults(newTerm)
  }
})

// 监听对象的特定属性,需要用函数返回
const orderForm = ref({ address: '', phone: '' })

watch(
  () => orderForm.value.address,
  (newAddr) => {
    console.log('收货地址已更新:', newAddr)
    recalculateShipping(newAddr)
  }
)

function fetchSearchResults(keyword) {
  console.log(`正在搜索:${keyword}...`)
}

function recalculateShipping(address) {
  console.log(`根据「${address}」重新计算运费...`)
}
</script>

watch 的第三个参数是配置对象,常用选项:

watch(source, callback, {
  immediate: true,  // 创建时立即执行一次回调
  deep: true,       // 深层监听对象内部变化
  flush: 'post',    // 回调在 DOM 更新之后执行
})

4.3 watchEffect:自动追踪依赖

watchEffect 不需要指定监听目标,它会自动追踪回调函数中使用到的所有响应式数据,并在它们变化时重新执行。

<script setup>
import { ref, watchEffect } from 'vue'

const categoryId = ref('electronics')
const sortOrder = ref('price-asc')
const currentPage = ref(1)

watchEffect(() => {
  // 任何一个变量变化都会重新触发
  console.log(
    `正在请求:分类=${categoryId.value}, 排序=${sortOrder.value}, 页码=${currentPage.value}`
  )
  loadProductList(categoryId.value, sortOrder.value, currentPage.value)
})

function loadProductList(category, sort, page) {
  // 模拟接口请求
  console.log(`API 请求已发出: /api/products?cat=${category}&sort=${sort}&page=${page}`)
}
</script>

watchwatchEffect 的对比

特性watchwatchEffect
是否需要指定监听源否,自动追踪
是否默认立即执行否(除非 immediate: true
能否获取新旧值不能
适用场景需要精确控制、需要旧值比对依赖多个数据源、逻辑简洁时

副作用清理watchEffect 支持通过 onCleanup 回调(Vue 3.5+ 也可使用 onWatcherCleanup)来清理上一次执行的副作用,这在取消过期请求、清除定时器等场景中非常实用:

import { ref, watchEffect } from 'vue'

const categoryId = ref('electronics')

watchEffect((onCleanup) => {
  const controller = new AbortController()
  fetch(`/api/products?cat=${categoryId.value}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => { /* 更新数据 */ })

  // 当 categoryId 变化时,自动取消上一次未完成的请求
  onCleanup(() => controller.abort())
})

🤔 思考题 3:在购物车页面中,用户修改任意商品的数量后需要重新向后端校验库存。你会选择 watch 还是 watchEffect?为什么?如果使用 watchEffect,如何利用 onCleanup 取消上一次未完成的请求?


五、模板中的事件处理——交互的艺术

5.1 方法绑定与内联处理

Vue 提供了两种事件处理方式:

<template>
  <!-- 方式一:绑定方法引用 -->
  <button @click="handlePurchase">立即购买</button>

  <!-- 方式二:内联语句,可传参 -->
  <button @click="adjustQty(item.skuId, 1)">+</button>
  <button @click="adjustQty(item.skuId, -1)">-</button>

  <!-- 方式三:需要原生事件对象时,用 $event -->
  <input @input="onSearchInput($event, 'main')" placeholder="搜索" />
</template>

<script setup>
import { ref } from 'vue'

const item = ref({ skuId: 'SKU-5010', qty: 1 })

const handlePurchase = () => {
  console.log('用户点击了购买按钮')
}

const adjustQty = (skuId, delta) => {
  console.log(`商品 ${skuId}:数量变化 ${delta}`)
  item.value.qty = Math.max(1, item.value.qty + delta)
}

const onSearchInput = (event, source) => {
  console.log(`搜索来源:${source},输入值:${event.target.value}`)
}
</script>

5.2 事件修饰符

Vue 将常见的 DOM 事件处理模式封装为修饰符,让我们无需在方法中手动调用 event.preventDefault() 等方法。

<template>
  <!-- 阻止默认行为:表单提交不刷新页面 -->
  <form @submit.prevent="submitOrder">
    <input v-model="orderRemark" placeholder="订单备注" />
    <button type="submit">提交订单</button>
  </form>

  <!-- 阻止事件冒泡 -->
  <div class="product-card" @click="goToDetail">
    <h3>无线充电器 Q2</h3>
    <button @click.stop="addItemToCart">加入购物车</button>
  </div>

  <!-- 只触发一次 -->
  <button @click.once="claimCoupon">领取新人优惠券</button>

  <!-- 键盘修饰符 -->
  <input @keyup.enter="executeSearch" v-model="keyword" placeholder="按 Enter 搜索" />

  <!-- 修饰符可以链式使用 -->
  <a @click.prevent.stop="showSharePanel" href="#">分享商品</a>
</template>

<script setup>
import { ref } from 'vue'

const orderRemark = ref('')
const keyword = ref('')

const submitOrder = () => console.log('订单已提交,备注:', orderRemark.value)
const goToDetail = () => console.log('进入商品详情')
const addItemToCart = () => console.log('已添加到购物车')
const claimCoupon = () => console.log('优惠券已领取')
const executeSearch = () => console.log('搜索:', keyword.value)
const showSharePanel = () => console.log('打开分享面板')
</script>

常用事件修饰符速查:

修饰符等价操作场景示例
.preventevent.preventDefault()表单提交、链接跳转
.stopevent.stopPropagation()嵌套元素的点击冲突
.once只执行一次后自动解绑领取优惠券、一次性确认
.self只在事件目标是自身时触发弹窗遮罩点击关闭
.capture使用捕获模式全局事件拦截
.passive不调用 preventDefault滚动性能优化

六、类与样式绑定——让界面随数据变化

商城中的视觉反馈至关重要:库存不足时商品卡片变灰、选中的分类高亮、促销商品添加醒目边框……这些都需要动态的 class 和 style 绑定。

6.1 动态 class 绑定

Vue 对 class 绑定做了特殊增强,支持对象语法和数组语法。

<template>
  <div
    v-for="product in productList"
    :key="product.id"
    :class="{
      'product-card': true,
      'product-card--soldout': product.stock === 0,
      'product-card--promo': product.isOnSale,
      'product-card--featured': product.isFeatured,
    }"
  >
    <h3>{{ product.name }}</h3>
    <p>¥{{ product.price }}</p>
  </div>

  <!-- 数组语法:适合需要动态拼接的场景 -->
  <div :class="[baseCardClass, sizeClass]">
    混合用法
  </div>

  <!-- 数组 + 对象混合 -->
  <div :class="[baseCardClass, { 'is-active': isSelected }]">
    数组与对象混用
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const productList = reactive([
  { id: 1, name: '电竞椅 R1', price: 1299, stock: 12, isOnSale: true, isFeatured: false },
  { id: 2, name: '桌面音箱 B5', price: 399, stock: 0, isOnSale: false, isFeatured: true },
  { id: 3, name: 'USB 拓展坞 H3', price: 169, stock: 45, isOnSale: true, isFeatured: true },
])

const baseCardClass = ref('product-card')
const sizeClass = ref('product-card--medium')
const isSelected = ref(true)
</script>

6.2 动态 style 绑定

对于需要精确控制的样式(如进度条宽度、动态颜色),可以直接绑定 style 对象。

<template>
  <div class="inventory-bar">
    <div
      class="inventory-fill"
      :style="{
        width: stockPercentage + '%',
        backgroundColor: stockColor,
        transition: 'width 0.3s ease',
      }"
    ></div>
    <span>库存:{{ currentStock }} / {{ maxStock }}</span>
  </div>

  <!-- 多个样式对象可以用数组合并 -->
  <div :style="[baseStyles, highlightStyles]">
    多样式合并
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const currentStock = ref(23)
const maxStock = ref(100)

const stockPercentage = computed(() => {
  return (currentStock.value / maxStock.value) * 100
})

const stockColor = computed(() => {
  const pct = stockPercentage.value
  if (pct > 50) return '#22c55e'      // 绿色:库存充足
  if (pct > 20) return '#f59e0b'      // 橙色:库存紧张
  return '#ef4444'                     // 红色:即将售罄
})

const baseStyles = ref({ padding: '12px', borderRadius: '8px' })
const highlightStyles = ref({ boxShadow: '0 2px 8px rgba(0,0,0,0.1)' })
</script>

6.3 条件样式的最佳实践

在实际开发中,推荐以下优先级:

  1. 优先使用动态 class——将样式逻辑保留在 CSS 中,保持关注点分离
  2. computed 计算 class 对象——当条件复杂时,把逻辑放到 computed 中
  3. 动态 style 用于真正动态的值——如进度条百分比、用户自定义颜色等无法预设 class 的场景
<script setup>
import { reactive, computed } from 'vue'

const product = reactive({
  stock: 5,
  rating: 4.8,
  isNew: true,
  isOnSale: false,
})

// 将复杂逻辑抽到 computed 中,模板保持清爽
const cardClasses = computed(() => ({
  'product-card': true,
  'product-card--low-stock': product.stock > 0 && product.stock <= 10,
  'product-card--out-of-stock': product.stock === 0,
  'product-card--top-rated': product.rating >= 4.5,
  'product-card--new-arrival': product.isNew,
  'product-card--on-sale': product.isOnSale,
}))
</script>

<template>
  <div :class="cardClasses">
    <!-- 模板简洁,逻辑集中 -->
  </div>
</template>

综合实战:迷你购物车

让我们把本章所有知识融合到一个完整的购物车组件中:

<template>
  <div class="mini-cart">
    <h2>🛒 我的购物车</h2>

    <!-- 搜索筛选 -->
    <input
      v-model.trim="filterText"
      placeholder="筛选购物车商品..."
      @keyup.enter="executeFilter"
    />

    <!-- 购物车列表 -->
    <div v-if="filteredItems.length > 0">
      <div
        v-for="cartProduct in filteredItems"
        :key="cartProduct.skuId"
        :class="{
          'cart-item': true,
          'cart-item--low-stock': cartProduct.remainingStock <= 5,
        }"
      >
        <span class="cart-item__name">{{ cartProduct.title }}</span>
        <span class="cart-item__price">¥{{ cartProduct.price.toFixed(2) }}</span>
        <div class="cart-item__controls">
          <button @click.prevent="changeQty(cartProduct.skuId, -1)">-</button>
          <input
            :value="cartProduct.qty"
            @change="onQtyInput(cartProduct.skuId, $event)"
            type="number"
            min="1"
            :style="{ width: '50px', textAlign: 'center' }"
          />
          <button @click.prevent="changeQty(cartProduct.skuId, 1)">+</button>
        </div>
        <span class="cart-item__subtotal">
          ¥{{ (cartProduct.price * cartProduct.qty).toFixed(2) }}
        </span>
        <button @click.stop="removeItem(cartProduct.skuId)" class="btn-remove">删除</button>
      </div>
    </div>
    <p v-else class="empty-hint">购物车为空,快去选购吧!</p>

    <!-- 汇总区域 -->
    <div v-show="filteredItems.length > 0" class="cart-footer">
      <p>共 {{ totalQty }} 件商品</p>
      <p>合计:<strong>¥{{ grandTotal.toFixed(2) }}</strong></p>
      <p v-if="grandTotal >= 299" class="free-shipping">已满 299 元,免运费</p>
      <p v-else class="shipping-hint">
        再买 ¥{{ (299 - grandTotal).toFixed(2) }} 即可免运费
      </p>
      <button @click.once="checkout" :disabled="totalQty === 0">结算</button>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'

const filterText = ref('')

const cartData = reactive([
  { skuId: 'SKU-A100', title: '机械键盘 Pro', price: 599, qty: 1, remainingStock: 20 },
  { skuId: 'SKU-B200', title: '无线鼠标 X1', price: 199, qty: 2, remainingStock: 3 },
  { skuId: 'SKU-C300', title: '显示器支架 A3', price: 89, qty: 1, remainingStock: 50 },
  { skuId: 'SKU-D400', title: '桌面收纳盒 M2', price: 49, qty: 3, remainingStock: 8 },
])

// 筛选后的列表
const filteredItems = computed(() => {
  if (!filterText.value) return cartData
  const keyword = filterText.value.toLowerCase()
  return cartData.filter((p) => p.title.toLowerCase().includes(keyword))
})

// 商品总数(基于全部商品,而非筛选结果)
const totalQty = computed(() => {
  return cartData.reduce((sum, p) => sum + p.qty, 0)
})

// 合计金额(基于全部商品,筛选只影响展示,不影响汇总)
const grandTotal = computed(() => {
  return cartData.reduce((sum, p) => sum + p.price * p.qty, 0)
})

// 修改数量
const changeQty = (skuId, delta) => {
  const target = cartData.find((p) => p.skuId === skuId)
  if (!target) return
  const newQty = target.qty + delta
  if (newQty < 1) return
  if (newQty > target.remainingStock) {
    console.warn(`库存不足,最多可购买 ${target.remainingStock} 件`)
    return
  }
  target.qty = newQty
}

// 手动输入数量
const onQtyInput = (skuId, event) => {
  const val = parseInt(event.target.value, 10)
  if (isNaN(val) || val < 1) return
  const target = cartData.find((p) => p.skuId === skuId)
  if (!target) return
  target.qty = Math.min(val, target.remainingStock)
}

// 删除商品
const removeItem = (skuId) => {
  const idx = cartData.findIndex((p) => p.skuId === skuId)
  if (idx !== -1) cartData.splice(idx, 1)
}

// 筛选文本执行
const executeFilter = () => {
  console.log('筛选关键词:', filterText.value)
}

// 结算
const checkout = () => {
  console.log(`结算 ${totalQty.value} 件商品,总计 ¥${grandTotal.value.toFixed(2)}`)
}

// 监听总价变化,自动保存到 localStorage
watch(grandTotal, (newTotal, oldTotal) => {
  console.log(`购物车总价变化:¥${oldTotal.toFixed(2)} → ¥${newTotal.toFixed(2)}`)
  // 实际项目中可以做:localStorage.setItem('cartTotal', newTotal)
})
</script>

这个综合示例覆盖了:

  • 插值表达式:商品名称、价格、数量显示
  • v-bind:class:style:disabled:value
  • v-on@click@change@keyup.enter
  • 事件修饰符.prevent.stop.once
  • v-model:搜索筛选输入
  • v-if / v-else / v-show:空状态、免运费提示
  • v-for + key:购物车列表
  • ref()reactive():基本值和复杂对象
  • computed:筛选列表、总数、总价
  • watch:监听总价变化

📝 章末自测

完成本章学习后,请独立回答以下五个问题。如果某一题感觉没把握,建议回到对应小节复习。

1. 以下代码有什么问题?如何修复?

const price = ref(100)
price = 200

2. 在一个有 500 条商品的列表页面中,每条商品都有一个”收藏”开关按钮,点击后切换收藏状态。你应该用 v-if 还是 v-show 来控制收藏图标的显示?理由是什么?

3. 假设有以下代码:

const cart = reactive({ items: [], total: 0 })
const { total } = cart

修改 cart.total = 100 后,total 的值是多少?为什么?如何修复?

4. computed 和普通方法(function)都可以返回计算结果。在购物车页面中,以下两种写法的性能差异是什么?

// 写法 A
const totalPrice = computed(() => cartItems.reduce((s, i) => s + i.price * i.qty, 0))

// 写法 B
const getTotalPrice = () => cartItems.reduce((s, i) => s + i.price * i.qty, 0)

5. 你需要在用户修改收货地址后,自动调用运费计算接口。以下两种方案分别有什么优缺点?

// 方案 A
watch(() => orderForm.address, (newAddr) => { fetchShippingFee(newAddr) })

// 方案 B
watchEffect(() => { fetchShippingFee(orderForm.address) })

下一章预告:我们将进入 Vue 3 组件化开发的世界,学习如何将页面拆分为可复用的组件,掌握 props 传递、事件通信、插槽分发等核心技能,为构建真正的在线商城项目做好准备。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90