前言
在 Vue 应用中,除了计算属性这种衍生状态,我们还需要处理各种副作用:网络请求、DOM 操作、本地存储、定时器等。Vue3 提供了两个强大的 API:watch 和 watchEffect 来响应式地执行副作用。然而,很多开发者对它们的使用场景和区别认识不清,要么过度使用导致性能问题,要么使用不当导致内存泄漏。
本文将深入剖析 watch 和 watchEffect 的工作原理、使用场景和优化策略,帮助我们精准监听、高效管理副作用。
watch vs watchEffect:核心区别
watch
watch 的基本概念
watch 的设计理念是精准控制:我们需要明确告诉它需要监听什么,以及当监听的数据发生变化时又需要做什么:
import { ref, watch } from 'vue'
const count = ref(0)
const name = ref('张三')
// 基本用法:监听单个源
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})
// 监听响应式对象
watch(name, (newValue, oldValue) => {
console.log(`name 从 ${oldValue} 变为 ${newValue}`)
})
watch 的核心特点
- 懒执行:只有在监听源发生变化时才执行,不会立即执行
- 需要指定源:必须明确告诉它要监听什么
- 可以访问新旧值:在回调中可以获得数据变化前后的值
- 可以监听多个源:可以使用数组的形式监听多个源
watchEffect
watchEffect 的基本概念
watchEffect 的设计理念是自动追踪:它会立即执行一次,并且在执行过程中自动收集 所有 响应式依赖,当这些依赖发生变化时重新执行:
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('张三')
watchEffect(() => {
// 自动追踪 count 和 name
console.log(`count: ${count.value}, name: ${name.value}`)
})
// 立即输出: count: 0, name: 张三
// 修改 count,自动重新执行
count.value++ // 输出: count: 1, name: 张三
watchEffect 的核心特点
- 立即执行:创建时会立即执行一次
- 自动收集依赖:不需要指定监听源,依赖是自动收集的
- 无法获取旧值:回调中只有当前值,没有变化前的值
- 语法更简洁:适合不需要旧值的场景
选择决策树
watch 的进阶用法
深度监听:deep
当我们需要监听一个对象时,默认情况下只有对象的引用变化才会触发,对象中的属性变化并不会触发监听:
const user = ref({
name: '张三',
address: {
city: '北京'
}
})
// ❌ 属性变化不会触发
watch(user, () => {
console.log('user 变化')
})
user.value.name = '李四' // 不会触发
当我们使用 deep配置时,就可以触发深度监听,即:对象中的属性发生改变时也会触发监听:
// ✅ 使用 deep: true 监听所有嵌套属性变化
watch(user, () => {
console.log('user 变化')
}, { deep: true })
user.value.name = '李四' // 触发
user.value.address.city = '上海' // 触发
deep 的性能分析
- 深度监听
deep: true:需要递归遍历所有嵌套属性,对大型对象开销较大 - 监听具体属性:只监听需要的属性,性能更好
- 使用
computed:可以组合多个属性,但只在这些属性变化时触发
立即执行:immediate
默认情况下,watch 都是懒执行的,但有些场景我们需要在初始化时就执行一次监听,此时就需要用到 immediate 配置:
const userId = ref(1)
const userData = ref(null)
// 会立即执行一次
watch(userId, async (newId) => {
userData.value = await fetchUser(newId)
}, { immediate: true })
监听多个源:使用数组
当需要监听多个数据源,并且希望在任何一个数据源变化时,都执行同一个回调:
const categoryId = ref('all')
const sortBy = ref('relevance')
// 监听多个源
watch([categoryId, sortBy], () => {
console.log('筛选条件变化')
})
flush 时机:pre | post | sync 的区别
flush 选项可以控制回调的执行时机,这对 DOM 操作特别重要:
pre:默认值,在组件更新前执行,此时无法操作 DOMpost:在组件更新后执行,可以访问更新后的 DOMsync:在响应式依赖变化时立即执行(谨慎使用)
import { ref, watch } from 'vue'
const count = ref(0)
// 默认 pre:在组件更新前执行
watch(count, () => {
console.log('pre: DOM 还未更新')
}, { flush: 'pre' })
// post:在组件更新后执行,可以访问更新后的 DOM
watch(count, () => {
console.log('post: DOM 已更新')
// 可以安全地操作更新后的 DOM
}, { flush: 'post' })
// sync:在响应式依赖变化时立即执行(谨慎使用)
watch(count, () => {
console.log('sync: 立即执行')
}, { flush: 'sync' })
副作用清理:避免内存泄漏
场景:监听路由变化,取消之前的请求
在处理异步操作时,最常见的场景就是竞态条件:当请求发起后,但还没返回结果时,参数又变化了。这时需要取消之前的请求:
import { watch, ref } from 'vue'
import { searchAPI } from './api'
const searchQuery = ref('')
const searchResults = ref([])
const loading = ref(false)
// ❌ 错误:没有处理竞态条件
watch(searchQuery, async (newQuery) => {
loading.value = true
const results = await searchAPI(newQuery) // 慢请求
// 如果此时 query 已经变化,这个结果可能是过时的
searchResults.value = results
loading.value = false
})
// ✅ 正确:使用 onCleanup 取消之前的请求
watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
const controller = new AbortController()
// 注册清理函数
onCleanup(() => {
controller.abort()
console.log('取消请求:', newQuery)
})
loading.value = true
try {
const results = await searchAPI(newQuery, {
signal: controller.signal
})
// 只有请求没有被取消时才更新结果
searchResults.value = results
} catch (error) {
if (error.name === 'AbortError') {
// 请求被取消,忽略
console.log('请求已取消')
} else {
// 其他错误
console.error('搜索失败:', error)
}
} finally {
loading.value = false
}
})
onCleanup 的实现原理
onCleanup 是 watch 回调的第三个参数,它是一个函数,用来注册清理回调:
// 模拟 onCleanup 的工作原理
function createWatcher(source, callback) {
let cleanup = null
const registerCleanup = (fn) => {
cleanup = fn
}
const runCallback = () => {
// 执行之前的清理函数
if (cleanup) {
cleanup()
}
// 执行新的回调
callback(source.value, null, registerCleanup)
}
// 监听变化
onSourceChange(runCallback)
}
更多清理场景
清理定时器
const delay = ref(1000)
watch(delay, (newDelay, oldDelay, onCleanup) => {
const timer = setInterval(() => {
console.log('定时器执行')
}, newDelay)
onCleanup(() => {
clearInterval(timer)
console.log('定时器已清理')
})
}, { immediate: true })
取消 WebSocket 连接
const roomId = ref('general')
watch(roomId, (newRoom, oldRoom, onCleanup) => {
const socket = new WebSocket(`ws://server/${newRoom}`)
socket.onmessage = (event) => {
// 处理消息
}
onCleanup(() => {
socket.close()
console.log(`离开房间: ${oldRoom}`)
})
}, { immediate: true })
移除事件监听
const element = ref(null)
const eventType = ref('click')
watch([element, eventType], ([el, type], [oldEl, oldType], onCleanup) => {
if (!el) return
const handler = (e) => {
console.log(`事件触发: ${type}`, e)
}
el.addEventListener(type, handler)
onCleanup(() => {
el.removeEventListener(type, handler)
console.log(`移除事件监听: ${type}`)
})
}, { immediate: true })
性能陷阱与优化
过度监听:监听整个对象 vs 监听具体属性
const filters = ref({
category: 'all',
priceRange: [0, 1000],
inStock: true,
rating: 0,
sortBy: 'price',
keywords: ''
})
watch(filters, () => {
// 任何 filter 属性变化都会触发 API 调用
fetchProducts(filters.value)
}, { deep: true })
// 修改一个属性就调用一次 API,可能过于频繁
优化方案:监听特定属性
const fetchTrigger = computed(() => ({
category: filters.value.category,
priceRange: filters.value.priceRange,
inStock: filters.value.inStock
}))
watch(fetchTrigger, () => {
// 只有这三个相关属性变化才触发
fetchProducts(filters.value)
})
使用 debounce 进一步优化
import { debounce } from 'lodash-es'
const debouncedFetch = debounce((filters) => {
fetchProducts(filters)
}, 300)
watch(filters, () => {
debouncedFetch(filters.value)
}, { deep: true })
频繁触发:使用 throttle 和 debounce
场景1:搜索输入 - 使用 debounce
const debouncedSearch = debounce((query) => {
console.log('执行搜索:', query)
}, 300)
watch(searchInput, (newValue) => {
debouncedSearch(newValue)
})
场景2:滚动位置 - 使用 throttle
const scrollPosition = ref(0)
const throttledSave = throttle((position) => {
localStorage.setItem('scrollPosition', position)
}, 1000)
watch(scrollPosition, (newPos) => {
throttledSave(newPos)
})
实战:实现一个可取消的异步请求监听器
完整实现
// composables/useCancellableWatch.js
import { watch } from 'vue'
export function useCancellableWatch(source, asyncFn, options = {}) {
const { immediate = false, debounce: debounceMs = 0, onError } = options
let controller = new AbortController()
let timeoutId = null
const wrappedAsyncFn = (value) => {
// 取消之前的请求
controller.abort()
controller = new AbortController()
// 执行新的异步函数
asyncFn(value, controller.signal).catch(error => {
if (error.name !== 'AbortError' && onError) {
onError(error)
}
})
}
const handler = (value) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
if (debounceMs > 0) {
timeoutId = setTimeout(() => wrappedAsyncFn(value), debounceMs)
} else {
wrappedAsyncFn(value)
}
}
// 创建监听
const stop = watch(source, handler, { immediate })
// 返回停止函数
return () => {
stop()
controller.abort()
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
在组件中使用
<template>
<div class="search-container">
<input
v-model="query"
placeholder="搜索..."
@input="handleInput"
/>
<span class="loading" v-if="loading">搜索中...</span>
<div class="results">
<div v-for="item in results" :key="item.id">
{{ item.title }}
</div>
</div>
<div v-if="error" class="error">
出错了: {{ error.message }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useCancellableWatch } from './composables/useCancellableWatch'
const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)
// 模拟搜索 API
async function searchAPI(query, signal) {
loading.value = true
error.value = null
try {
// 模拟网络请求
await new Promise(resolve => setTimeout(resolve, 1000))
// 检查是否被取消
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError')
}
// 模拟返回结果
const mockResults = [
{ id: 1, title: `${query} 结果1` },
{ id: 2, title: `${query} 结果2` },
{ id: 3, title: `${query} 结果3` }
]
results.value = mockResults
} finally {
loading.value = false
}
}
// 使用我们的自定义监听器
const stopWatch = useCancellableWatch(
query,
async (value, signal) => {
if (value.length < 2) {
results.value = []
return
}
await searchAPI(value, signal)
},
{
immediate: false,
debounce: 300,
onError: (err) => {
if (err.name !== 'AbortError') {
error.value = err
}
}
}
)
// 组件卸载时自动清理
onUnmounted(() => {
stopWatch()
})
</script>
决策指南
| 需求 | 推荐方案 | 原因 |
|---|---|---|
| 需要访问新旧值 | watch | watch 提供新旧值参数 |
| 需要立即执行一次 | watch + immediate: true 或 watchEffect | 两者皆可,看是否需要旧值 |
| 只需要知道变化了 | watchEffect | 语法更简洁 |
| 监听多个相关源 | watch 数组形式 | 可以一起处理,也可以分别处理 |
| 需要操作更新后的 DOM | watch + flush: post | 确保 DOM 已更新 |
| 需要取消异步操作 | watch + onCleanup | 提供专门的清理机制 |
| 监听对象内部属性变化 | watch + 函数返回具体属性 | 避免 deep: true 的性能开销 |
结语
watch 用于精确控制,watchEffect 用于自动追踪。开发中需要选择哪个,取决于我们的具体需求:需要细粒度控制就用 watch,想要简洁的自动追踪就用 watchEffect。理解它们的本质区别,就能在合适的场景做出正确的选择。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!