【Vue3】watch 和 computed 的区别,你真的搞清楚了吗?

276 阅读3分钟

最近在带新人,发现很多人对 watchcomputed 的区别理解得不够深入,经常用错。今天就来详细聊聊这两个 API,帮你彻底搞明白什么时候该用哪个。

核心区别一句话总结

computed 是用来计算派生值的,watch 是用来执行副作用的。

如果你只是想要一个基于其他数据计算出来的值,用 computed;如果你需要在数据变化时执行一些操作(比如发请求、操作 DOM、打印日志等),用 watch

1. computed - 计算属性

基本概念

computed 是一个计算属性(默认只读),它会根据依赖的响应式数据自动计算并缓存结果。只有当依赖的数据发生变化时,才会重新计算。

特点

  1. 有缓存:依赖的数据没变化时,直接返回缓存的值,不会重新计算
  2. 同步计算:计算过程是同步的,不能有异步操作
  3. 返回一个值:最终返回一个计算结果
  4. 自动追踪依赖:会自动追踪函数内部使用的响应式数据

基本用法

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 计算属性 - 自动拼接全名
const fullName = computed(() => {
  return `${firstName.value}${lastName.value}`
})

console.log(fullName.value) // '张三'

firstName.value = '李'
console.log(fullName.value) // '李三' - 自动重新计算

可读写的 computed

computed 也可以有 getter 和 setter,实现双向绑定:

const fullName = computed({
  get() {
    return `${firstName.value}${lastName.value}`
  },
  set(newValue) {
    // 当设置 fullName 时,自动拆分
    const parts = newValue.split('')
    if (parts.length >= 2) {
      firstName.value = parts[0]
      lastName.value = parts.slice(1).join('')
    }
  }
})

fullName.value = '王五' // 自动更新 firstName 和 lastName

使用场景

  • 数据转换:格式化、过滤、排序等
  • 条件判断:基于多个数据计算布尔值
  • 数据聚合:计算总和、平均值等
  • 模板中的复杂表达式:避免在模板中写复杂逻辑
// 场景1:数据过滤
const todos = ref([
  { id: 1, text: '学习 Vue', done: false },
  { id: 2, text: '写代码', done: true },
])

const activeTodos = computed(() => {
  return todos.value.filter(todo => !todo.done)
})

// 场景2:条件判断
const canSubmit = computed(() => {
  return form.value.email && 
         form.value.password && 
         form.value.password.length >= 6
})

// 场景3:数据聚合
const totalPrice = computed(() => {
  return cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})

2. watch - 监听器

基本概念

watch 用来监听数据变化并执行副作用。当被监听的数据发生变化时,会执行回调函数。

特点

  1. 无缓存:每次数据变化都会执行回调
  2. 可以异步:回调函数中可以执行异步操作
  3. 执行副作用:可以执行任何操作,不一定要返回值
  4. 需要明确指定依赖:要明确告诉它监听哪些数据

基本用法

import { ref, watch } from 'vue'

const count = ref(0)
const message = ref('')

// 监听单个数据源
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变成了 ${newVal}`)
  message.value = `当前计数:${newVal}`
})

count.value++ // 触发 watch

监听多个数据源

const firstName = ref('张')
const lastName = ref('三')

// 监听多个数据源
watch(
  [firstName, lastName],
  ([newFirst, newLast], [oldFirst, oldLast]) => {
    console.log('姓名变化了')
    // 可以在这里发请求、更新其他数据等
  }
)

深度监听

const state = reactive({
  user: {
    name: '张三',
    age: 20
  }
})

// 深度监听对象
watch(
  () => state.user,
  (newVal, oldVal) => {
    console.log('user 对象变化了')
  },
  { deep: true } // 深度监听嵌套属性
)

立即执行

const userId = ref(1)

// 立即执行一次,然后监听变化
watch(
  userId,
  (newVal) => {
    fetchUserData(newVal) // 立即执行一次,然后每次变化都执行
  },
  { immediate: true }
)

使用场景

  • 数据变化时发请求:搜索、分页等
  • 执行异步操作:数据变化后调用 API
  • 操作 DOM:数据变化后更新 DOM
  • 打印日志、调试:监听数据变化用于调试
  • 数据同步:同步多个数据源
// 场景1:搜索功能
const searchText = ref('')

watch(searchText, async (newVal) => {
  if (newVal.trim()) {
    // 发请求搜索
    const results = await searchAPI(newVal)
    searchResults.value = results
  }
})

// 场景2:路由参数变化
watch(
  () => route.params.id,
  (newId) => {
    // 路由参数变化时重新加载数据
    loadData(newId)
  }
)

// 场景3:表单验证
watch(
  () => form.value.email,
  (newEmail) => {
    if (newEmail && !isValidEmail(newEmail)) {
      errors.value.email = '邮箱格式不正确'
    } else {
      errors.value.email = ''
    }
  }
)

3. 关键区别对比

区别1:用途不同

// computed - 计算派生值
const fullName = computed(() => {
  return `${firstName.value}${lastName.value}` // 返回一个值
})

// watch - 执行副作用
watch(count, (newVal) => {
  console.log('count 变化了') // 执行操作,不返回值
  updateUI(newVal) // 可以执行任何操作
})

区别2:缓存机制

const count = ref(0)

// computed - 有缓存
const doubleCount = computed(() => {
  console.log('computed 执行了')
  return count.value * 2
})

console.log(doubleCount.value) // 打印 'computed 执行了',返回 0
console.log(doubleCount.value) // 不打印,直接返回缓存的值 0
console.log(doubleCount.value) // 不打印,直接返回缓存的值 0

count.value = 1
console.log(doubleCount.value) // 打印 'computed 执行了',返回 2

// watch - 无缓存,每次变化都执行
watch(count, (newVal) => {
  console.log('watch 执行了', newVal) // 每次 count 变化都会执行
})

count.value = 2 // 打印 'watch 执行了 2'
count.value = 3 // 打印 'watch 执行了 3'

区别3:返回值

// computed - 必须返回一个值
const result = computed(() => {
  return someCalculation() // 必须 return
})

// watch - 不需要返回值
watch(data, () => {
  doSomething() // 可以没有 return
  // 或者 return 任何值,但通常不使用返回值
})

区别4:异步操作

// computed - 不能有异步操作
const data = computed(async () => {
  // ❌ 错误!computed 不能是 async 函数
  return await fetchData()
})

// watch - 可以有异步操作
watch(id, async (newId) => {
  // ✅ 正确!watch 可以是 async 函数
  const data = await fetchData(newId)
  result.value = data
})

为什么 computed 中不应该发请求?

  1. computed 必须是同步的

    • computed 需要立即返回一个值,不能等待异步操作完成
    • 如果返回 Promise,模板中访问 computed.value 会得到 Promise 对象,而不是实际数据
  2. 会被频繁调用,导致重复请求

    // ❌ 错误示例
    const userData = computed(() => {
      fetchUser() // 每次访问都会发请求!
      return {}
    })
    
    // 模板中多次使用
    // <div>{{ userData.name }}</div>
    // <div>{{ userData.email }}</div>
    // 每次访问都会触发新的请求!
    
  3. 无法控制请求时机

    • computed 的缓存机制无法处理异步状态(loading、error)
    • 无法在合适的时机取消请求
    • 无法处理竞态条件(多个请求同时进行)
  4. 违反设计原则

    • computed 应该是纯函数(相同输入总是返回相同输出)
    • 发请求会产生副作用,每次调用可能返回不同的结果
    • 违反了响应式系统的可预测性原则
  5. 正确的做法

    // ✅ 正确:用 watch 发请求
    const userId = ref(1)
    const userData = ref(null)
    const loading = ref(false)
    const error = ref(null)
    
    watch(userId, async (newId) => {
      loading.value = true
      error.value = null
      try {
        userData.value = await fetchUser(newId)
      } catch (e) {
        error.value = e
      } finally {
        loading.value = false
      }
    }, { immediate: true })
    

区别5:依赖追踪

const a = ref(1)
const b = ref(2)
const c = ref(3)

// computed - 自动追踪所有依赖
const sum = computed(() => {
  // 自动追踪 a、b、c
  return a.value + b.value + c.value
})

// watch - 需要明确指定依赖
watch([a, b, c], ([newA, newB, newC]) => {
  // 明确监听 a、b、c
  console.log(newA + newB + newC)
})

// 或者监听计算属性
watch(sum, (newSum) => {
  console.log('sum 变化了', newSum)
})

4. 常见误用和正确用法

误用1:用 watch 计算派生值

// ❌ 错误:用 watch 计算派生值
const firstName = ref('张')
const lastName = ref('三')
const fullName = ref('')

watch([firstName, lastName], ([first, last]) => {
  fullName.value = `${first}${last}` // 不必要,应该用 computed
})

// ✅ 正确:用 computed
const fullName = computed(() => {
  return `${firstName.value}${lastName.value}`
})

误用2:在 computed 中执行副作用

// ❌ 错误:在 computed 中执行副作用
const count = ref(0)
const doubleCount = computed(() => {
  console.log('执行副作用') // ❌ computed 中不应该有副作用
  fetchData() // ❌ computed 中不应该发请求
  return count.value * 2
})

// ✅ 正确:用 watch 执行副作用
watch(count, (newVal) => {
  console.log('count 变化了', newVal) // ✅ watch 中可以执行副作用
  fetchData(newVal) // ✅ watch 中可以发请求
})

const doubleCount = computed(() => count.value * 2) // ✅ computed 只计算值

误用3:用 computed 监听路由变化

// ❌ 错误:用 computed 监听路由变化并执行操作
const userData = computed(() => {
  const id = route.params.id
  fetchUser(id) // ❌ computed 中不应该有副作用
  return {}
})

// ✅ 正确:用 watch 监听路由变化
watch(
  () => route.params.id,
  (newId) => {
    fetchUser(newId) // ✅ watch 中可以执行副作用
  }
)

5. 实际项目中的选择

场景1:显示计算后的值 → 用 computed

// 购物车总价
const totalPrice = computed(() => {
  return cart.value.reduce((sum, item) => {
    return sum + item.price * item.quantity
  }, 0)
})

// 模板中使用
// <div>总价:{{ totalPrice }}</div>

场景2:数据变化时发请求 → 用 watch

// 搜索功能
const keyword = ref('')

watch(keyword, async (newKeyword) => {
  if (newKeyword.trim()) {
    loading.value = true
    try {
      const results = await searchAPI(newKeyword)
      searchResults.value = results
    } finally {
      loading.value = false
    }
  }
})

场景3:格式化显示 → 用 computed

// 日期格式化
const formattedDate = computed(() => {
  return new Date(date.value).toLocaleDateString('zh-CN')
})

场景4:数据变化时更新其他数据 → 用 watch

// 同步两个表单字段
watch(
  () => form.value.phone,
  (newPhone) => {
    // 自动格式化手机号
    form.value.phoneDisplay = formatPhone(newPhone)
  }
)

6. watchEffect 的补充

watchEffectwatch 的简化版,会自动追踪依赖,不需要明确指定:

import { watchEffect, ref } from 'vue'

const count = ref(0)
const doubleCount = ref(0)

// watchEffect 会自动追踪 count
watchEffect(() => {
  doubleCount.value = count.value * 2
  // 自动追踪了 count,count 变化时会重新执行
})

// 等价于(需要加上 immediate: true 才能立即执行)
watch(count, () => {
  doubleCount.value = count.value * 2
}, { immediate: true })

watchEffect vs watch:

  • watchEffect:自动追踪依赖,立即执行一次
  • watch:需要明确指定依赖,默认不立即执行

watchEffect 的清理函数

watchEffect 的回调函数会接收一个 onInvalidate 参数,用于注册清理函数:

watchEffect((onInvalidate) => {
  const timer = setInterval(() => {
    console.log('定时器执行')
  }, 1000)
  
  // 注册清理函数:在下次执行前或组件卸载时调用
  onInvalidate(() => {
    clearInterval(timer)
  })
})

7. watch 的高级选项

flush 选项 - 控制执行时机

watchflush 选项可以控制回调的执行时机:

// flush: 'pre' (默认) - 在组件更新前执行
watch(data, callback, { flush: 'pre' })

// flush: 'post' - 在组件更新后执行
watch(data, callback, { flush: 'post' })

// flush: 'sync' - 同步执行(不推荐,性能差)
watch(data, callback, { flush: 'sync' })

实际应用:

// 场景:需要在 DOM 更新后操作 DOM
const message = ref('')
const messageEl = ref(null)

watch(
  message,
  () => {
    // 需要等 DOM 更新后才能获取到元素
    if (messageEl.value) {
      messageEl.value.scrollTop = messageEl.value.scrollHeight
    }
  },
  { flush: 'post' } // 在 DOM 更新后执行
)

停止监听

watchwatchEffect 都会返回一个停止函数:

const stopWatch = watch(count, (newVal) => {
  console.log(newVal)
})

// 停止监听
stopWatch()

// watchEffect 同样
const stopEffect = watchEffect(() => {
  // ...
})
stopEffect()

使用场景: 在组件卸载时停止监听,避免内存泄漏:

import { onUnmounted } from 'vue'

const stop = watch(data, callback)

onUnmounted(() => {
  stop() // 组件卸载时停止监听
})

once 选项(Vue 3.4+)

只执行一次监听:

watch(
  userId,
  (newId) => {
    loadUserData(newId)
  },
  { once: true } // 只执行一次,之后自动停止
)

8. 组合使用场景

在实际项目中,computedwatch 经常组合使用:

// 场景:计算派生值,然后监听变化执行操作
const userId = ref(1)
const user = ref(null)

// 1. 用 computed 计算 API 地址
const userApiUrl = computed(() => `/api/users/${userId.value}`)

// 2. 用 watch 监听 API 地址变化,发送请求
watch(
  userApiUrl,
  async (url) => {
    const response = await fetch(url)
    user.value = await response.json()
  },
  { immediate: true } // 立即执行一次
)

另一个例子:表单验证

const form = reactive({
  email: '',
  password: '',
  confirmPassword: ''
})

// 用 computed 计算验证状态
const isEmailValid = computed(() => {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
})

const isPasswordValid = computed(() => {
  return form.password.length >= 6
})

const passwordsMatch = computed(() => {
  return form.password === form.confirmPassword
})

const canSubmit = computed(() => {
  return isEmailValid.value && isPasswordValid.value && passwordsMatch.value
})

// 用 watch 监听验证失败时显示错误
watch(
  () => form.email,
  (newEmail) => {
    if (newEmail && !isEmailValid.value) {
      showError('邮箱格式不正确')
    }
  }
)

9. 性能优化建议

1. 避免在 computed 中做复杂计算

// ❌ 不好:每次依赖变化都会重新计算(虽然有缓存,但计算本身很耗时)
const expensiveResult = computed(() => {
  return hugeArray.value
    .filter(/* 复杂过滤 */)
    .map(/* 复杂映射 */)
    .reduce(/* 复杂聚合 */)
})

// ✅ 更好:如果计算非常耗时且数据不常变化,可以考虑用 watch 手动控制计算时机
// 注意:computed 的缓存机制通常已经足够,只有在特殊场景下才需要这样做
const expensiveResult = ref(null)
watch(
  hugeArray,
  () => {
    expensiveResult.value = hugeArray.value
      .filter(/* 复杂过滤 */)
      .map(/* 复杂映射 */)
      .reduce(/* 复杂聚合 */)
  },
  { immediate: true }
)

2. 避免深度监听大对象

// ❌ 不好:深度监听大对象性能差
const bigData = reactive({ /* 大量嵌套数据 */ })
watch(bigData, callback, { deep: true })

// ✅ 更好:只监听需要的属性
watch(
  () => bigData.importantField,
  callback
)

3. 使用 computed 替代模板中的复杂表达式

// ❌ 不好:模板中复杂表达式每次渲染都计算
// <div>{{ items.filter(x => x.active).map(x => x.name).join(', ') }}</div>

// ✅ 更好:用 computed 缓存结果
const activeItemNames = computed(() => {
  return items.value
    .filter(x => x.active)
    .map(x => x.name)
    .join(', ')
})
// <div>{{ activeItemNames }}</div>

4. watch 的防抖和节流

对于频繁触发的 watch,可以使用防抖或节流:

import { watch, ref, watchEffect } from 'vue'

const searchText = ref('')

// 方法1:在 watchEffect 中使用防抖(推荐)
let debounceTimer = null
watchEffect(() => {
  const value = searchText.value
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(() => {
    searchAPI(value)
  }, 300) // 300ms 后执行
})

// 方法2:使用 watch 配合手动防抖
let debounceTimer = null
watch(searchText, (newVal) => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(() => {
    searchAPI(newVal)
  }, 300)
})

// 方法3:使用工具函数(需要自己实现或使用第三方库)
import { debounce } from 'lodash-es'
const debouncedSearch = debounce((value) => {
  searchAPI(value)
}, 300)

watch(searchText, (newVal) => {
  debouncedSearch(newVal)
})

10. 调试技巧

调试 computed

const fullName = computed(() => {
  const result = `${firstName.value}${lastName.value}`
  console.log('computed 执行了', { firstName: firstName.value, lastName: lastName.value, result })
  return result
})

调试 watch

watch(
  count,
  (newVal, oldVal) => {
    console.group('watch 触发')
    console.log('旧值:', oldVal)
    console.log('新值:', newVal)
    console.trace('调用栈') // 查看调用栈
    console.groupEnd()
  }
)

使用 Vue DevTools

Vue DevTools 可以帮你:

  • 查看所有 computed 的当前值
  • 查看所有 watch 的监听状态
  • 手动触发 watch 回调进行调试

总结

特性computedwatch
用途计算派生值执行副作用
缓存✅ 有缓存❌ 无缓存
返回值✅ 必须返回值❌ 不需要返回值
异步❌ 不能异步✅ 可以异步
依赖追踪✅ 自动追踪⚠️ 需要明确指定
执行时机依赖变化时计算数据变化时执行回调

选择原则:

  1. 需要计算一个值并显示 → 用 computed
  2. 需要在数据变化时执行操作 → 用 watch
  3. 需要缓存计算结果 → 用 computed
  4. 需要异步操作 → 用 watch
  5. 模板中需要复杂表达式 → 用 computed

记住:computed 是计算,watch 是监听。根据你的需求选择,不要混用!

希望这篇文章能帮你彻底理解这两个 API 的区别。在实际项目中多练习,慢慢就能形成正确的使用习惯了。