Computed、Watch 与 watchEffect
computed 负责声明衍生状态,watch 和 watchEffect 负责响应状态变化并执行副作用。
先建立心智模型
选择 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)
})
})
惰性求值、依赖追踪与缓存
计算属性有三个关键特征:
- 自动追踪依赖:执行 getter 时读取的响应式状态会成为依赖。
- 惰性求值:只有结果被读取时,getter 才需要执行。
- 基于依赖缓存:依赖没有变化时,多次读取直接复用上次结果。
可以把内部过程简化为:
- 首次读取计算属性,执行 getter、收集依赖并缓存结果。
- 依赖变化,将计算属性标记为“待更新”。
- 下次读取时重新计算并更新缓存。
因此,计算属性与方法虽然可能返回相同结果,执行时机却不同:
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} 条`)
})
它适合“使用了哪些依赖”和“依赖变化后做什么”紧密关联的简单副作用。依赖较多时,可以减少重复声明。
watch 与 watchEffect 的核心区别是:
| 对比项 | watch | watchEffect |
|---|
| 依赖来源 | 显式指定监听源 | 自动收集同步读取的依赖 |
| 首次执行 | 默认不执行 | 立即执行 |
| 新值与旧值 | 可以获取 | 不直接提供 |
| 触发控制 | 只由监听源决定 | 读取了什么就可能被什么触发 |
| 适合场景 | 依赖明确、需要精确控制 | 简单且依赖与逻辑紧密的副作用 |
异步 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.value 在 await 前也读取过,那么 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 不会自动保证请求按发出顺序完成。请求类副作用应实现取消、失效处理或结果版本校验。
怎么选
按下面的顺序判断:
- 是否要根据现有状态得到一个值?使用
computed。
- 是否要在明确的数据变化后执行副作用?使用
watch。
- 是否是一个依赖简单、希望立即执行并自动收集依赖的副作用?使用
watchEffect。
- 是否只是由事件触发一次操作?直接在事件处理函数中执行,不需要 watcher。
| 需求 | 推荐 API |
|---|
| 过滤列表、合计金额、格式化展示 | computed |
| 参数变化后请求接口 | watch |
| 获取新旧值 | watch |
| 读取更新后的 DOM | watch + flush: 'post' |
| 多个依赖驱动简单副作用 | watchEffect |
| 点击按钮后提交表单 | 事件处理函数 |
面试回答
computed 用于声明衍生状态,getter 应保持纯粹。它会自动追踪响应式依赖,并基于依赖进行惰性求值和缓存:依赖不变时,多次读取不会重复计算。
watch 用于监听明确的数据源并执行副作用,可以获取新旧值,并通过 immediate、deep、flush 等选项控制行为,适合请求接口、操作 DOM 和同步外部系统。
watchEffect 也用于副作用,但它会立即执行并自动收集同步执行期间读取的依赖。它写起来更简洁,触发来源却不如 watch 明确。
一句话总结:要一个值用 computed,值变化后要做一件事用 watch;简单且适合自动收集依赖的副作用用 watchEffect。