先别急着区分,先搞懂它们俩为啥会存在?
最近身边好多前端转Vue3的小伙伴找我吐槽:之前Vue2用惯了watch,现在突然冒出来个watchEffect,官方文档说这俩都是侦听响应式变化,却不说清楚什么时候用哪个,写代码的时候总纠结要不要试试新东西,怕用错反而麻烦,其实我刚接触的时候也踩过坑,后来翻了好多资料,结合自己写项目的经验,总算理得明明白白了,今天咱们就用一问一答的方式,把这俩的区别、适用场景、甚至踩坑点都讲透,看完你绝对不会再纠结。 很多人一开始学的时候,会把这俩当成“Vue2 watch的升级版加个新功能版”,但其实不是,它们俩在Vue3的响应式设计里,承担的角色定位从一开始就不一样,这得从Vue3的核心——Proxy响应式系统讲起。
Vue2的Object.defineProperty虽然能实现响应式,但有天生的缺陷:不能监听对象新增/删除属性,不能监听数组索引和长度的变化,所以Vue2不得不加了$set、$delete这些API,数组的变异方法也重写了一遍,用起来多少有点束缚。
Vue3改用Proxy之后,这些问题都解决了,但响应式的追踪逻辑也变了——它是在代码执行过程中,动态地追踪到被访问的响应式数据,而不是像Vue2那样在初始化阶段就提前收集依赖(虽然Vue2组件里的data也是初始化收集,但其他地方比如watch的源是函数返回值,Vue3也沿用了类似逻辑,但watchEffect更纯粹)。
watch和watchEffect就是基于这个新的追踪逻辑,拆出来的两个不同场景的工具:
- watch是“选择性追踪、主动触发、可控回调”的工具,更像Vue2 watch的完全体(解决了Vue2的那些缺陷),适合需要明确知道侦听的源数据、变化前后的值、或者控制触发时机的场景;
- watchEffect是“自动追踪、自动触发、回调只做响应式数据联动”的工具,是Vue3响应式系统的“原生小助手”,适合只要侦听回调里用到的所有响应式数据、不需要旧值、不需要手动指定源的场景。
这样定位是不是就清晰多了?接下来咱们一个问题一个问题拆细节。
最直观的区别:要不要手动指定侦听的源数据?
这是新手一眼就能看出来的区别,咱们先举两个简单的例子对比下。
先看watch的写法:
import { ref, watch } from 'vue'
const count = ref(0)
const name = ref('张三')
// 侦听单个ref
watch(count, (newVal, oldVal) => {
console.log(`count从${oldVal}变成了${newVal}`)
})
// 侦听多个源,用数组包起来
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
console.log(`count从${oldCount}变${newCount},name从${oldName}变${newName}`)
})
// 侦听对象的某个属性,要传一个getter函数(或者直接传ref,这里演示复杂点的场景)
const user = reactive({
age: 18,
job: { '前端开发'
}
})
watch(
() => user.job.title,
(newTitle, oldTitle) => {
console.log(`工作从${oldTitle}变成了${newTitle}`)
}
)
再看watchEffect的写法:
import { ref, reactive, watchEffect } from 'vue'
const count = ref(0)
const name = ref('张三')
const user = reactive({
age: 18,
job: { '前端开发'
}
})
const stopEffect = watchEffect(() => {
// 这里用到的count、user.job.title都会被自动追踪
console.log(`当前count是${count},工作是${user.job.title}`)
// name没用到,所以name变化时不会触发这个回调
})
看出来了吧?watch必须显式地传第一个参数(源数据),可以是单个ref、单个reactive(但要注意后面说的reactive深度侦听的问题)、单个getter函数(用来精准定位对象的某个属性或组合多个属性的结果)、或者这些的数组;而watchEffect完全不需要传源,它会在第一次执行回调函数的时候,自动收集回调里用到的所有响应式数据作为源,之后只要这些源里有任何一个变化,都会重新触发回调。
这里有个小细节:watchEffect的回调是立即执行一次的,用来完成第一次依赖收集;而watch的回调默认不会立即执行,只有源数据第一次变化之后才会触发——这个默认行为可以改,后面会讲。
第二个核心区别:能不能拿到变化前后的旧值?
这个区别其实和“要不要手动指定源”是绑定的——因为watchEffect是自动追踪,而且它的设计初衷就是“只做联动,不管过去”,所以它永远拿不到旧值,每次回调执行时,只能拿到源数据的最新值。
而watch因为是显式指定源的,所以它的第二个参数(回调函数)的前两个参数,分别是源数据的最新值和变化前的旧值——哪怕你侦听的是多个源,新值和旧值也会按顺序对应成数组传进去,非常清晰。
举个例子,假设我们要做一个“用户修改个人简介超过20个字就弹窗提醒”的功能,这个时候就必须拿到旧值,判断用户是不是真的加了太多字,而不是一开始就很长:
import { ref, watch } from 'vue'
const bio = ref('')
watch(bio, (newBio, oldBio) => {
// 只有当新简介超过20,且旧简介没超过的时候才弹窗(避免每次输入超过20的字都弹)
if (newBio.length > 20 && oldBio.length <= 20) {
alert('个人简介最多20个字哦!')
}
})
这个场景如果用watchEffect的话,根本做不到——因为每次bio变化都会触发,不管旧值是多少,你总不能每次输入超过20的字都弹一次吧?用户体验会炸的。
第三个区别:侦听reactive对象/数组的默认行为不一样?
这个是很多新手踩的大坑,一定要注意!咱们先回忆下reactive的特点:它是一个响应式的代理对象,你修改它的属性,或者数组的元素/长度,Proxy都能监听到,但如果你直接替换整个reactive对象(比如把user = reactive({...})改成user = {age: 20}),那原来的代理就丢失了,不会再触发任何侦听——这个不管是watch还是watchEffect都一样,但默认的深度侦听行为,它们俩完全不同。
先看侦听整个reactive对象的情况:
import { reactive, watch, watchEffect } from 'vue'
const user = reactive({
name: '张三',
age: 18,
job: { '前端开发'
}
})
// 情况1:watch直接传整个reactive对象
watch(user, (newUser, oldUser) => {
console.log('watch直接侦听user触发了')
console.log('newUser === oldUser?', newUser === oldUser) // 这里会输出true!因为Proxy的引用没变
})
// 情况2:watch传getter函数获取整个user
watch(
() => ({ ...user }), // 必须解构赋值或者JSON.parse(JSON.stringify()),否则和直接传user一样
(newUser, oldUser) => {
console.log('watch传getter+解构user触发了')
console.log('newUser === oldUser?', newUser === oldUser) // 这里输出false
}
)
// 情况3:watchEffect回调里用到整个user的属性
watchEffect(() => {
console.log('watchEffect用到user的属性触发了')
console.log(user.name, user.job.title)
})
// 现在我们修改不同层级的属性
user.name = '李四' // 情况1、2、3都触发
user.job.title = '全栈开发' // 情况1、3触发,情况2如果是解构整个user也会触发,但如果只是解构了第一层(name, age})就不会
user = { name: '王五', age: 25 } // 都不触发,因为代理丢失了
这里的关键点有三个:
- watch直接传整个reactive对象/数组时,默认开启深度侦听(deep: true),不管修改哪一层属性都会触发,但因为Proxy的引用没变,所以newUser和oldUser是同一个对象,旧值等于新值,完全没用;
- 如果想让watch侦听整个reactive对象/数组的整体变化,或者拿到有效的旧值,必须传一个getter函数,并且在getter里返回一个新的对象/数组引用(比如解构赋值、扩展运算符、JSON.parse(JSON.stringify())),但要注意JSON.parse(JSON.stringify())不能处理函数、正则、Symbol、循环引用这些类型;
- watchEffect没有“深度侦听”的选项!它是“按需深度追踪”的——如果你的回调里只用到了user.name(第一层属性),那修改user.job.title(第二层)不会触发;如果回调里用到了user.job.title(第二层),那修改第二层就会触发;如果回调里遍历了整个user的所有属性(包括嵌套的),那修改任何一层都会触发——完全取决于你回调里写了什么。
第四个区别:能不能控制侦听的触发时机?
这个区别也是watch的“专属福利”,watchEffect完全没有——watch有两个很重要的配置项(第三个参数options):immediate和flush,这两个配置项可以让你灵活控制回调的触发时机,而watchEffect只有一个flush选项(不过用法和watch略有不同,后面会讲)。
先讲immediate配置项:watch默认是懒加载的,也就是第一次初始化的时候不会执行回调,只有源数据第一次变化之后才会执行;如果把immediate设为true,那watch的回调会和watchEffect一样,在第一次初始化的时候立即执行一次,用来完成第一次依赖收集(不过watch的依赖收集本来就是显式的,immediate只是提前触发回调)。
比如刚才的个人简介例子,如果我们希望页面加载的时候就检查一下bio的长度(比如从后端接口拿到的初始bio),那就可以加immediate: true:
watch(bio, (newBio, oldBio) => {
if (newBio.length > 20 && oldBio.length <= 20) {
alert('个人简介最多20个字哦!')
}
}, {
immediate: true // 第一次加载就执行
})
这里要注意,immediate设为true的时候,第一次执行回调的oldVal是undefined,因为还没有变化过。
再讲flush配置项:这个配置项用来控制回调在Vue的更新周期里的哪个阶段执行,有三个可选值:'pre'(默认)、'post'、'sync',不管是watch还是watchEffect都有这个选项,但它们的默认值都是'pre',咱们先讲通用的三个值的含义,再讲watchEffect的小差异。
flush的三个值通用含义
- 'pre'(默认,预更新):回调会在Vue组件DOM更新之前执行——这个时候你可以拿到最新的响应式数据,但DOM还是旧的,如果你的回调里需要操作DOM,可能会拿到旧的DOM元素或者旧的样式;
- 'post'(后更新):回调会在Vue组件DOM更新之后执行——这个时候响应式数据和DOM都是最新的,适合需要操作DOM的场景;
- 'sync'(同步):回调会在响应式数据变化的那一刻同步执行——这个时候不管Vue的更新周期到哪了,都会立刻执行,性能会比较差,尽量不要用,除非是非常特殊的场景(比如要拦截某个数据的变化,在DOM更新之前做紧急处理,但又不能用'pre'的情况)。
watch和watchEffect的flush配置项小差异
watchEffect还有一个变种API叫watchPostEffect和watchSyncEffect,其实就是watchEffect把flush设为'post'和'sync'的简写,写起来更方便:
// 这两行是等价的
watchEffect(() => {}, { flush: 'post' })
watchPostEffect(() => {})
// 这两行也是等价的
watchEffect(() => {}, { flush: 'sync' })
watchSyncEffect(() => {})
而watch只有一个API,所有配置都要在第三个参数里写,没有简写版本。
举个需要操作DOM的例子,比如我们要做一个“当输入框内容变化时,自动把输入框滚动到最底部”的功能:
import { ref, watch, nextTick } from 'vue'
const content = ref('')
const textareaRef = ref(null)
// 方法1:用watch + flush: 'post'
watch(content, () => {
textareaRef.value.scrollTop = textareaRef.value.scrollHeight
}, {
flush: 'post'
})
// 方法2:用watch + nextTick(nextTick也是等DOM更新完再执行,和flush: 'post'类似,但有细微差异,后面讲)
watch(content, () => {
nextTick(() => {
textareaRef.value.scrollTop = textareaRef.value.scrollHeight
})
})
// 方法3:用watchPostEffect
watchPostEffect(() => {
// 这里要注意,watchPostEffect是自动追踪的,所以content变化时会触发
textareaRef.value.scrollTop = textareaRef.value.scrollHeight
})
这里顺便提一下nextTick和flush: 'post'的细微差异:nextTick会在所有组件的DOM更新都完成之后执行,而flush: 'post'只会在当前组件的DOM更新完成之后执行——如果你只需要操作当前组件的DOM,用flush: 'post'或者watchPostEffect会更高效。
第五个区别:能不能手动停止侦听?
这个其实两个都能,但用法一样,也算个小知识点吧——不管是watch还是watchEffect,调用它们的时候都会返回一个停止函数,只要调用这个停止函数,就会停止对应的侦听,之后源数据再变化也不会触发回调了。
举个例子,比如我们要做一个“倒计时30秒,30秒之后停止侦听倒计时数据”的功能:
import { ref, watch, watchEffect } from 'vue'
const countdown = ref(30)
// 用watch的情况
const stopWatch = watch(countdown, (newVal) => {
console.log(`倒计时:${newVal}秒`)
})
// 用watchEffect的情况
const stopEffect = watchEffect(() => {
console.log(`倒计时:${countdown}秒`)
})
// 30秒后停止
setTimeout(() => {
stopWatch()
stopEffect()
console.log('倒计时结束,停止侦听')
}, 30000)
不过要注意,如果你的侦听是在组件的setup函数里写的,或者在
code前端网