计算属性与侦听器

2026-03-03 20:29
49
0

计算属性与侦听器

计算属性和侦听器是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请求、副作用

记住这些要点

  1. 能用计算属性就用计算属性 ——更简洁、有缓存
  2. 需要异步或副作用时用侦听器
  3. watch明确指定依赖,watchEffect自动追踪
  4. 在侦听器中记得清理副作用
  5. 不要在计算属性中修改其他状态

下一站预告

在下一篇文章《条件渲染与列表渲染》中,我们将深入学习:

  • v-if与v-show的深度对比
  • v-for的高级用法
  • 列表渲染的性能优化

敬请期待!

作者:洋洋技术笔记
发布日期:2026-03-02
系列:Vue.js从入门到精通 - 第4篇

全部评论