计算属性与侦听器
计算属性与侦听器
计算属性和侦听器是Vue响应式系统的左膀右臂。一个负责"算",一个负责"看",配合默契。
在前面的文章中,我们已经初步接触了计算属性和侦听器。今天,让我们深入探索这两个核心特性,掌握它们的精髓。
📌 写作约定 :本系列文章以 Vue 3
<script setup>语法糖 为主要讲解方式,这是Vue 3.2+官方推荐的写法。同时会顺带介绍Vue 2和Vue 3 Options API的写法作为对比,帮助大家理解演进过程和维护老项目。
一、计算属性:智能的计算器
计算属性(Computed)就像一个聪明的计算器——它记住了上次的计算结果,只有当输入数据变化时,才会重新计算。
1.1 缓存机制:计算属性的核心优势
计算属性最重要的特性是 缓存 。看这个例子:
<template> <div> <p>价格:{{ price }}</p> <p>数量:{{ quantity }}</p> <!-- 多次使用,只计算一次 --> <p>总价:{{ totalPrice }}</p> <p>总价:{{ totalPrice }}</p> <p>总价:{{ totalPrice }}</p> </div> </template><script setup> import { ref, computed } from 'vue'
const price = ref(100) const quantity = ref(2)
const totalPrice = computed(() => { console.log('计算属性执行了') // 只会打印一次! return price.value * quantity.value }) </script>
缓存的工作原理 :
第一次访问 totalPrice
↓
执行计算函数,结果缓存
↓
后续访问 totalPrice
↓
直接返回缓存值(不重新计算)
↓
price 或 quantity 变化
↓
缓存失效,下次访问时重新计算
1.2 计算属性 vs 方法
很多人会问:计算属性和方法有什么区别?
<template> <div> <!-- 计算属性:有缓存 --> <p>计算属性:{{ totalPrice }}</p> <p>计算属性:{{ totalPrice }}</p> <!-- 不会重新计算 --> <!-- 方法:无缓存 --> <p>方法:{{ getTotalPrice() }}</p> <p>方法:{{ getTotalPrice() }}</p> <!-- 会重新计算 --> </div> </template><script setup> import { ref, computed } from 'vue'
const price = ref(100) const quantity = ref(2)
// 计算属性:有缓存 const totalPrice = computed(() => { console.log('计算属性执行') return price.value * quantity.value })
// 方法:无缓存 const getTotalPrice = () => { console.log('方法执行') return price.value * quantity.value } </script>
对比总结 :
| 特性 | 计算属性 | 方法 |
|---|---|---|
| 缓存 | ✅ 有 | ❌ 无 |
| 调用方式 | {{ totalPrice }} |
{{ getTotalPrice() }} |
| 性能 | 更优(避免重复计算) | 一般 |
| 适用场景 | 数据派生、格式化 | 需要传参、事件处理 |
什么时候用方法?
<template> <!-- 需要传参时,用方法 --> <p>{{ formatPrice(price, 'USD') }}</p> <p>{{ formatPrice(price, 'CNY') }}</p><!-- 事件处理,用方法 --> <button @click="handleClick">点击</button> </template>
<script setup> const formatPrice = (price, currency) => { const rates = { USD: 1, CNY: 7.2 } return (price * rates[currency]).toFixed(2) }
const handleClick = () => { console.log('clicked') } </script>
1.3 可写计算属性
计算属性默认是只读的,但也可以设置为可写:
<template> <div> <p>姓:{{ firstName }}</p> <p>名:{{ lastName }}</p> <p>全名:{{ fullName }}</p> <input v-model="fullName" placeholder="输入全名"> </div> </template><script setup> import { ref, computed } from 'vue'
const firstName = ref('张') const lastName = ref('三')
// 可写计算属性 const fullName = computed({ // getter:读取时调用 get() { return${firstName.value}${lastName.value}}, // setter:赋值时调用 set(value) { firstName.value = value.charAt(0) lastName.value = value.slice(1) } }) </script>
实际应用场景 :
<template> <input v-model="searchQuery" placeholder="搜索..."> </template><script setup> import { ref, computed } from 'vue'
const internalQuery = ref('')
// 可写计算属性:自动去空格 const searchQuery = computed({ get() { return internalQuery.value }, set(value) { internalQuery.value = value.trim() } }) </script>
1.4 计算属性的最佳实践
✅ 推荐做法 :
<script setup> import { ref, computed } from 'vue'const items = ref([ { id: 1, name: 'iPhone', price: 6999, inStock: true }, { id: 2, name: 'iPad', price: 3999, inStock: false }, { id: 3, name: 'MacBook', price: 9999, inStock: true } ])
// ✅ 计算属性之间可以相互依赖 const availableItems = computed(() => items.value.filter(item => item.inStock) )
const totalAvailablePrice = computed(() => availableItems.value.reduce((sum, item) => sum + item.price, 0) )
// ✅ 返回新对象,避免修改原数据 const sortedItems = computed(() => [...items.value].sort((a, b) => a.price - b.price) ) </script>
❌ 避免的做法 :
<script setup> import { ref, computed } from 'vue'const items = ref([...]) let count = 0 // 非响应式数据
// ❌ 不要在计算属性中修改其他状态 const badComputed = computed(() => { count++ // 副作用!会导致问题 return items.value.length })
// ❌ 不要在计算属性中执行异步操作 const asyncComputed = computed(async () => { const data = await fetchData() // 异步操作不会生效 return data })
// ❌ 不要依赖非响应式数据 const wrongComputed = computed(() => { return count // count不是响应式的,变化不会触发重新计算 }) </script>
二、侦听器:敏锐的观察员
侦听器(Watch)就像一个尽职的观察员——当数据变化时,它会立即采取行动。
2.1 watch的基本用法
Vue 3 <script setup> 写法 :
<script setup> import { ref, watch } from 'vue'const count = ref(0)
// 监听ref watch(count, (newVal, oldVal) => { console.log(count从${oldVal}变为${newVal}) }) </script>
对比 Vue 3 Options API写法 :
export default {
data() {
return { count: 0 }
},
watch: {
count(newVal, oldVal) {
console.log(`count从${oldVal}变为${newVal}`)
}
}
}
2.2 监听选项
watch支持多个选项来控制行为:
<script setup> import { ref, watch } from 'vue'const searchQuery = ref('')
watch(searchQuery, (newVal, oldVal, onCleanup) => { console.log('搜索:', newVal)
// 清理函数:下次执行前调用 onCleanup(() => { console.log('清理上一次的请求') }) }, { immediate: true, // 立即执行一次 deep: true, // 深度监听(用于对象) flush: 'post' // DOM更新后执行 }) </script>
选项说明 :
| 选项 | 作用 | 使用场景 |
|---|---|---|
immediate |
创建时立即执行一次 | 需要初始值时 |
deep |
深度监听对象内部变化 | 监听复杂对象 |
flush |
控制执行时机 | 需要访问更新后的DOM |
once |
只触发一次 | 一次性监听(Vue 3.4+) |
2.3 监听对象属性
监听对象的属性变化有多种方式:
<script setup> import { ref, reactive, watch } from 'vue'// =================== 监听ref对象 =================== const user = ref({ name: '张三', profile: { age: 25, city: '北京' } })
// 方式一:getter函数(推荐) watch(() => user.value.name, (newVal) => { console.log('名字变了:', newVal) })
// 方式二:深度监听整个对象 watch(user, (newVal) => { console.log('user变了') }, { deep: true })
// 方式三:监听嵌套属性 watch(() => user.value.profile.age, (newVal) => { console.log('年龄变了:', newVal) })
// =================== 监听reactive对象 =================== const state = reactive({ count: 0, settings: { theme: 'dark' } })
// reactive的属性可以直接用getter watch(() => state.count, (newVal) => { console.log('count变了:', newVal) })
// 监听整个reactive对象(自动deep) watch(state, (newVal) => { console.log('state变了') }) </script>
2.4 监听多个数据源
<script setup> import { ref, watch } from 'vue'const firstName = ref('张') const lastName = ref('三')
// 监听多个数据源 watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => { console.log(名字从 ${oldFirst}${oldLast} 变为 ${newFirst}${newLast}) }) </script>
2.5 watch vs watchEffect
Vue 3提供了两种侦听方式:
<script setup> import { ref, watch, watchEffect } from 'vue'const count = ref(0) const name = ref('张三')
// =================== watch:明确指定监听目标 =================== watch(count, (newVal) => { console.log('count变了:', newVal) })
// =================== watchEffect:自动追踪依赖 =================== watchEffect(() => { // 自动追踪:用到谁就监听谁 console.log(count=${count.value}, name=${name.value}) // count或name变化都会触发 }) </script>
对比 :
| 特性 | watch | watchEffect |
|---|---|---|
| 依赖声明 | 显式指定 | 自动追踪 |
| 获取旧值 | ✅ 可以 | ❌ 不可以 |
| 立即执行 | 需要 immediate |
默认立即执行 |
| 适用场景 | 需要比较新旧值 | 不关心旧值 |
选择建议 :
<script setup> import { ref, watch, watchEffect } from 'vue'const count = ref(0) const userId = ref(1)
// ✅ 用watch:需要比较新旧值 watch(userId, (newVal, oldVal) => { if (newVal !== oldVal) { fetchUserData(newVal) } })
// ✅ 用watchEffect:不关心旧值,自动追踪 watchEffect(() => { document.title =计数: ${count.value}}) </script>
2.6 侦听器的最佳实践
✅ 推荐做法 :
<script setup> import { ref, watch, onUnmounted } from 'vue'const searchQuery = ref('') let timer = null
// ✅ 清理副作用 watch(searchQuery, (newVal) => { clearTimeout(timer) timer = setTimeout(() => { searchAPI(newVal) }, 300) })
// 组件卸载时清理 onUnmounted(() => { clearTimeout(timer) }) </script>
使用清理函数 :
<script setup> import { ref, watch } from 'vue'const userId = ref(1)
watch(userId, (newVal, oldVal, onCleanup) => { const controller = new AbortController()
// 发起请求 fetchUser(newVal, controller.signal)
// 清理函数:下次执行前或组件卸载时调用 onCleanup(() => { controller.abort() // 取消上一次请求 }) }) </script>
三、计算属性 vs 侦听器:如何选择?
这是面试常考题,也是实际开发中经常纠结的问题。
3.1 核心区别
| 特性 | 计算属性 | 侦听器 |
|---|---|---|
| 返回值 | 必须返回值 | 可以不返回 |
| 缓存 | ✅ 有 | ❌ 无 |
| 异步 | ❌ 不支持 | ✅ 支持 |
| 副作用 | ❌ 不应该有 | ✅ 可以有 |
| 代码风格 | 声明式 | 命令式 |
3.2 选择指南
用计算属性当 :
<script setup> import { ref, computed } from 'vue'const firstName = ref('张') const lastName = ref('三') const items = ref([...])
// ✅ 数据派生 const fullName = computed(() =>
${firstName.value}${lastName.value})// ✅ 数据过滤 const activeItems = computed(() => items.value.filter(i => i.active))
// ✅ 数据格式化 const formattedPrice = computed(() =>
¥${price.value.toFixed(2)})
// ✅ 复杂计算 const totalPrice = computed(() => { return items.value .filter(i => i.selected) .reduce((sum, i) => sum + i.price * i.quantity, 0) }) </script>
用侦听器当 :
<script setup> import { ref, watch } from 'vue'const searchQuery = ref('') const userId = ref(1)
// ✅ 异步操作 watch(searchQuery, async (newVal) => { const results = await searchAPI(newVal) searchResults.value = results })
// ✅ 需要比较新旧值 watch(userId, (newVal, oldVal) => { if (newVal !== oldVal) { fetchUserData(newVal) } })
// ✅ 副作用操作 watch(theme, (newVal) => { document.body.className = newVal localStorage.setItem('theme', newVal) }) </script>
3.3 常见误区
误区一:用侦听器实现数据派生
<script setup> import { ref, watch, computed } from 'vue'const firstName = ref('张') const lastName = ref('三')
// ❌ 不推荐:用watch实现数据派生 const fullName = ref('') watch([firstName, lastName], ([first, last]) => { fullName.value =
${first}${last}})
// ✅ 推荐:用computed const fullName = computed(() =>${firstName.value}${lastName.value}) </script>
误区二:在计算属性中执行异步操作
<script setup> import { ref, computed, watch } from 'vue'const userId = ref(1)
// ❌ 错误:computed不支持异步 const userData = computed(async () => { return await fetchUser(userId.value) // 返回Promise,不是数据 })
// ✅ 正确:用watch + ref const userData = ref(null) watch(userId, async (newVal) => { userData.value = await fetchUser(newVal) }, { immediate: true }) </script>
四、实战案例
4.1 搜索防抖
<template> <div class="search-box"> <input v-model="searchQuery" placeholder="搜索用户..." class="search-input" /> <div v-if="loading" class="loading">搜索中...</div> <ul v-else-if="results.length" class="results"> <li v-for="user in results" :key="user.id" class="result-item"> <span class="name">{{ user.name }}</span> <span class="email">{{ user.email }}</span> </li> </ul> <div v-else-if="searchQuery && searched" class="empty"> 未找到结果 </div> </div> </template><script setup> import { ref, watch } from 'vue'
const searchQuery = ref('') const results = ref([]) const loading = ref(false) const searched = ref(false) let debounceTimer = null
// 防抖搜索 watch(searchQuery, (newVal) => { clearTimeout(debounceTimer)
if (!newVal.trim()) { results.value = [] searched.value = false return }
debounceTimer = setTimeout(async () => { loading.value = true searched.value = true // 模拟API请求 await new Promise(r => setTimeout(r, 300)) results.value = [ { id: 1, name:
${newVal}用户1, email: 'user1@example.com' }, { id: 2, name:${newVal}用户2, email: 'user2@example.com' } ] loading.value = false }, 500) // 500ms防抖 }) </script><style scoped> .search-box { max-width: 400px; margin: 20px auto; }
.search-input { width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 16px; transition: border-color 0.3s; }
.search-input:focus { outline: none; border-color: #42b983; }
.loading { text-align: center; padding: 20px; color: #666; }
.results { list-style: none; padding: 0; margin-top: 10px; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; }
.result-item { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; }
.result-item:last-child { border-bottom: none; }
.result-item:hover { background: #f9f9f9; }
.name { font-weight: 500; }
.email { color: #999; font-size: 14px; }
.empty { text-align: center; padding: 20px; color: #999; } </style>
4.2 表单验证
<template> <form @submit.prevent="handleSubmit" class="form"> <h2>用户注册</h2> <!-- 用户名 --> <div class="form-group"> <label>用户名</label> <input v-model="form.username" @blur="touched.username = true" /> <p v-if="errors.username" class="error">{{ errors.username }}</p> </div> <!-- 邮箱 --> <div class="form-group"> <label>邮箱</label> <input v-model="form.email" type="email" @blur="touched.email = true" /> <p v-if="errors.email" class="error">{{ errors.email }}</p> </div> <!-- 密码 --> <div class="form-group"> <label>密码</label> <input v-model="form.password" type="password" @blur="touched.password = true" /> <p v-if="errors.password" class="error">{{ errors.password }}</p> </div> <!-- 确认密码 --> <div class="form-group"> <label>确认密码</label> <input v-model="form.confirmPassword" type="password" @blur="touched.confirmPassword = true" /> <p v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</p> </div> <!-- 密码强度 --> <div class="password-strength"> <span>密码强度:</span> <span :class="['strength', passwordStrength.level]"> {{ passwordStrength.text }} </span> </div> <button type="submit" :disabled="!isValid" class="submit-btn"> 注册 </button> </form> </template><script setup> import { reactive, ref, computed, watch } from 'vue'
const form = reactive({ username: '', email: '', password: '', confirmPassword: '' })
const touched = reactive({ username: false, email: false, password: false, confirmPassword: false })
// =================== 计算属性:表单验证 =================== const errors = computed(() => { const errs = {}
if (touched.username && !form.username) { errs.username = '请输入用户名' } else if (touched.username && form.username.length < 3) { errs.username = '用户名至少3个字符' }
if (touched.email && !form.email) { errs.email = '请输入邮箱' } else if (touched.email && !/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(form.email)) { errs.email = '请输入有效的邮箱地址' }
if (touched.password && !form.password) { errs.password = '请输入密码' } else if (touched.password && form.password.length < 6) { errs.password = '密码至少6个字符' }
if (touched.confirmPassword && !form.confirmPassword) { errs.confirmPassword = '请确认密码' } else if (touched.confirmPassword && form.password !== form.confirmPassword) { errs.confirmPassword = '两次密码不一致' }
return errs })
// 表单是否有效 const isValid = computed(() => { return form.username && form.email && form.password && form.confirmPassword && Object.keys(errors.value).length === 0 })
// =================== 计算属性:密码强度 =================== const passwordStrength = computed(() => { const pwd = form.password if (!pwd) return { level: '', text: '未输入' }
let strength = 0 if (pwd.length >= 6) strength++ if (pwd.length >= 10) strength++ if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++ if (/\d/.test(pwd)) strength++ if (/[!@#$%^&*]/.test(pwd)) strength++
const levels = { 0: { level: 'weak', text: '非常弱' }, 1: { level: 'weak', text: '弱' }, 2: { level: 'medium', text: '中等' }, 3: { level: 'good', text: '强' }, 4: { level: 'strong', text: '很强' }, 5: { level: 'strong', text: '非常强' } }
return levels[strength] })
// =================== 侦听器:实时验证提示 =================== watch(() => form.username, (newVal) => { if (newVal && !touched.username) { touched.username = true } })
const handleSubmit = () => { // 标记所有字段为已触碰 Object.keys(touched).forEach(key => touched[key] = true)
if (isValid.value) { console.log('提交表单:', form) alert('注册成功!') } } </script>
<style scoped> .form { max-width: 400px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
h2 { text-align: center; color: #333; margin-bottom: 24px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #333; font-weight: 500; }
input { width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; transition: border-color 0.3s; }
input:focus { outline: none; border-color: #42b983; }
.error { color: #e74c3c; font-size: 12px; margin-top: 6px; }
.password-strength { margin-bottom: 20px; font-size: 14px; }
.strength { font-weight: 500; }
.strength.weak { color: #e74c3c; } .strength.medium { color: #f39c12; } .strength.good { color: #3498db; } .strength.strong { color: #27ae60; }
.submit-btn { width: 100%; padding: 14px; background: #42b983; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.3s; }
.submit-btn:hover:not(:disabled) { background: #3aa876; }
.submit-btn:disabled { background: #ccc; cursor: not-allowed; } </style>
4.3 数据格式化与本地存储同步
<template> <div class="settings-panel"> <h2>用户设置</h2> <div class="setting-item"> <label>主题</label> <select v-model="settings.theme"> <option value="light">浅色</option> <option value="dark">深色</option> <option value="auto">跟随系统</option> </select> </div> <div class="setting-item"> <label>字体大小</label> <input type="range" v-model.number="settings.fontSize" min="12" max="24" /> <span>{{ settings.fontSize }}px</span> </div> <div class="setting-item"> <label> <input type="checkbox" v-model="settings.notifications" /> 启用通知 </label> </div> <div class="setting-item"> <label>用户名</label> <input v-model="settings.username" placeholder="输入用户名" /> </div> <div class="preview"> <h3>预览效果</h3> <p :style="{ fontSize: settings.fontSize + 'px' }"> 这是{{ settings.username || '用户' }}的预览文本 </p> </div> <p class="save-status">{{ saveStatus }}</p> </div> </template><script setup> import { reactive, computed, watch, watchEffect } from 'vue'
// =================== 从本地存储加载设置 =================== const loadSettings = () => { const saved = localStorage.getItem('userSettings') return saved ? JSON.parse(saved) : { theme: 'light', fontSize: 16, notifications: true, username: '' } }
const settings = reactive(loadSettings()) const saveStatus = ref('')
// =================== 侦听器:保存到本地存储(防抖) =================== let saveTimer = null
watch(settings, (newSettings) => { clearTimeout(saveTimer) saveStatus.value = '保存中...'
saveTimer = setTimeout(() => { localStorage.setItem('userSettings', JSON.stringify(newSettings)) saveStatus.value = '已保存' setTimeout(() => { saveStatus.value = '' }, 2000) }, 500) }, { deep: true })
// =================== watchEffect:应用主题 =================== watchEffect(() => { const theme = settings.theme
if (theme === 'auto') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches document.body.className = isDark ? 'dark-theme' : 'light-theme' } else { document.body.className =
${theme}-theme} })// =================== 计算属性:验证用户名 =================== const usernameError = computed(() => { if (!settings.username) return '' if (settings.username.length < 2) return '用户名至少2个字符' if (settings.username.length > 20) return '用户名最多20个字符' if (!/^[\u4e00-\u9fa5a-zA-Z0-9_]+$/.test(settings.username)) { return '只能包含中文、字母、数字和下划线' } return '' }) </script>
<style scoped> .settings-panel { max-width: 500px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
h2 { color: #333; margin-bottom: 24px; }
.setting-item { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #f0f0f0; }
.setting-item label { min-width: 80px; color: #333; }
.setting-item select, .setting-item input[type="text"] { flex: 1; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; }
.setting-item input[type="range"] { flex: 1; }
.preview { background: #f9f9f9; padding: 20px; border-radius: 8px; margin-top: 20px; }
.preview h3 { margin-bottom: 12px; color: #666; }
.save-status { text-align: center; color: #42b983; font-size: 14px; margin-top: 16px; } </style>
五、总结
今天我们深入学习了计算属性和侦听器:
| 特性 | 计算属性 | 侦听器 |
|---|---|---|
| 核心用途 | 数据派生 | 响应变化 |
| 缓存 | ✅ 有 | ❌ 无 |
| 异步 | ❌ 不支持 | ✅ 支持 |
| 返回值 | 必须返回 | 可选 |
| 典型场景 | 格式化、过滤、计算 | API请求、副作用 |
记住这些要点 :
- 能用计算属性就用计算属性 ——更简洁、有缓存
- 需要异步或副作用时用侦听器
- watch明确指定依赖,watchEffect自动追踪
- 在侦听器中记得清理副作用
- 不要在计算属性中修改其他状态
下一站预告
在下一篇文章《条件渲染与列表渲染》中,我们将深入学习:
- v-if与v-show的深度对比
- v-for的高级用法
- 列表渲染的性能优化
敬请期待!
作者:洋洋技术笔记
发布日期:2026-03-02
系列:Vue.js从入门到精通 - 第4篇
全部评论