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。如果需要可靠比较变更前后的结构,应监听具体字段,或自行创建快照。

清理异步副作用

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

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。