计算属性与侦听器

0 阅读10分钟

计算属性和侦听器是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>

对比

特性watchwatchEffect
依赖声明显式指定自动追踪
获取旧值✅ 可以❌ 不可以
立即执行需要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篇

Vue计算属性与侦听器详解 | computed、watch、watchEffect用法与选择指南