Code前端首页关于Code前端联系我们

Vue3里用watch还是watchEffect?新手总踩的7个坑和实战避坑指南

terry 2小时前 阅读数 32 #Vue

watch和watchEffect的区别是什么?为什么明明选了一个API却没效果?或者明明生效了却出了一堆重复调用、内存泄漏的问题?别慌,今天咱们就从日常开发的角度,把这俩货扒得明明白白,连官网提过但容易被忽略的细节,都会结合具体案例说清楚。

先别急着讲区别,先回忆下Vue2里的watch?毕竟很多新手都是从Vue2转过来的,把俩框架的东西做个小对比,反而能更快上手,Vue2里的watch是显式侦听,必须指定要“盯着”的具体数据(或者是函数返回值),默认只侦听数据的第一层变化,如果要侦听深层对象/数组,还得加deep: true,还有个immediate: true可以让它在组件挂载后立刻执行一次回调,那Vue3的watch呢?其实就是“升级款的Vue2 watch”,核心逻辑没变,但多了几个有用的能力,比如可以同时侦听多个源、可以取消侦听、回调函数的参数顺序是「新值在前旧值在后」(这点和Vue2完全一样,这点可以放心记)。

那watchEffect呢?这是Vue3完全新增的API,官网叫它“副作用函数”,从名字就能看出来,它的核心和watch不一样——watch是“我先告诉你要盯啥,等它变了我再干活”,而watchEffect是“我先跑一遍我的函数,里面用到啥数据就自动盯啥,等用到的数据变了我再重新跑”,光说太抽象,咱们先写个最简单的计数器对比案例,直观感受下:

<template>
  <div>
    <p>普通计数:{{ count }}</p>
    <button @click="count++">加1</button>
    <p>平方计数(watch算):{{ square1 }}</p>
    <p>平方计数(watchEffect算):{{ square2 }}</p>
  </div>
</template>
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const square1 = ref(0)
const square2 = ref(0)
// watch显式侦听count
watch(count, (newVal) => {
  square1.value = newVal * newVal
  console.log('watch触发了,square1更新')
})
// watchEffect自动侦听里面用到的count
watchEffect(() => {
  square2.value = count.value * count.value
  console.log('watchEffect触发了,square2更新')
})
</script>

你把这段代码复制到Vue3的项目或者在线编辑器(比如Vue SFC Playground)里跑一下,点击“加1”按钮,会看到什么?第一次打开页面的时候,watchEffect的log立刻就出来了,square2也直接变成了0;但watch的log一开始没有,square1还是初始的0,只有点了加1之后,watch才会触发,square1才会更新,哦对了,如果想让watch也在挂载后立刻执行,就得像Vue2那样加immediate: true,改一下watch的代码:

// 加了immediate的watch
watch(count, (newVal) => {
  square1.value = newVal * newVal
  console.log('watch触发了(带immediate),square1更新')
}, { immediate: true })

现在再打开页面,俩log都会出来,square1和square2也都是0了,这就是第一个小区别点:watchEffect默认是immediate执行的,watch默认不是。

watch和watchEffect的核心区别拆解(带避坑提示)

刚才的计数器案例太简单了,只能看出immediate的区别,接下来咱们从「侦听源的指定方式」「触发时机的精确控制」「回调函数的参数」「是否能取消侦听」「是否能侦听深层对象」「是否能获取旧值」「性能消耗」这7个维度,仔仔细细讲清楚,每个维度都带新手容易踩的坑。

侦听源的指定方式:显式 vs 隐式

watch的显式指定:

watch的侦听源是必须手动写的,而且支持的类型很多:

  • 单个ref:比如刚才的count
  • 单个reactive对象:注意这里有个新手必踩的坑!直接侦听reactive对象本身的话,默认就是deep: true的,不管你加不加,它都会侦听对象内部每一层属性的变化;而且回调函数的新值和旧值永远是同一个对象,因为reactive是响应式引用类型,不是值类型的副本。
    <script setup>
    import { reactive, watch } from 'vue'
    const user = reactive({
    name: '张三',
    age: 18,
    address: {
      city: '北京'
    }
    })

// 直接侦听reactive对象本身 watch(user, (newVal, oldVal) => { console.log('直接侦听user触发了') console.log(newVal === oldVal) // 永远打印true! })

// 点这个按钮,直接侦听user的watch会触发 const changeCity = () => { user.address.city = '上海' }

``` 那如果我只想侦听reactive对象的某个具体属性呢?或者说,我想获取某个具体属性变化的旧值?这时候就不能直接侦听对象本身了,得用**函数返回值**作为侦听源,也就是官网说的“getter函数”: ```javascript // 用getter函数侦听user.name,这样新值旧值就是字符串副本,能拿到不同的 watch( () => user.name, (newName, oldName) => { console.log('user.name变化了') console.log('新name:', newName) console.log('旧name:', oldName) } )

// 用getter函数侦听user.address.city,同样能拿到不同的 watch( () => user.address.city, (newCity, oldCity) => { console.log('user.address.city变化了') console.log('新city:', newCity) console.log('旧city:', oldCity) } )

// 同时侦听多个源,用数组包起来就行!这也是Vue3 watch比Vue2强的地方 // Vue2里只能分开写多个watch,或者watch一个computed返回值 watch( [() => user.name, () => user.age], ([newName, newAge], [oldName, oldAge]) => { console.log('name或age变化了') console.log('新值数组:', [newName, newAge]) console.log('旧值数组:', [oldName, oldAge]) } )

#### watchEffect的隐式指定:
watchEffect不用你手动写侦听源,它是“用啥盯啥”的——它会立刻执行一遍你传入的回调函数,在执行过程中,收集所有用到的响应式数据(不管是ref的.value,还是reactive的属性/深层属性),然后等这些收集到的数据**任意一个发生变化**,就重新执行回调函数。
这里也有两个新手必踩的坑:
第一个坑:**watchEffect只能收集同步执行时用到的响应式数据**,如果是异步代码里用到的,它不会盯!
举个例子:
```javascript
import { ref, watchEffect } from 'vue'
const count = ref(0)
const asyncData = ref('')
watchEffect(() => {
  // 同步用到count,会收集
  console.log('count同步用了:', count.value)
  // 异步(setTimeout)里用到asyncData,不会收集!
  setTimeout(() => {
    console.log('asyncData异步用了:', asyncData.value)
  }, 1000)
})
// 点这个按钮,count变了,watchEffect会重新跑
const changeCount = () => count.value++
// 点这个按钮,asyncData变了,但因为是异步里用的,watchEffect不会重新跑!
const changeAsyncData = () => asyncData.value = '新数据'

你可以试一下,点changeAsyncData,log里不会再出现watchEffect的第一条,更不会有asyncData的新log,那如果我确实需要在异步里用到响应式数据,同时想让数据变化时触发整个逻辑怎么办?这时候可以用watch,或者把异步逻辑拆出来,用watchEffect加nextTick?不对,nextTick是DOM更新后的钩子,这里更适合用watch显式侦听asyncData。

第二个坑:watchEffect的回调函数里,如果用到了非响应式数据,它不会有任何反应——这个其实不算坑,是正常逻辑,但新手容易搞混,比如把普通的number变量放进去,以为能侦听,结果不行。

触发时机的精确控制:flush选项

不管是watch还是watchEffect,都有个flush选项,用来控制回调函数的触发时机,默认值都是'pre',但也可以改成'post'或者'sync',这个细节官网提过,但很多新手从来没用过,甚至不知道有这个东西,直到遇到“数据变了但DOM没更新就执行回调”的问题,才到处找原因。

flush的三个值分别是什么意思?

  • 'pre'(默认):在组件更新之前触发回调函数,这时候,你拿到的响应式数据是最新的,但DOM还是旧的,如果在回调里直接操作DOM(比如获取某个元素的高度),拿到的还是数据变化前的高度。
  • 'post':在组件更新之后触发回调函数,这时候,响应式数据是最新的,DOM也是最新的,适合做依赖DOM的操作(比如获取高度、滚动位置、初始化第三方DOM库)。
  • 'sync':在响应式数据变化的瞬间就触发回调函数,同步执行,不会等待组件更新,这个要慎用!因为如果在很短的时间内多次修改同一个数据,'sync'会触发多次回调,而'pre'和'post'会把多次修改合并成一次更新,只触发一次回调,性能更好。

什么时候用哪个flush?给你几个具体场景:

  • 场景1:需要根据数据变化计算某个值,但不需要操作DOM→用默认的'pre'就行。
  • 场景2:数据变化后,要获取某个元素的最新宽度/高度→用'post',或者在'pre'里加nextTick(),效果是一样的,但加flush: 'post'更简洁。
  • 场景3:需要实现“实时响应,不能有延迟”的功能,比如拖拽的时候改变元素位置→这时候可以考虑'sync',但还是要注意性能,避免回调里做太重的操作。

实战案例:flush: 'post' vs nextTick()

<template>
  <div>
    <div ref="boxRef" :style="{ height: boxHeight + 'px', background: 'pink' }">
      我的高度是{{ boxHeight }}px
    </div>
    <button @click="boxHeight += 100">加高度</button>
    <p>默认flush='pre'里拿到的高度:{{ preHeight }}px</p>
    <p>flush='post'里拿到的高度:{{ postHeight }}px</p>
    <p>pre里加nextTick拿到的高度:{{ preNextTickHeight }}px</p>
  </div>
</template>
<script setup>
import { ref, watch, watchEffect, nextTick } from 'vue'
const boxHeight = ref(100)
const boxRef = ref(null)
const preHeight = ref(0)
const postHeight = ref(0)
const preNextTickHeight = ref(0)
// 默认flush='pre',组件更新前执行,DOM是旧的
watch(boxHeight, () => {
  preHeight.value = boxRef.value?.offsetHeight || 0
})
// flush='post',组件更新后执行,DOM是新的
watch(boxHeight, () => {
  postHeight.value = boxRef.value?.offsetHeight || 0
}, { flush: 'post' })
// flush='pre'里加nextTick,效果和'post'一样
watch(boxHeight, async () => {
  await nextTick()
  preNextTickHeight.value = boxRef.value?.offsetHeight || 0
})
</script>

把这段代码跑起来,第一次打开页面的时候,preHeight是0(因为boxRef还没挂载?哦不对,刚才忘了watch的immediate!如果加immediate的话,第一次打开preHeight会是0吗?对,因为默认flush='pre',immediate执行的时候,组件还没完成挂载后的更新?或者说组件刚挂载完DOM,但watch的immediate在'pre'阶段?等下你可以自己试一下加immediate的情况,不过不管加不加,点“加高度”按钮之后,preHeight永远是旧的100、200…,而postHeight和preNextTickHeight都是新的200、300…,这就能直观感受到flush的作用了。

回调函数的参数:有 vs 无(大部分情况)

watch的回调参数:

不管侦听源是单个还是多个,watch的回调函数都有两个固定参数:第一个是新值(newVal),第二个是旧值(oldVal),顺序和Vue2完全一样,这点可以放心,如果侦听源是数组,那newVal和oldVal也是对应的数组,顺序和侦听源的顺序一致。 刚才在讲显式指定reactive属性的时候已经用过参数了,这里就不再重复写案例了,但要再次强调那个新手必踩的坑:如果直接侦听reactive对象本身,newVal和oldVal永远是同一个对象引用,拿不到真正的旧值,如果需要旧值,必须用getter函数返回具体的属性(或者用JSON.parse(JSON.stringify())复制一份,但这个方法性能差,而且不能复制函数、Symbol、循环引用的对象,所以尽量用getter)。

watchEffect的回调参数:

watchEffect的回调函数默认没有参数,但它有一个可选的参数:onInvalidate函数,这个函数用来清理副作用,是个非常重要的东西,新手很容易忽略,导致内存泄漏或者重复请求的问题,后面讲“取消侦听和清理副作用”的时候会重点讲onInvalidate。

是否能取消侦听:都能,但用法差不多

不管是watch还是watchEffect,调用后都会返回一个停止函数(stop function),调用这个停止函数,就能取消对应的侦听,停止函数的用法是一样的。 什么时候需要取消侦听?举几个常见的场景:

  • 场景1:组件卸载的时候——不过这里有个好消息!Vue3会自动在组件卸载的时候,取消掉所有在setup()或者