Vue3里的监听到底怎么用?watch、watchEffect分不清怎么办?还有进阶监听可选吗?
很多刚从Vue2转过来的朋友,刚上手Vue3 Composition API的监听机制就懵:原来的watch还能用,但好像加了一堆参数;又冒出来个自动追踪的watchEffect,看着简单但坑也不少;甚至还有watchPostEffect、watchSyncEffect这种连名字都绕口的东西,到底要不要花时间学?其实这些监听API各有各的分工,只要搞清楚触发时机、依赖规则、使用场景,根本不会混乱,今天就掰开揉碎讲明白。
从Vue2到Vue3:老的watch变了什么,没变什么?
先别着急学新东西,先捋捋咱们熟悉的老朋友,在Vue2 Options API里,watch是个对象,每个键是要监听的数据,值可以是函数、字符串方法名或者带handler、deep、immediate这些选项的对象,到了Vue3,watch不仅在Options API里保留了下来(功能和Vue2几乎一模一样),还搬到了Composition API里,写法有了变化,但核心逻辑没丢,这对转场的人来说是好事。
那Composition API里的watch长什么样?举个最简单的例子:假设我们有个计数器count,还有个响应式数组todos,要监听count的变化打印新值,监听todos里所有todo的完成状态更新总数,代码大概是这样的:
import { ref, reactive, watch } from 'vue'
const count = ref(0)
const todos = reactive([
{ id: 1, text: '学Vue3基础', done: false },
{ id: 2, text: '练Composition API', done: false }
])
const doneCount = ref(0)
// 监听单个ref
watch(count, (newVal, oldVal) => {
console.log(`count从${oldVal}变成了${newVal}`)
})
// 监听多个源:可以是ref、reactive属性的getter、甚至普通响应式变量的直接访问?
// 不对,直接访问reactive的变量会监听整个代理对象的根引用变化,得用getter!
watch(
[count, () => todos.filter(t => t.done).length],
([newCount, newDone], [oldCount, oldDone]) => {
doneCount.value = newDone
console.log(`count变了,或者完成数从${oldDone}变成了${newDone}`)
}
)
这里要提几个转Vue3 Composition API watch必须注意的点,踩坑率特别高: 第一个坑就是直接监听reactive的整个对象,比如watch(todos, () => {}),这时候不管你是改todos里某个todo的text,还是push/pop新元素,甚至把整个todos赋值成新数组(不过reactive不能直接根级赋值哦,要替换得用Object.assign或者ref包对象),watch都会触发,而且deep默认是true!但如果你只想监听整个对象的引用变化,或者某个嵌套深的属性,得调整:监听引用变化的话,刚才的例子可以改成watch(() => [...todos], ...)这种返回新数组的getter;监听单个嵌套属性的话,别直接todos[0].done,要写成() => todos[0].done,同时注意如果监听的是对象类型的嵌套属性,比如todo本身,也要考虑deep的问题。 第二个坑是oldVal的获取:当监听的是reactive的整个对象(或者用getter返回的数组/对象但没加deep:false?不对刚才直接监听reactive根是deep:true,getter返回新对象默认不触发深层,除非手动加),或者监听的是数组的直接push/pop(不是根替换),oldVal和newVal会指向同一个引用!这时候你拿不到旧值,要是真需要旧值,刚才的返回新数组的getter是个办法,比如watch(() => [...todos.map(t => ({...t}))], (newArr, oldArr) => {}),不过这样如果todos很大的话,性能会有点影响,得权衡。 第三个坑是immediate选项,这个和Vue2一样,加上的话,watch会在组件挂载或者watch创建的时候立刻执行一次回调,这时候oldVal会是undefined(如果监听的是单个ref/非立即的嵌套属性),或者是getter第一次返回的值(如果多个源里有其他有值的)。
什么是watchEffect?为什么说它“懒”又“自动”?
说完了老朋友,来看看新冒出来的核心角色watchEffect,大家可能听过一句很形象的话:watch是“主动指定依赖,等变化了再干活”,watchEffect是“自动收集依赖,干第一次活的时候顺便摸清楚要等谁”,这句话基本概括了它俩的核心区别,但实际用起来还有更多细节。
先看个watchEffect的基础例子,还是刚才的count和doneCount:
import { ref, reactive, watchEffect } from 'vue'
const count = ref(0)
const todos = reactive([
{ id: 1, text: '学Vue3基础', done: false },
{ id: 2, text: '练Composition API', done: false }
])
const doneCount = ref(0)
const message = ref('')
watchEffect(() => {
console.log('watchEffect触发了!')
doneCount.value = todos.filter(t => t.done).length
// 这里如果加一句message.value = `当前完成${doneCount.value}个任务`,message也会被自动收集为依赖
// 但如果把这行放在console.log上面,依赖收集的顺序会不会变?不会的,只要在回调里同步访问了响应式数据,都会被收集
})
对比刚才的watch,有没有发现几点不同?watchEffect不需要你指定要监听谁,它第一次执行回调的时候,碰到了todos.filter,filter里又访问了每个todo的done,所以这些done属性都成了它的依赖;watchEffect没有newVal和oldVal参数,因为它是每次依赖变化就触发,只关心“现在要做什么”,不关心“之前和现在有什么不一样”;第三,watchEffect默认就是“非懒加载”?不对等一下,刚才watch加immediate才第一次执行,watchEffect不加任何选项,创建的时候就立刻执行一次——对的,这点和加了immediate的watch很像,但immediate的watch还是指定了依赖的。
那什么时候用watchEffect比watch好?第一个场景是“你自己都不确定依赖哪些响应式数据,但只要用到的变了就更新”,比如刚才的doneCount更新,要是你后面加了个priority属性,想过滤掉priority低于2的不算完成,直接在filter里加个条件就行,不用去watch的依赖数组里加新的getter;第二个场景是“只需要同步当前状态,不需要对比旧值”,比如请求数据的场景:假设你有个page和pageSize的ref,每次它们变了就发请求拿列表,这时候用watchEffect可以直接在回调里写axios.get(/api/list?page=${page.value}&pageSize=${pageSize.value}),自动收集page和pageSize的依赖,不用手动指定;但这里要加个注意事项!请求数据的话,最好用watchEffect的清理函数,不然组件销毁了或者依赖快速变化的时候,会有多个请求同时返回,导致数据混乱。
清理函数怎么用?watchEffect的回调可以接受一个onInvalidate参数,是个函数,你调用它的时候传进去的清理逻辑,会在watchEffect重新执行之前、或者组件卸载的时候执行,比如刚才的请求场景:
import { ref, watchEffect } from 'vue'
import axios from 'axios'
const page = ref(1)
const pageSize = ref(10)
const list = ref([])
const loading = ref(false)
watchEffect((onInvalidate) => {
const controller = new AbortController()
const signal = controller.signal
loading.value = true
axios.get(`/api/list?page=${page.value}&pageSize=${pageSize.value}`, { signal })
.then(res => {
list.value = res.data
loading.value = false
})
.catch(err => {
if (!signal.aborted) { // 只有不是手动取消的才处理错误
console.error('请求失败', err)
loading.value = false
}
})
// 清理逻辑:取消上一次的请求
onInvalidate(() => {
controller.abort()
})
})
这个清理函数太重要了,不管是用watchEffect还是后面要讲的进阶API,只要是做异步操作(请求、定时器、DOM事件监听),都应该加上。
进阶监听API:watchSyncEffect、watchPostEffect到底有什么用?
刚才讲的watch和watchEffect,其实都是Vue3默认的“异步批量更新”模式下的产物,什么是异步批量更新?简单说就是Vue3不会每次响应式数据变了就立刻更新DOM和触发所有监听,而是会把同一事件循环里的所有变化收集起来,等到下一个微任务(比如Promise.then)的时候再统一处理,这样可以避免重复渲染,提升性能。
那默认的watch和watchEffect是在什么时候触发的?watchEffect默认是在DOM更新之前触发的(属于pre阶段),watch如果没加flush选项的话也是pre阶段,但有时候我们需要在DOM更新之后触发监听,比如你要基于新的DOM元素做一些操作(比如获取元素的宽度高度、初始化第三方库);还有时候你需要在响应式数据变化立刻同步触发监听,不想等批量更新,这时候就需要watchSyncEffect和watchPostEffect了,其实它们本质上就是watchEffect加了不同的flush选项,当然watch也可以加flush选项,功能是一样的。
先列一下三个flush选项的区别:
- pre(默认):在组件DOM更新之前触发,这时候访问DOM的话,拿到的是旧的DOM状态;
- post:在组件DOM更新之后触发,这时候可以拿到新的DOM状态;
- sync:同步触发,只要响应式数据变了,立刻执行,不参与批量更新,性能最差,尽量少用。
那对应的三个API呢?
- watchEffect = watchEffect({ flush: 'pre' })
- watchPostEffect = watchEffect({ flush: 'post' })
- watchSyncEffect = watchEffect({ flush: 'sync' })
同样,watch也可以加flush选项,比如watch(count, () => {}, { flush: 'post' }),这时候watch的回调就会在DOM更新之后触发。
举个post阶段的例子吧,比如你有个todo列表,每次添加新todo的时候,要让最后一个todo自动滚动到视野里:
import { ref, reactive, watchPostEffect } from 'vue'
const todos = reactive([{ id: 1, text: '默认任务', done: false }])
const todoListRef = ref(null) // 绑定ul的ref
const addTodo = () => {
todos.push({ id: Date.now(), text: '新任务', done: false })
}
// 用post阶段的API,等DOM更新之后(也就是新的li已经渲染出来了)再滚动
watchPostEffect(() => {
if (todoListRef.value && todos.length > 0) {
const lastLi = todoListRef.value.lastElementChild
lastLi.scrollIntoView({ behavior: 'smooth' })
}
})
如果这里用默认的watchEffect或者watch,会发现滚动不到新的li,因为这时候DOM还没更新,lastElementChild还是旧的最后一个。
再讲个sync阶段的场景,什么时候必须用?其实很少很少,除非你要和第三方非响应式的库做非常紧密的同步,比如某个第三方库需要你立刻把响应式数据的变化传进去,不能等哪怕一个微任务,比如假设你有个第三方图表库,必须在数据变化的瞬间更新图表的内部状态,不然会报错(当然这种库设计得不太好,但现实中可能会遇到):
import { ref, watchSyncEffect } from 'vue'
const chartData = ref([10, 20, 30])
// 假设initChart是第三方库的初始化函数,返回的chart实例有setData方法必须同步调用
const chart = initChart(document.getElementById('chart'), chartData.value)
watchSyncEffect(() => {
chart.setData(chartData.value)
})
但一定要记住,sync模式会破坏Vue的异步批量更新机制,可能会导致同一个事件循环里触发多次监听、多次渲染,性能会下降,所以除非万不得已,别用。
一张表帮你选对监听API
讲了这么多,可能还是有点记不住,没关系,最后整理一张清晰的对比表,下次要用的时候直接看就行:
| API/选项 | 依赖收集方式 | 初始执行? | 新旧值? | 触发时机(默认) | 适用场景 |
|---|---|---|---|---|---|
| watch(Options) | 手动指定(键/getter) | 可选immediate | 有 | pre(DOM前) | 需要对比新旧值、只监听明确的一个/几个数据、Vue2转过来习惯Options的场景 |
| watch(Composition) | 手动指定(数组/getter) | 可选immediate | 有 | pre(DOM前) | 需要对比新旧值、只监听明确的一个/几个数据、用Composition API的场景 |
| watchEffect | 自动同步收集 | 是 | 无 | pre(DOM前) | 不需要新旧值、依赖不确定/会动态变化、同步当前状态(含异步加清理)的场景 |
| watchPostEffect | 自动同步收集 | 是 | 无 | post(DOM后) | 不需要新旧值、需要操作新DOM的场景 |
| watchSyncEffect | 自动同步收集 | 是 | 无 | sync(立即同步) | 万不得已的必须同步第三方非响应式库的场景 |
| watch+flush:post | 手动指定 | 可选immediate | 有 | post(DOM后) | 需要对比新旧值、还需要操作新DOM的场景(很少见,但有备无患) |
| watch+flush:sync | 手动指定 | 可选immediate | 有 | sync(立即同步) | 万不得已的必须同步+对比新旧值的场景(更少见) |
最后再提几个容易被忽略的小细节
除了上面讲的核心内容,还有几个小细节能帮你少踩坑:
- 停止监听:不管是watch还是watchEffect,调用它们都会返回一个stop函数,你调用这个stop函数,就能手动停止监听,比如在某些条件满足之后不需要再监听了,就可以这么做;
- 在setup之外使用监听API:可以,但必须在组件的生命周期上下文里,比如在onMounted、onUpdated这些钩子函数里调用;如果是在普通的JS函数里调用,又没有上下文的话,Vue3会警告,而且监听不会自动在组件卸载的时候停止,必须手动调用stop函数;
- 监听非响应式数据? 不行!不管是watch还是watchEffect,只能监听响应式数据(ref、reactive、computed这些),如果你要监听普通的JS变量,得先把它变成响应式的;
- computed和watch/watchEffect的区别:很多人也会搞混这个,其实computed是“基于响应式数据计算新的响应式数据”,有缓存,只有依赖变化的时候才会重新计算,而且必须有返回值;watch/watchEffect是“基于响应式数据做副作用操作”(比如修改DOM、发请求、修改全局变量),没有缓存(除非手动加,但没必要),不需要返回值。
好啦,关于Vue3监听的所有核心内容和踩坑点都讲完了,希望能帮到你,其实只要多写几个小demo,试一下不同的场景,很快就能熟练掌握这些API的使用了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


