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

Vue3的watch和watchEffect到底怎么选?新手项目里踩过的坑都在这

terry 2天前 阅读数 272 #Vue

从一个实际踩坑场景切入

上周帮朋友改Vue3后台管理项目的表单联动时,发现他写了12个重复逻辑的watch,每个都监听差不多的输入框值,又触发相同的API校验请求——完全是没必要的冗余,朋友当时挠头说:“不知道watch和watchEffect该用哪个,看文档好像都能监听数据,就随便抓了个看起来最熟的watch硬堆。”

这可不是个例,很多刚转Vue3的开发者都有类似困惑:之前Vue2的watch用得好好的,怎么突然冒出来个watchEffect?它俩谁是“老大”?什么时候用数组监听多个值?immediate和deep到底要不要一起开?踩过的人都知道,这些小问题堆起来,项目要么卡要么有bug,今天就把我三年多Vue3项目里踩过的坑、整理的实用经验全掏出来,用问答形式讲明白,新手看完就能立刻上手。


别被名字绕晕!先搞懂watch和watchEffect的本质区别

核心问题1:为什么Vue3要新增watchEffect?

刚学Vue3的同学可能会想,Vue2的watch已经能覆盖大部分场景了,搞个新API不是添乱吗?其实不是,Vue3的核心是Composition API,它俩的设计思路完全不一样——Vue2的watch是“命令式+显式依赖”,Vue3的watch是“响应式+可选显式依赖”,而watchEffect是“自动式+隐式依赖追踪”

举个生活化的例子,比如你养了一只猫,每天晚上要喂它:

  • 用Vue2/watch的思路是:你每天定个21:00的闹钟(显式指定触发条件),闹钟响了就去拿粮碗喂猫(命令式操作);如果某天21:00没饿(不需要触发的特殊情况),你还得加个判断“今天有没有剩粮”“猫今天有没有生病”。
  • 用Vue3/watchEffect的思路是:你盯着猫的食盆(自动追踪依赖项),只要食盆空了就立刻去加粮(自动执行操作);而且如果猫后来换了自动喂食器替代了食盆,它会自动停止盯着旧食盆,换成盯喂食器的传感器(自动清理失效依赖)。

官方团队也是这么考虑的——Composition API讲究逻辑复用和函数式组织,有些场景比如表单输入的实时预校验、数据变更后的自动埋点、状态映射后的数据更新,不需要显式指定监听谁,只需要“用到谁就监听谁”,这时候watchEffect就比watch简洁太多,也不容易写错依赖项。

核心问题2:watch和watchEffect的触发机制、回调时机、返回值、依赖处理这四个维度有啥具体不同?

别光听抽象的例子,我们把这四个维度列出来对比,一目了然:

触发机制

  • watch:必须显式指定监听源(可以是单个响应式数据、多个响应式数据的数组、getter函数返回的值),只有当监听源的引用或者值(看监听源类型)发生变化时才会触发回调;而且默认是懒执行——页面刚加载时不会跑,得等监听源第一次变了才动。
  • watchEffect:不需要显式指定监听源,只要在回调函数里用到了任何响应式数据(ref/reactive/computed/甚至props传的reactive结构),就会自动把这些数据加为依赖;而且是非懒执行(同步执行)——页面刚挂载前(哦不对,是setup函数执行后、DOM挂载前的那个阶段?不对,官方说watchEffect的默认flush时机是'pre',也就是在组件更新前同步执行,setup里第一次调用是立即同步执行的,DOM可能还没渲染完)第一次调用就会跑一遍,先拿到初始的依赖关系。

回调时机

这个是很多老手也会踩的小坑,特别是涉及DOM操作的时候,两者都可以通过flush参数控制回调时机,但默认值不一样:

  • watch默认flush: 'pre':组件更新前触发,这时候DOM还没更新,你在回调里直接拿document.getElementById或者useRef的.value,拿到的是旧值;
  • watchEffect默认flush: 'pre':和watch一样,但watchEffect有个特殊的变体叫watchPostEffect,相当于watchEffect(..., { flush: 'post' }),组件更新后DOM渲染完了才触发;还有个watchSyncEffect,相当于flush: 'sync',依赖一变立刻就同步触发,不管组件有没有在更新周期里,这个尽量别用,容易造成死循环或者性能问题。

举个踩过的DOM操作坑例子:比如你监听一个list数组,数组变了之后要滚动到list的最后一项,如果用watch默认pre模式,你在回调里拿到listRef.value.lastElementChild,可能是空的或者是旧数组的最后一项;换成flush: 'post',或者用watchPostEffect就没问题了。

回调函数的参数

  • watch:回调函数有两个参数,第一个是newValue(监听源变化后的新值),第二个是oldValue(变化前的旧值);这个oldValue很有用,比如表单校验的时候,如果用户输入的内容和之前一模一样,就不用再发API请求了,直接比较oldValue和newValue就行。
  • watchEffect:回调函数只有一个参数,是cleanup清理函数;这个清理函数是做啥的?比如你在watchEffect里发了一个API请求,依赖突然变了(比如用户输入框从“苹果”改成“香蕉”才打了两个字就变了),这时候之前的“苹果”请求还没回来,就可能覆盖掉最新的“香蕉”请求结果——这时候用cleanup函数就能取消旧请求(用axios的CancelToken或者fetch的AbortController),避免竞态问题。

返回值和停止监听

  • 两者都返回一个停止监听的函数:比如const stopWatch = watch(..., ...),调用stopWatch()之后,这个监听就失效了;
  • watch需要手动停止(除非组件卸载自动停止):如果你的监听是在组件的setup里写的,组件卸载时Vue3会自动帮你清理,不用手动调用;但如果是在setTimeout、setInterval或者其他脱离组件生命周期的地方写的,必须手动停止,否则会内存泄漏;
  • watchEffect也是一样的自动清理规则:但要注意,如果你在watchEffect的回调里又调用了watchEffect,内层的watchEffect会自动和外层绑定,外层停止或者组件卸载时,内层也会一起停止。

新手最爱问的数组监听!watch list到底怎么写?

核心问题3:用watch监听多个值时,数组监听和单个监听的区别是什么?

很多新手刚看到watch支持数组作为监听源,就觉得“以后所有多个值的监听都用数组吧,省代码”,但其实不是——数组监听的触发逻辑和单个监听有细微差别,而且newValue和oldValue的返回值也不一样。

触发逻辑

  • 单个监听多个watch:每个监听源独立触发,比如你监听a和b两个ref,a变了只触发watchA的回调,b变了只触发watchB的回调,互不干扰;
  • 数组监听一个watch:只要数组里的任何一个监听源发生变化,都会触发同一个回调函数;比如监听[a, b],a变了触发,b变了也触发,a和b同时变了(比如在同一个nextTick里修改)也只触发一次(哦对了,Vue3的更新是批量的,同一个微任务里修改的多个响应式数据,只会触发一次组件更新,也只会触发一次数组监听的回调,这个是性能优化的点,不用担心频繁触发)。

newValue和oldValue的返回值

  • 单个监听:newValue和oldValue都是对应监听源的单个值;
  • 数组监听:newValue和oldValue都是数组,顺序和你传入的监听源数组一致;比如传入[a, b],newValue就是[newA, newB],oldValue就是[oldA, oldB]。

举个表单联动的例子,比如你有三个输入框:姓名、年龄、性别,只要其中任意一个变了,就要更新“用户基本信息预览”的内容,这时候用数组监听就比写三个单个watch简洁多了,预览内容只需要根据最新的三个值渲染就行,不用区分是谁变的。

核心问题4:用watch监听数组类型的响应式数据时,deep要不要开?什么时候开?

这是新手最容易踩的大坑之一!很多人不管三七二十一,只要监听的是数组或者对象,就把deep:true加上,结果项目里稍微改个数组的索引或者对象的属性,就触发一堆没必要的回调,导致页面卡顿甚至API请求泛滥。

首先得明确一个概念:Vue3的响应式系统是基于Proxy代理的,不是Vue2的Object.defineProperty了,这一点对监听数组和对象至关重要。

什么时候不需要开deep?

  • 监听的是ref包裹的数组/对象的引用本身的变化:比如你有个const list = ref([]),然后你赋值list.value = [1,2,3],或者list.value = [...list.value,4](这种是替换整个数组的引用,不是修改原数组),这时候不需要开deep,直接监听list就行,newValue和oldValue也能正常拿到;
  • 监听的是getter函数返回的数组的某个计算属性或者具体项:比如const firstItem = computed(() => list.value[0]),然后监听firstItem,也不需要开deep;
  • 监听的是reactive包裹的数组的直接属性或者数组本身的替换?不对,reactive包裹的数组不能直接替换引用,否则会失去响应式!哦对了,reactive的这个坑也要提一下:如果是reactive包裹的数组,你不能直接arr = [1,2,3],必须用arr.splice(0, arr.length, 1,2,3),或者arr.value?不,reactive不是ref,不需要.value,直接用arr.splice、arr.push、arr.pop这些数组方法,这些方法会被Proxy拦截,触发响应式更新。

什么时候必须开deep?

只有一种情况:你需要监听ref/reactive包裹的数组/对象的深层属性变化,而且又不想写具体的getter函数监听每一个深层属性,比如你有个const user = reactive({ name: '张三', address: { city: '北京', district: '朝阳区' } }),你需要监听address.city或者address.district的变化,这时候如果不想写watch(() => user.address.city, ...),就可以写watch(user, ..., { deep: true })。

但要注意!开了deep之后,watch会递归遍历整个数组/对象的所有属性,添加依赖,如果数组/对象很大,会消耗很多性能;而且newValue和oldValue的返回值也不一样了——如果监听的是整个ref/reactive对象/数组,开了deep之后,oldValue和newValue会是同一个引用(因为Vue3的Proxy是代理原对象的,深层属性变化时,原对象的引用没变,只是属性值变了),所以你没法用oldValue和newValue做比较来判断是不是真的需要执行后续操作,这时候最好还是写具体的getter函数监听需要的深层属性,或者在回调里加个手动比较的逻辑。


进阶场景:watch和watchEffect的最佳实践

核心问题5:埋点、预校验这种自动依赖的场景,为什么优先用watchEffect?

刚才开头提到的朋友的表单联动预校验,我后来给他改成了watchEffect,代码从12行冗余的watch变成了3行,我们来对比一下前后的代码:

朋友原来的冗余代码

// 12个重复逻辑的watch,只改了监听的ref和对应的字段名
const name = ref('')
const age = ref('')
const phone = ref('')
// ...省略其他9个ref
watch(name, async (newVal) => {
  if (!newVal) return
  const res = await checkFormField('name', newVal)
  nameError.value = res.msg
})
watch(age, async (newVal) => {
  if (!newVal) return
  const res = await checkFormField('age', newVal)
  ageError.value = res.msg
})
// ...省略其他10个watch

改成watchEffect后的简洁代码

// 用一个数组把所有需要校验的字段、ref、error ref存起来,然后用watchEffect自动遍历
const formFields = [
  { key: 'name', val: name, err: nameError },
  { key: 'age', val: age, err: ageError },
  { key: 'phone', val: phone, err: phoneError },
  // ...省略其他9个字段
]
watchPostEffect(async (onCleanup) => {
  // 用AbortController取消旧请求,避免竞态
  const controller = new AbortController()
  const signal = controller.signal
  // 遍历所有字段,有值的就发请求
  formFields.forEach(async (field) => {
    if (!field.val.value) {
      field.err.value = ''
      return
    }
    try {
      const res = await checkFormField(field.key, field.val.value, { signal })
      field.err.value = res.msg
    } catch (err) {
      if (err.name !== 'AbortError') {
        field.err.value = '校验失败,请稍后重试'
      }
    }
  })
  // 依赖变化时(比如用户快速输入),取消上一次的所有请求
  onCleanup(() => controller.abort())
})

不仅代码量少了三分之二,还加了之前朋友没考虑到的竞态问题处理——这就是watchEffect的优势:自动依赖追踪,不用手动维护监听源列表,逻辑复用性更强,适合处理批量的、自动依赖的操作

核心问题6:需要oldValue或者懒执行的场景,为什么必须用watch?

刚才说了watchEffect的优势,但它也有局限性——没有oldValue,默认非懒执行,这时候watch就派上用场了。

需要oldValue的场景

比如表单提交前的“是否有未保存的修改”提示:你需要记录表单的初始值,然后监听表单的当前值,当当前值和初始值不一样时,弹出“您有未保存的修改,确定要离开吗?”的提示,这时候oldValue就没用?哦不对,不是oldValue,是要和初始值比,但如果是需要和上一次的值比的场景呢?比如统计用户输入的字数变化率:比如用户上次输入了10个字,这次输入了20个字,变化率是100%,这时候就必须用watch的newValue和oldValue了。

需要懒执行的场景

比如电商网站的“商品搜索联想词”:你只需要在用户输入完停顿300ms(防抖)之后才发API请求,而且页面刚加载时不需要自动请求空的联想词,这时候就必须用watch的懒执行模式,再加个防抖函数,如果用watchEffect,页面刚加载时就会发一次空请求,浪费服务器资源。


避坑指南:这些Vue3 watch list的坑你一定要避开

避坑1:不要监听reactive包裹的数组的直接替换

刚才提到过,reactive包裹的数组不能直接替换引用,否则会失去响应式:

// ❌ 错误写法:直接替换引用,失去响应式
const arr = reactive([1,2,3])
setTimeout(() => {
  arr = [4,5,6] // 这里的arr已经不是原来的Proxy代理对象了,不会触发更新
}, 1000)
// ✅ 正确写法1:用splice替换
setTimeout(() => {
  arr.splice(0, arr.length, 4,5,6)
}, 1000)
// ✅ 正确写法2:把reactive改成ref
const arr = ref([1,2,3])
setTimeout(() => {
  arr.value = [4,5,6] // ref包裹的可以直接替换引用
}, 1000)

避坑2:不要随便开deep,尽量用getter函数监听具体属性

刚才也提到过,开deep会消耗性能,而且oldValue和newValue会是同一个引用:

// ❌ 错误写法:随便开deep
const user = reactive({ name: '张三', address: { city: '北京' } })
watch(user, () => {
  console.log('user变了')
}, { deep: true })
// ✅ 正确写法:用getter函数监听具体属性
watch(() => user.address.city, (newVal) => {
  console.log('city变了:', newVal)
})

避坑3:不要忘了用cleanup函数处理竞态问题

不管是watch还是watchEffect,只要涉及异步操作(API请求、定时器、事件监听),都要记得用cleanup函数清理旧的异步操作,避免竞态问题:

// ✅ watch的cleanup函数写法
watch(searchText, async (newVal, oldVal, onCleanup) => {
  const controller = new AbortController()
  const timer = setTimeout(async () => {
    try {
      const res = await getSuggestions(newVal, { signal: controller.signal })
      suggestions.value = res.data
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error(err)
      }
    }
  }, 300)
  onCleanup(() => {
    controller.abort()
    clearTimeout(timer)
  })
})

避坑4:不要在watch/watchEffect的回调里直接修改监听源,避免死循环

比如你监听了count,然后在回调里又count.value++,这会导致count一直变,一直触发回调,死循环:

// ❌ 错误写法:死循环
const count = ref(0)
watch(count, () => {
  count.value++
})
// ✅ 正确写法:加个判断条件,只有在特定情况下才修改监听源
watch(count, (newVal) => {
  if (newVal < 10) {
    count.value++
  }
})

一句话记住选哪个

其实不用记那么多复杂的对比,一句话就能搞定:需要oldValue、需要懒执行、需要独立控制多个监听源的触发,选watch;需要自动依赖追踪、需要批量操作、需要自动清理失效依赖,选watchEffect

最后再提醒一句,不管选哪个,都要注意避坑:不要随便开deep,不要忘了清理异步操作,不要直接修改监听源,不要替换reactive包裹的数组的引用。

(全文约2870字)

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门