Computed、Watch 与 watchEffect

computed 负责声明衍生状态,watchwatchEffect 负责响应状态变化并执行副作用。

先建立心智模型

选择 API 前,先判断代码的目标是什么:

  • 衍生状态:可以完全由其他状态计算得到,例如总价、筛选结果、完整姓名。
  • 副作用:会影响计算过程之外的内容,例如请求接口、操作 DOM、写入本地存储。
import { computed, ref, watch } from 'vue'

const price = ref(100)
const quantity = ref(2)

// 衍生状态:相同输入始终对应相同结果
const total = computed(() => price.value * quantity.value)

// 副作用:total 变化后同步到本地存储
watch(total, (value) => {
  localStorage.setItem('total', String(value))
})

不要用 watch 手动维护本可以计算出来的状态:

// 不推荐:创建了可变的重复状态
const total = ref(0)
watch([price, quantity], () => {
  total.value = price.value * quantity.value
})

// 推荐:直接描述 total 与源状态的关系
const total = computed(() => price.value * quantity.value)

computed:声明衍生状态

computed() 接收 getter,返回一个计算属性 ref。JavaScript 中通过 .value 读取,模板中会自动解包。

import { computed, ref } from 'vue'

const keyword = ref('')
const products = ref([
  { id: 1, name: 'Keyboard' },
  { id: 2, name: 'Mouse' }
])

const filteredProducts = computed(() => {
  const value = keyword.value.trim().toLowerCase()
  return products.value.filter((item) => {
    return item.name.toLowerCase().includes(value)
  })
})

惰性求值、依赖追踪与缓存

计算属性有三个关键特征:

  1. 自动追踪依赖:执行 getter 时读取的响应式状态会成为依赖。
  2. 惰性求值:只有结果被读取时,getter 才需要执行。
  3. 基于依赖缓存:依赖没有变化时,多次读取直接复用上次结果。

可以把内部过程简化为:

  1. 首次读取计算属性,执行 getter、收集依赖并缓存结果。
  2. 依赖变化,将计算属性标记为“待更新”。
  3. 下次读取时重新计算并更新缓存。

因此,计算属性与方法虽然可能返回相同结果,执行时机却不同:

const filteredProducts = computed(() => expensiveFilter(products.value))

function getFilteredProducts() {
  return expensiveFilter(products.value)
}
  • filteredProducts.value 只在响应式依赖变化后重新计算。
  • getFilteredProducts() 每次调用都会执行。

缓存只与响应式依赖有关。下面的结果不会随时间自动更新,因为 Date.now() 不是响应式状态:

const now = computed(() => Date.now())

getter 应保持纯粹

computed getter 应该同步地根据依赖返回结果,不要在其中请求接口、修改其他状态或操作 DOM:

// 不推荐
const user = computed(async () => {
  return fetch('/api/user').then((response) => response.json())
})

异步函数在语法上可以返回 Promise,但这会让计算属性变成 Promise 的缓存,而不是异步数据状态;加载、失败、取消和竞态也无法被清晰管理。异步副作用应使用 watch 或封装成专门的组合式函数。

可写计算属性

计算属性默认只读。少数需要双向转换的场景,可以同时提供 getter 和 setter:

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value) {
    const [first, ...rest] = value.trim().split(/\s+/)
    firstName.value = first ?? ''
    lastName.value = rest.join(' ')
  }
})

优先修改源状态。只有当“写入衍生值”能被明确转换为“更新源状态”时,才适合使用可写计算属性。

watch:精确监听并执行副作用

watch() 显式指定监听源,在监听值发生变化时执行回调。它适合需要旧值、新值,或者需要控制执行时机的副作用。

const keyword = ref('')

watch(keyword, (newKeyword, oldKeyword) => {
  console.log(`${oldKeyword} -> ${newKeyword}`)
})

默认情况下,回调不会在创建监听器时执行,而是在监听源后续变化时执行。

可以监听什么

监听源可以是 ref、计算属性、响应式对象、getter,或者由这些监听源组成的数组:

const user = reactive({ profile: { name: 'Ada' } })
const page = ref(1)
const pageSize = ref(20)

watch(page, loadPage)
watch(() => user.profile.name, validateName)
watch([page, pageSize], ([newPage, newPageSize], [oldPage, oldPageSize]) => {
  // 任一监听源变化时执行
})

不能直接把普通值作为监听源:

// 错误:传入的是当前字符串,而不是响应式监听源
watch(user.profile.name, validateName)

// 正确:用 getter 描述要监听的属性
watch(() => user.profile.name, validateName)

常用选项

watch(
  () => user.profile,
  (profile) => {
    saveProfile(profile)
  },
  {
    immediate: true,
    deep: true,
    flush: 'post'
  }
)
  • immediate: true:创建监听器时立即执行一次。
  • deep: true:递归追踪嵌套属性变化;数据结构很大时要注意遍历成本。
  • flush: 'pre':默认值,在组件自身 DOM 更新前执行。
  • flush: 'post':在组件 DOM 更新后执行,适合读取更新后的 DOM。
  • flush: 'sync':同步执行,不参与批处理,应谨慎用于频繁变化的数据。
  • once: true:源首次变化后停止监听,Vue 3.4+ 支持。

直接监听一个 reactive 对象时会隐式深度监听。由于新旧值指向同一个对象,嵌套属性变化时 newValue === oldValue 可能为 true。如果需要可靠比较变更前后的结构,应监听具体字段,或自行创建快照。

flush:控制回调的执行时机

一次响应式状态修改可能同时触发组件更新和多个监听器。Vue 通常不会立刻逐个执行这些任务,而是把它们放入调度队列并进行批处理。flush 决定监听器回调相对于组件 DOM 更新在什么时机执行:

取值执行时机是否批处理典型用途
'pre'父组件更新后、所属组件 DOM 更新前默认选择;根据变化继续修改状态、发请求等
'post'所属组件 DOM 更新后读取或操作更新后的 DOM、测量元素尺寸
'sync'检测到响应式变化时立即执行极少数必须同步使缓存失效或同步状态的场景

这里的“所属组件”是创建监听器的组件。默认的 'pre' 并不表示页面上所有 DOM 都尚未更新:父组件可能已经更新,但当前组件自己的 DOM 仍是旧状态。

flush: 'pre':默认的更新前回调

'pre' 监听器会被加入队列,同一个同步任务中的多次修改通常会合并,避免重复执行回调:

const count = ref(0)

watch(count, (value) => {
  console.log(value)
}) // 等价于 { flush: 'pre' }

count.value++
count.value++
count.value++
// 回调不会紧跟在每次 ++ 后执行,而会在队列刷新时执行

由于回调运行时所属组件还没有完成本轮 DOM 更新,不应在这里读取依赖最新渲染结果的 DOM。普通的数据请求、日志记录、状态联动等副作用通常保留默认值即可。

flush: 'post':等待所属组件 DOM 更新

需要访问更新后的 DOM 时使用 'post'。例如,根据最新内容读取元素高度:

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

const message = ref('')
const messageElement = ref(null)

watch(
  message,
  () => {
    // 此时模板已经根据最新 message 完成更新
    console.log(messageElement.value?.offsetHeight)
  },
  { flush: 'post' }
)
</script>

<template>
  <p ref="messageElement">{{ message }}</p>
</template>

对于这个场景,flush: 'post' 通常比在默认监听器中手动 await nextTick() 更直接。两者都能等待 DOM 更新,但 'post' 明确表达了整个监听器都属于“渲染后副作用”。

'post' 仍然参与批处理,只保证 Vue 已经提交本轮 DOM 更新,不保证浏览器已经完成布局和绘制。如果需要把动画或其他视觉操作安排到下一帧,可以再结合 requestAnimationFrame()

flush: 'sync':响应式变化时同步执行

'sync' 不进入批处理队列。每次检测到变化都会立即调用回调:

const enabled = ref(false)

watch(
  enabled,
  (value) => {
    invalidateCache(value)
  },
  { flush: 'sync' }
)

enabled.value = true
// 赋值过程中同步触发回调,执行到下一行前回调已经结束

这会带来两个风险:

  1. 性能问题:同步连续修改数组等数据时,回调可能执行成百上千次。
  2. 中间状态暴露:连续修改多个相关状态时,回调可能看到尚未修改完整的临时状态。
const items = ref([])

watch(items, persistItems, {
  deep: true,
  flush: 'sync'
})

for (let index = 0; index < 1000; index++) {
  items.value.push(index) // 可能同步触发 1000 次回调
}

因此只应对更新频率低、逻辑简单,并且确实要求同步执行的数据使用 'sync'。它不是让监听器“更快”的性能选项。

watchEffect 的关系

watchEffect() 使用相同的 flush 语义。Vue 还为两种常见时机提供了别名:

import { watchPostEffect, watchSyncEffect } from 'vue'

watchPostEffect(() => {
  // 等价于 watchEffect(callback, { flush: 'post' })
})

watchSyncEffect(() => {
  // 等价于 watchEffect(callback, { flush: 'sync' })
})

没有 watchPreEffect(),因为 watchEffect() 默认就是 flush: 'pre'

选择时可以记住一个简单原则:不依赖最新 DOM 就用默认的 'pre';依赖更新后的 DOM 用 'post';只有能够说明“为什么必须在赋值过程中同步执行”时才使用 'sync'

清理异步副作用

搜索词连续变化时,较早发出的请求可能更晚返回,进而用旧结果覆盖新结果。应在监听器失效时取消上一次请求:

const results = ref([])

watch(keyword, async (value, _oldValue, onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
    signal: controller.signal
  })
  results.value = await response.json()
})

清理函数会在当前回调失效、下一次回调执行前调用,也会在监听器停止时调用。

停止监听

watch() 返回停止函数。在组件 setup() 中同步创建的监听器会随组件卸载自动停止;手动管理或异步创建的监听器应主动清理。

const stop = watch(keyword, search)

// 不再需要监听时
stop()

watchEffect:自动追踪副作用依赖

watchEffect() 会立即执行回调,并自动追踪回调同步执行期间读取的响应式依赖:

const page = ref(1)
const pageSize = ref(20)

watchEffect(() => {
  console.log(`加载第 ${page.value} 页,每页 ${pageSize.value} 条`)
})

它适合“使用了哪些依赖”和“依赖变化后做什么”紧密关联的简单副作用。依赖较多时,可以减少重复声明。

watchwatchEffect 的核心区别是:

对比项watchwatchEffect
依赖来源显式指定监听源自动收集同步读取的依赖
首次执行默认不执行立即执行
新值与旧值可以获取不直接提供
触发控制只由监听源决定读取了什么就可能被什么触发
适合场景依赖明确、需要精确控制简单且依赖与逻辑紧密的副作用

异步 watchEffect 只会追踪第一个 await 之前同步读取的依赖:

watchEffect(async () => {
  const id = userId.value // 会被追踪
  const response = await fetch(`/api/users/${id}`)
  detail.value = await response.json() // await 后读取的依赖不会被追踪
})

这是由 Vue 的依赖收集方式和 JavaScript 的异步执行机制共同决定的。

Vue 执行 watchEffect 回调时,会暂时把当前副作用标记为“正在收集依赖的 effect”。在此期间读取响应式状态,相应的 ref 或 reactive 属性就能把当前 effect 记录为自己的订阅者。同步回调结束后,Vue 会清除这个标记。

可以把这个过程简化为:

let activeEffect

function runEffect(effect) {
  activeEffect = effect
  effect()
  activeEffect = undefined
}

function track(target) {
  if (activeEffect) {
    target.dependencies.add(activeEffect)
  }
}

异步函数执行到第一个 await 时,会立即返回 Promise,本轮同步调用随之结束。await 后面的代码要等 Promise 完成,再进入新的微任务继续执行:

async function effect() {
  readA()
  await request()
  readB()
}

// 可以近似理解为
function effect() {
  readA()

  return request().then(() => {
    readB()
  })
}

readB() 执行时,Vue 已经结束本轮依赖收集,记录当前 effect 的同步上下文也已经被清除。因此,只有 await 前读取的 A 能与当前 watchEffect 建立依赖关系。

const a = ref(1)
const b = ref(1)

watchEffect(async () => {
  console.log(a.value) // await 前读取,会被追踪

  await Promise.resolve()

  console.log(b.value) // await 后才读取,不会因此被追踪
})

在这个例子中:

  • 修改 a.value 会重新执行回调。
  • 只修改 b.value 不会重新执行回调。
  • 如果 b.valueawait 前也读取过,那么 b 仍然会被追踪。

需要自动追踪多个依赖时,可以先在 await 前读取并保存它们:

watchEffect(async () => {
  const currentUserId = userId.value
  const currentLocale = locale.value

  const response = await fetch(`/api/users/${currentUserId}`)
  detail.value = await response.json()

  console.log(currentLocale)
})

如果异步逻辑的依赖较多,使用 watch 显式声明通常更加清晰:

watch([userId, locale], async ([currentUserId, currentLocale]) => {
  const response = await fetch(`/api/users/${currentUserId}`)
  detail.value = await response.json()

  console.log(currentLocale)
})

当依赖关系需要读完整个函数才能确定时,显式的 watch 通常更容易维护。

三者放在同一个场景中

以商品搜索页为例:

const keyword = ref('')
const products = ref([])

// 1. 根据现有数据得到新值:computed
const matchedProducts = computed(() => {
  return products.value.filter((product) => {
    return product.name.includes(keyword.value)
  })
})

// 2. 搜索词变化后请求后端:watch
watch(keyword, fetchProducts, { immediate: true })

// 3. 自动收集多个依赖并记录调试信息:watchEffect
watchEffect(() => {
  console.log(keyword.value, matchedProducts.value.length)
})

这三段代码不是互相替代的关系,而是在解决不同问题:

  • computed 维护数据之间的声明式关系。
  • watch 精确控制副作用由什么变化触发。
  • watchEffect 便捷地执行依赖较直观的响应式副作用。

常见误区

1. 用 watch 同步两个状态

如果 B 能完全由 A 计算得出,优先使用 computed。重复状态可能出现更新顺序、初始化和数据不一致问题。

2. 在 computed 中修改依赖

getter 应保持纯粹。在计算过程中修改它依赖的状态,可能产生循环更新和难以预测的行为。

3. 滥用深度监听

deep: true 需要遍历嵌套结构。优先监听真正关心的字段,避免对大型对象做无差别深度监听。

4. 认为 watch 每次赋值都会立即执行

默认 watcher 会被 Vue 调度和批处理,同一轮更新中的多次同步变更不一定产生等量回调。只有确实需要同步行为时才考虑 flush: 'sync'

5. 忽略异步竞态

监听器可以使用异步回调,但 watch 不会自动保证请求按发出顺序完成。请求类副作用应实现取消、失效处理或结果版本校验。

怎么选

按下面的顺序判断:

  1. 是否要根据现有状态得到一个值?使用 computed
  2. 是否要在明确的数据变化后执行副作用?使用 watch
  3. 是否是一个依赖简单、希望立即执行并自动收集依赖的副作用?使用 watchEffect
  4. 是否只是由事件触发一次操作?直接在事件处理函数中执行,不需要 watcher。
需求推荐 API
过滤列表、合计金额、格式化展示computed
参数变化后请求接口watch
获取新旧值watch
读取更新后的 DOMwatch + flush: 'post'
多个依赖驱动简单副作用watchEffect
点击按钮后提交表单事件处理函数

面试回答

computed 用于声明衍生状态,getter 应保持纯粹。它会自动追踪响应式依赖,并基于依赖进行惰性求值和缓存:依赖不变时,多次读取不会重复计算。

watch 用于监听明确的数据源并执行副作用,可以获取新旧值,并通过 immediatedeepflush 等选项控制行为,适合请求接口、操作 DOM 和同步外部系统。

watchEffect 也用于副作用,但它会立即执行并自动收集同步执行期间读取的依赖。它写起来更简洁,触发来源却不如 watch 明确。

一句话总结:要一个值用 computed,值变化后要做一件事用 watch;简单且适合自动收集依赖的副作用用 watchEffect。