第1章 1.3 计算属性与侦听器

📖 这是系列教程「Vue3 从入门到精通」的第 3 章,上一章我们学了模板语法与指令,这一章要解决一个实际问题。

🎯 开场:为什么需要计算属性?

想象这样一个场景:小明在电商公司做运营,每天要对着一份 Excel 表格发呆。

表格里有几百行商品数据,他要找出:
- 所有「价格大于 100 且库存小于 50」的商品
- 这些商品的总销售额是多少
- 库存告急(<20)的商品有哪些

你会怎么做?

# 普通人做法:直接在模板里写逻辑
{{ products.filter(p => p.price > 100 && p.stock < 50).reduce((sum, p) => sum + p.price * p.stock, 0) }}

看着就头皮发麻对吧?更糟糕的是——如果这份数据要重复用,你得复制粘贴 N 遍,改一处要改 N 个地方。

学完这章,你就能:用计算属性和侦听器,把复杂逻辑封装起来,像搭积木一样构建你的页面。


🧱 基础:\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n核心概念

什么是计算属性?

生活类比:计算属性就像餐厅的「自动计算小票机」。

  • 你点了几道菜,小票机自动帮你加总价格
  • 你删了一道菜,小票机自动重新算一遍
  • 你不需要每次都说「帮我算一下总价」,它自己会算

代码栗子

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

const products = ref([
{ name: 'iPhone', price: 6999, stock: 45 },
{ name: 'AirPods', price: 999, stock: 120 },
{ name: 'MacBook', price: 9999, stock: 15 },
])

// 计算属性:一句话搞定筛选+统计
const expensiveLowStockItems = computed(() => {
return products.value
.filter(p => p.price > 1000 && p.stock < 50)
})
</script>

computed 就是那个自动计算小票机,你只需要定义「算什么」,它自己会「算对」。


什么是侦听器?

生活类比:侦听器就像「快递物流提醒」。

  • 你下单后,系统会盯着物流状态
  • 一旦状态变了(「已发货」「运输中」「派送中」),立刻通知你
  • 你不用每秒刷新一次查物流,它变了自己会告诉你

代码栗子

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

const orderStatus = ref('已下单')
const notification = ref('')

// 侦听器:盯着 orderStatus,一旦变了就执行回调
watch(orderStatus, (newStatus, oldStatus) => {
notification.value = `订单从「${oldStatus}」变成了「${newStatus}」`
})

// 模拟状态变化
setTimeout(() => orderStatus.value = '已发货', 2000)
</script>

watch 就是那个物流提醒,你定义「盯什么」和「变了怎么办」。


computed vs methods:到底用哪个?

很多人分不清 computed 和 methods 的区别,说白了就是:

计算属性 方法
调用方式 当属性用 {{ total }} 当函数用 {{ getTotal() }}
缓存 ✅ 有缓存,依赖不变不重算 ❌ 每次都重新执行
适用场景 派生状态(从原数据计算出来的) 纯操作(做一些动作)

一个例子看区别

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

const count = ref(0)

// 计算属性:有缓存
const doubled = computed(() => {
console.log('计算属性执行了')
return count.value * 2
})

// 方法:没缓存
const getDoubled = () => {
console.log('方法执行了')
return count.value * 2
}
</script>

<template>
<p>计算属性:{{ doubled }}</p>  <!-- 第一次执行,后续依赖不变不重算 -->
<p>方法:{{ getDoubled() }}</p>  <!-- 每次都执行 -->
</template>

watchEffect:更偷懒的侦听方式

有时候你不想写「监听谁」,只想写「依赖啥」。

生活类比:就像你点外卖后,只关心「外卖到了吗」,不关心物流具体走到哪了。

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

const name = ref('小明')
const age = ref(18)

// 自动监听函数内用到的所有响应式数据
watchEffect(() => {
console.log(`用户信息改变了:${name.value},${age.value}岁`)
})
</script>

比 watch 简洁,但缺点是:不知道是谁触发的


🔥 实战:3 个小项目

项目 1:个人开销统计(5 分钟)

目标:输入一周开销,自动统计总支出和平均日支出。

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

const expenses = ref([
{ day: '周一', amount: 45 },
{ day: '周二', amount: 120 },
{ day: '周三', amount: 30 },
{ day: '周四', amount: 80 },
{ day: '周五', amount: 200 },
{ day: '周六', amount: 350 },
{ day: '周日', amount: 90 },
])

// 计算属性自动算好,用的时候直接拿
const totalExpense = computed(() => {
return expenses.value.reduce((sum, e) => sum + e.amount, 0)
})

const avgDaily = computed(() => {
return (totalExpense.value / expenses.value.length).toFixed(2)
})
</script>

<template>
<h3>本周开销清单</h3>
<ul>
<li v-for="e in expenses" :key="e.day">
  {{ e.day }}:¥{{ e.amount }}
</li>
</ul>
<p>总支出:<strong>¥{{ totalExpense }}</strong></p>
<p>日均:<strong>¥{{ avgDaily }}</strong></p>
</template>

预期输出

本周开销清单
- 周一:¥45
- 周二:¥120
- ...
总支出:¥915
日均:¥130.71

跟着抄一遍,是不是感觉计算属性超好用?


项目 2:商品搜索过滤(15 分钟)

目标:从 JSON 数据中搜索商品,支持按价格区间筛选。

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

const products = ref([
{ id: 1, name: 'iPhone 15', price: 6999, stock: 45, category: '手机' },
{ id: 2, name: '小米手环', price: 299, stock: 200, category: '穿戴' },
{ id: 3, name: 'AirPods Pro', price: 1899, stock: 80, category: '配件' },
{ id: 4, name: 'iPad Air', price: 4799, stock: 30, category: '平板' },
{ id: 5, name: 'Apple Watch', price: 2999, stock: 60, category: '穿戴' },
])

const searchKeyword = ref('')
const minPrice = ref(0)
const maxPrice = ref(99999)

// 搜索条件变了,过滤结果自动更新
const filteredProducts = computed(() => {
return products.value.filter(p => {
const matchName = p.name.includes(searchKeyword.value)
const matchPrice = p.price >= minPrice.value && p.price <= maxPrice.value
return matchName && matchPrice
})
})

const resultCount = computed(() => filteredProducts.value.length)
</script>

<template>
<h3>商品搜索</h3>
<input v-model="searchKeyword" placeholder="搜索商品名称" />
<input v-model.number="minPrice" type="number" placeholder="最低价" />
<input v-model.number="maxPrice" type="number" placeholder="最高价" />

<p>找到 {{ resultCount }} 个商品:</p>
<ul>
<li v-for="p in filteredProducts" :key="p.id">
  {{ p.name }} - ¥{{ p.price }} - 库存{{ p.stock }}
</li>
</ul>
</template>

关键点:计算属性 filteredProducts 自动监听 searchKeywordminPricemaxPrice 的变化,你不需要手动刷新。


项目 3:待办清单 with 状态持久化(15 分钟)

目标:做一个带分类筛选的待办清单,数据存 localStorage,刷新不丢失。

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

// 从 localStorage 读取历史数据
const todos = ref(JSON.parse(localStorage.getItem('todos') || '[]'))
const newTodo = ref('')
const filter = ref('all') // all / active / done

// 计算属性:过滤后的待办列表
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'done') return todos.value.filter(t => t.done)
return todos.value
})

const activeCount = computed(() => todos.value.filter(t => !t.done).length)
const doneCount = computed(() => todos.value.filter(t => t.done).length)

// 侦听器:todos 变了自动存 localStorage
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// 添加待办
const addTodo = () => {
if (!newTodo.value.trim()) return
todos.value.push({
id: Date.now(),
text: newTodo.value,
done: false,
createdAt: new Date().toLocaleDateString()
})
newTodo.value = ''
}

// 切换完成状态
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}

// 删除待办
const deleteTodo = (id) => {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>

<template>
<h2>待办清单</h2>

<input v-model="newTodo" @keyup.enter="addTodo" placeholder="输入待办事项..." />
<button @click="addTodo">添加</button>

<div class="filters">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">进行中</button>
<button :class="{ active: filter === 'done' }" @click="filter = 'done'">已完成</button>
</div>

<ul>
<li v-for="todo in filteredTodos" :key="todo.id" :class="{ done: todo.done }">
  <input type="checkbox" :checked="todo.done" @change="toggleTodo(todo.id)" />
  <span>{{ todo.text }}</span>
  <small>{{ todo.createdAt }}</small>
  <button @click="deleteTodo(todo.id)">删除</button>
</li>
</ul>

<p>进行中:{{ activeCount }} | 已完成:{{ doneCount }}</p>
</template>

<style>
.done span { text-decoration: line-through; color: #999; }
.active { background: #4CAF50; color: white; }
</style>

核心能力回顾
- computed:自动计算「进行中」「已完成」数量
- watch:自动把数据持久化到 localStorage
- 组合使用,代码简洁又健壮


💪 进阶:常见坑 + 技巧

坑 1:计算属性不是函数

<!-- ❌ 错误:把计算属性当函数调用 -->
<p>{{ totalExpense() }}</p>

<!-- ✅ 正确:计算属性是响应式属性,不用加 () -->
<p>{{ totalExpense }}</p>

坑 2:watch 监听不到对象内部变化

<script setup>
// ❌ 这样写监听不到 user.name 的变化
watch(user, (newUser) => console.log(newUser))

// ✅ 正确写法 1:深度监听
watch(user, (newUser) => console.log(newUser), { deep: true })

// ✅ 正确写法 2:监听具体属性
watch(() => user.value.name, (newName) => console.log(newName))
</script>

坑 3:computed 和 watch 用混了

<script setup>
// ❌ 用 watch 做计算属性该做的事(冗余)
const count = ref(0)
watch(count, (newCount) => {
doubled.value = newCount * 2  // 还要手动维护 doubled
})

// ✅ 用 computed 做计算属性该做的事(自动)
const doubled = computed(() => count.value * 2)
</script>

坑 4:异步操作不能用 computed

<script setup>
// ❌ computed 不能写异步
const data = computed(async () => {
const res = await fetch('/api/data')
return res.json()
})

// ✅ 异步操作用 watchEffect 或在 onMounted 里处理
import { watchEffect } from 'vue'
watchEffect(async () => {
const res = await fetch('/api/data')
data.value = await res.json()
})
</script>

调试技巧:给计算属性加 console.log

<script setup>
const result = computed(() => {
console.log('计算属性执行了,当前值:', complicatedLogic())
return complicatedLogic()
})
</script>

✏️ 练习题

练习 1(2 分钟):基础抄改
- 输入:products = [{price: 100}, {price: 200}]
- 预期输出:total = 300
- 提示:把项目 1 的计算属性改一下数据源

练习 2(2 分钟):加个条件
- 在练习 1 基础上,加一个筛选条件:只统计价格 > 150 的商品
- 预期输出:total = 200
- 提示:在 filter 里加个条件

练习 3(3 分钟):换个数据源
- 用这个数组:[{name: 'A', score: 85}, {name: 'B', score: 92}]
- 用计算属性输出平均分
- 提示:项目 1 的 avgDaily 改一下就行

练习 4(5 分钟):串起来
- 把练习 2 和练习 3 合并成一个组件
- 同时显示:筛选后的总数、平均分
- 提示:一个 computed 统计数量,一个计算平均分

练习 5(5 分钟):报错分析
- 输入:以下代码报错 "Cannot read property 'value' of undefined"

const result = computed(() => data.value.filter(d => d > 10))
  • 预期输出:说出原因并修复
  • 提示:检查 data 是否在 setup 里正确定义

作业:做一个「个人资产负债表」

  • 需求:录入收入和支出,自动计算净资产
  • 功能点
    1. 添加收入/支出条目(类型、金额、备注)
    2. 自动计算总收入、总支出、净资产
    3. 支持按类型筛选显示
    4. 数据存 localStorage(加分项)
  • 验收标准:能跑起来 + 增删查算都正常 + 代码有注释
  • 提交:评论区贴代码截图

📚 总结

这一章学了 3 个核心点:
1. 计算属性:自动缓存的派生状态,用它代替模板里的复杂逻辑
2. 侦听器 watch:盯着某个数据,变了就执行回调(适合副作用)
3. watchEffect:更偷懒的侦听方式,自动收集依赖

推荐资源
- Vue3 官方文档 - 计算属性
- Vue3 官方文档 - 侦听器
- 《Vue.js 设计与实现》第 4 章(进阶必读)


互动钩子:你在项目里用过计算属性或侦听器吗?遇到过什么坑?评论区聊聊,老粉优先回复!

📌 下一章我们要学「组件基础与 props」,学完你就能把页面拆成可复用的小积木了。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。