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

Vue3开发搞不清楚watch和watchEffect?看完这篇直接选对工具不踩坑

terry 4小时前 阅读数 52 #Vue

你是不是刚从Vue2转Vue3,看到新增的watchEffect一脸懵——这不就是自动追踪依赖的watch吗?或者用了很久Vue3,但有时候选watch选了watchEffect,又有时候反过来,总觉得代码不够顺、性能有点小问题?其实很多开发者都踩过这个“相似但不同”的坑,今天咱们就从底层原理、实际场景、代码对比、性能细节这几个方向掰开揉碎讲,保证你下次敲代码不用犹豫,直接点兵点将选对工具。

先搞懂它们的根本身份:都是响应式副作用,但触发方式不一样

首先得回忆下什么是“响应式副作用”——简单说就是依赖响应式数据,并且会在数据变化时自动执行的代码块,比如在数据更新后发个请求、改个DOM属性(虽然Vue3推荐用模板绑定,但有些第三方库只能手动改)、存个localStorage这些,都是典型的响应式副作用场景。

那watch和watchEffect最大的区别是什么?别听网上说的“一个懒一个勤”——这只是表面现象,根本区别是:依赖追踪的方式、副作用执行的时机控制、数据变化时获取新旧值的能力,这三点搞透,后面所有的问题都迎刃而解。

咱们先从最直观的代码对比入手,建立第一印象。

代码初接触:用两个最常见的场景对比,一眼看出用法差异

就拿大家天天写的「用户名搜索防抖」和「保存用户实时输入到localStorage」这两个场景来说吧。

场景1:保存用户输入到localStorage,哪种写法更爽?

假设我们有个登录页的用户名输入框,需要用户每输入一个字就存到localStorage里,下次打开页面自动回填。

用watch写

<script setup>
import { ref, watch } from 'vue'
const username = ref(localStorage.getItem('saved_username') || '')
// 首先要**明确指定要监听的响应式变量**
// 这里直接传ref本身,不用.value
watch(username, (newVal) => {
  localStorage.setItem('saved_username', newVal)
})
// 哦对了!刚进入页面的时候,watch是不会自动执行的
// 要回填?要么单独加一行,要么加个immediate选项
watch(username, (newVal) => {
  localStorage.setItem('saved_username', newVal)
}, {
  immediate: true
})
</script>
<template>
  <input v-model="username" placeholder="请输入用户名" />
</template>

用watchEffect写

<script setup>
import { ref, watchEffect } from 'vue'
const username = ref(localStorage.getItem('saved_username') || '')
// watchEffect不用指定监听谁!
// 它会**自动执行一遍函数内部的代码,然后追踪里面用到的所有响应式变量**
// 下次这些变量变了,再自动执行
watchEffect(() => {
  // 这里用到了username.value,所以它就会被追踪
  localStorage.setItem('saved_username', username.value)
})
</script>
<template>
  <input v-model="username" placeholder="请输入用户名" />
</template>

哦?这个场景下watchEffect好像更短更爽?不用指定依赖,不用加immediate,自动处理刚进入页面的情况,那再看第二个场景。

场景2:用户名搜索防抖(要求只在输入结束1秒后发请求,并且要知道旧的搜索词)

假设现在输入框变成了搜索框,只有当用户停止输入1秒后才调用搜索接口,而且如果新搜索词和旧搜索词一样的话,就不发请求了。

用watch写

<script setup>
import { ref, watch } from 'vue'
import { debounce } from 'lodash-es' // 这里用lodash的防抖,或者自己写一个
const searchKeyword = ref('')
const searchResults = ref([])
// 明确指定监听searchKeyword
// 有了oldVal和newVal,就可以做新旧值对比优化
// debounce放在这里?不对不对!watch的回调每次都是新创建的,debounce放在里面会失效
// 要把debounce后的函数单独拿出来
const handleSearch = debounce(async (newVal) => {
  if (!newVal.trim()) {
    searchResults.value = []
    return
  }
  // 模拟接口请求
  const res = await fetch(`/api/search?keyword=${newVal}`)
  searchResults.value = await res.json()
}, 1000)
watch(searchKeyword, (newVal, oldVal) => {
  // 新搜索词和旧搜索词完全一样(包括空值),不发请求
  if (newVal === oldVal) return
  handleSearch(newVal)
})
</script>
<template>
  <input v-model="searchKeyword" placeholder="搜索内容..." />
  <div v-if="searchResults.length">
    <ul>
      <li v-for="item in searchResults" :key="item.id">{{ item.title }}</li>
    </ul>
  </div>
</template>

这个场景下watch就很顺手了,对吧?新旧值对比直接做,debounce也好控制,那用watchEffect能不能写呢?能,但会麻烦很多。

用watchEffect硬写这个场景

<script setup>
import { ref, watchEffect, onScopeDispose } from 'vue'
import { debounce } from 'lodash-es'
const searchKeyword = ref('')
const searchResults = ref([])
// 这里要手动存旧值!
let oldSearchVal = ''
// 要手动控制debounce的取消?因为watchEffect重新执行的话,之前的debounce函数还在计时
// 得用闭包或者ref存起来
const debouncedSearchRef = ref(null)
const stopWatchEffect = watchEffect((onCleanup) => {
  const newVal = searchKeyword.value
  // 手动做新旧值对比
  if (newVal === oldSearchVal) return
  // 旧值要在这里更新,不是在debounce里
  oldSearchVal = newVal
  // 先取消上一次的debounce
  onCleanup(() => {
    debouncedSearchRef.value?.cancel()
  })
  if (!newVal.trim()) {
    searchResults.value = []
    return
  }
  debouncedSearchRef.value = debounce(async () => {
    const res = await fetch(`/api/search?keyword=${newVal}`)
    searchResults.value = await res.json()
  }, 1000)
  debouncedSearchRef.value()
})
// 组件卸载的时候要手动取消watchEffect吗?其实setup里的不用,但取消debounce是需要的
onScopeDispose(() => {
  stopWatchEffect()
  debouncedSearchRef.value?.cancel()
})
</script>

我的天,这代码量翻了一倍不止,还要手动存旧值、手动管理debounce的取消、手动处理清理函数,这就完全没必要了,对吧?所以没有最好的工具,只有最适合的场景

深入底层原理:为什么用法和场景差这么多?

刚才只是看了表面的代码和场景,现在咱们挖挖Vue3的响应式原理(浅挖就行,不用到Proxy的get/set/deleteProperty那种太细的地步),搞懂为什么它们会有这些差异。

Vue3的依赖追踪和副作用触发机制是什么?

Vue3用的是Proxy劫持+WeakMap+Reflect的响应式系统(比Vue2的Object.defineProperty强多了,支持数组索引、对象属性增删等),简单说就是:

  1. 当你用ref/reactive创建响应式数据的时候,Vue会给这些数据建立一个“依赖收集器”(用WeakMap存,防止内存泄漏);
  2. 当有代码读取这个响应式数据的时候(比如在模板里绑定、在watch/watchEffect的函数里用到.value/直接用reactive的属性),Vue就会把这段代码对应的“副作用函数”扔进依赖收集器里;
  3. 当这个响应式数据更新的时候,Vue就会遍历依赖收集器里的所有副作用函数,依次触发它们。

那watch和watchEffect的副作用函数有什么不一样?

watch的底层原理:手动指定依赖+回调式触发

watch其实可以理解成Vue2 watch的“升级版+多态版”(支持监听单个ref、单个reactive属性、数组、getter函数),它的工作流程是这样的:

  1. 手动告诉watch要监听哪些响应式数据(传ref、reactive、数组或者getter函数);
  2. Vue会先给这些数据做“快照”(但不会立即执行回调);
  3. 然后Vue会监听这些数据的变化通知,只有当变化发生时,才会重新做快照,拿到oldVal和newVal,再执行你写的回调函数;
  4. 如果你加了immediate: true,那Vue会在初始化的时候先执行一次回调,用undefined或者初始值当oldVal;
  5. 如果你加了deep: true,那Vue会递归监听reactive对象内部的所有属性变化(这里要注意性能问题,后面会讲)。

注意关键点:watch的回调函数里,哪怕你用到了其他响应式数据,这些数据也不会被watch追踪!因为watch是“手动指定依赖制”,不是“自动读取追踪制”,比如刚才的场景1,如果我在watch的回调里加了一个无关的响应式变量theme,但没在watch的第一个参数里加它,那theme变了,watch的回调是不会执行的。

watchEffect的底层原理:自动读取依赖+立即式触发+内置清理机制

watchEffect是Vue3全新设计的一个API,它的工作流程完全不一样,更“智能化”但也“更自由散漫”:

  1. 你不用告诉它要监听谁,只需要写一个副作用函数
  2. Vue会立即执行一次这个副作用函数(这就是为什么它“看起来很勤”);
  3. 在执行的过程中,Vue会开启一个“依赖追踪开关”,函数里每读取一个响应式数据,就会把这个数据扔进watchEffect的依赖收集器里;
  4. 等第一次执行完,“依赖追踪开关”关闭,但依赖收集器已经保存好了所有用到的响应式数据;
  5. 下次这些数据里任何一个更新,Vue都会重新执行整个副作用函数(同时再次开启依赖追踪开关,动态更新依赖收集器——这点很重要!后面会讲动态依赖的场景);
  6. watchEffect还内置了一个onCleanup清理函数,每次重新执行副作用函数之前,或者组件卸载之前,都会先执行这个清理函数(比如取消上一次的接口请求、取消定时器、断开WebSocket连接这些,都可以放在这里)。

对比关键点:watchEffect是“自动读取追踪制+动态依赖更新制”,而且回调函数里没有oldVal和newVal——因为它每次都是重新执行整个函数,Vue根本不知道你具体想对比哪个数据的变化。

5个核心对比维度,彻底把它们分清楚

刚才的底层原理可能有点抽象,现在咱们用5个最常用的、最容易踩坑的对比维度,列成一个清晰的对比(不过为了不被说套路化,咱们用段落分开讲,最后再给一个极简速查表)。

维度1:是否需要手动指定依赖?

这是最直观的区别:

  • watch:必须手动指定!第一个参数可以是单个ref、单个reactive的某个属性(用getter函数,比如() => user.name,直接传user.name是没用的,因为那是个普通值)、多个响应式数据组成的数组(比如[username, () => user.age])、或者一个返回任意值的getter函数(Vue会监听getter函数返回值的变化);
  • watchEffect:完全不需要!自动追踪函数内部第一次执行时读取到的所有响应式数据,而且下次执行时还会动态更新依赖——比如第一次执行时用到了ab,后来a变成了false,函数里不再用到b了,那下次b更新,watchEffect就不会执行了;反之亦然。

动态依赖的watchEffect场景演示

比如我们有个开关,控制是否显示用户的详细信息:

<script setup>
import { ref, watchEffect } from 'vue'
const showDetail = ref(false)
const userName = ref('张三')
const userAge = ref(25)
watchEffect(() => {
  console.log('watchEffect执行了')
  if (showDetail.value) {
    console.log(`用户信息:${userName.value},${userAge.value}岁`)
  } else {
    console.log(`用户名:${userName.value}`)
  }
})
// 我们模拟一下操作:
// 1. 刚进入页面:showDetail是false,用到了showDetail.value和userName.value,打印「watchEffect执行了」和「用户名:张三」
// 2. 点击开关showDetail.value = true:用到了showDetail.value、userName.value、userAge.value,打印「watchEffect执行了」和「用户信息:张三,25岁」
// 3. 修改userAge.value = 26:用到了userAge.value,打印「watchEffect执行了」和「用户信息:张三,26岁」
// 4. 点击开关showDetail.value = false:用到了showDetail.value和userName.value,打印「watchEffect执行了」和「用户名:张三」
// 5. 修改userAge.value = 27:现在函数里没用到userAge.value了,所以watchEffect**不会执行**!
</script>

这个动态依赖的功能是watch做不到的——watch只能监听你一开始指定的依赖,不管后面用不用得到,比如如果用watch监听[showDetail, userName, userAge],那第5步修改userAge的时候,watch的回调还是会执行,哪怕你根本用不到它。

维度2:是否能获取新旧值?

这也是一个非常核心的场景区别:

  • watch:完全可以!回调函数的第一个参数是newVal(新值),第二个参数是oldVal(旧值),注意几个特殊情况:
    1. 如果监听的是单个ref/普通值的getter,那oldVal和newVal都是对应的具体值;
    2. 如果监听的是整个reactive对象(比如watch(user, ...)),那oldVal和newVal都是同一个对象的引用!因为Proxy劫持的是对象本身,修改对象属性不会改变对象的引用,所以你根本对比不出变化——这时候如果需要对比新旧对象,要么加deep: true(但新旧值还是同一个引用,除非你手动深拷贝),要么用getter函数监听具体的属性;
    3. 如果监听的是多个响应式数据组成的数组,那oldVal和newVal也是对应的数组,顺序和监听的一致;
    4. 如果加了immediate: true,第一次执行回调时,oldVal是undefined(如果监听的是单个值)或者对应数量的undefined数组(如果监听的是多个值)。
  • watchEffect完全没有!因为它每次都是重新执行整个函数,Vue不会记录之前每个依赖的值是什么——你只能自己手动在函数内部用变量存旧值,就像刚才硬写搜索防抖那个场景一样,非常麻烦。

维度3:初始化时是否自动执行?

这就是网上说的“懒”和“勤”:

  • watch:默认是懒执行!初始化时不会执行回调,只有当监听的依赖发生变化时才会执行,如果需要初始化时执行,必须加immediate: true选项;
  • watchEffect:默认是立即执行!初始化时一定会执行一次副作用函数,用来收集依赖,有没有办法让它懒执行?有!Vue3.2+新增了一个watchPostEffect?不对不对,那是调整执行时机的,哦对了!是用flush选项配合onMounted?不对,Vue3.4+新增了一个lazy: true选项!对,没错,watchEffect(() => {}, { lazy: true }),这样初始化时就不会执行了,只有当依赖变化时才会第一次执行——不过这个功能用得不多,因为真的需要懒执行的话,直接用watch就行。

维度4:是否支持deep深度监听?

深度监听就是监听reactive对象内部所有属性的变化,不管嵌套多少层:

  • watch:支持!加deep: true选项就行,但这里要注意性能问题——Vue会递归遍历整个reactive对象的所有属性,给每个属性都建立依赖收集器,如果你监听的是一个非常大的对象(比如包含1000个商品的列表),那性能会非常差,甚至可能导致页面卡顿,所以除非万不得已,不要随便加deep: true,尽量用getter函数监听具体的属性;
  • watchEffect默认就是“隐式的深度监听”!但不是你想的那样——watchEffect不会递归给对象的所有属性建立依赖收集器,它只会监听你在函数内部读取到的那些属性,比如你有个user: { name: '张三', info: { age: 25 } },如果在watchEffect里只用到了user.name,那只有user.name变化时才会执行;如果用到了user.info.age,那只有user.info.age变化时才会执行;但如果你直接用到了user本身(比如console.log(user)),那Vue会读取整个user对象的所有属性吗?不会!只会读取user的引用,所以只有当user的引用本身变化时(比如user.value = { name: '李四' },而不是user.value.name = '李四'),watchEffect才会执行。

维度5:执行时机flush选项有什么不一样?

flush选项是用来控制副作用函数在Vue响应式更新周期的哪个阶段执行的,有三个值:'pre'(默认)、'post''sync',不过watch和watchEffect的flush选项虽然值一样,但默认值有一点点细微的差别,而且watchEffect的watchPostEffectwatchSyncEffect是它的语法糖。

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

咱们先统一讲一下这三个值的含义,不管是watch还是watchEffect,含义都是一样的:

  1. 'pre'(默认,除了watchPostEffect):在组件更新之前执行,这时候DOM还没更新,你可以在这里修改一些响应式数据,避免额外的组件更新;
  2. 'post'(watchPostEffect的默认值):在组件更新之后执行,这时候DOM已经更新完成了,你可以在这里操作DOM(比如获取某个元素的宽高、滚动位置,或者初始化第三方UI库);
  3. 'sync'(watchSyncEffect的默认值):同步执行,也就是在响应式数据变化的同一时刻立即执行,不等待Vue的更新周期,这个选项非常危险!因为它可能会导致依赖收集混乱、多次触发组件更新、甚至死循环,除非你非常清楚自己在做什么,否则不要用。

watch和watchEffect的flush选项默认值有什么不一样?

  • watch:默认flush: 'pre'
  • watchEffect:默认flush: 'pre'
  • watchPostEffect:语法糖,等价于watchEffect(() => {}, { flush: 'post' })
  • watchSyncEffect:语法糖,等价于watchEffect(() => {}, { flush: 'sync' })

flush选项的场景演示:获取DOM的宽高

假设我们有个div,需要在它的内容变化时,获取它的最新宽高:

<script setup>
import { ref, watch, watchEffect, watchPostEffect, nextTick } from 'vue'
const content = ref('初始内容')
const divRef = ref(null)
// 用watch + flush: 'post'
watch(content, () => {
  console.log('watch post:', divRef.value.offsetWidth, divRef.value.offsetHeight)
}, {
  flush: 'post',
  immediate: true
})
// 用watchPostEffect(更简洁)
watchPostEffect(() => {
  // 这里要读取content.value,否则content变化时不会触发
  // 或者读取divRef.value的某个属性?不对,divRef本身是ref,但它的value是DOM元素,不是响应式的(除非用v-memo或者特殊的响应式绑定,但一般不用)
  // 所以必须手动读取content.value,让watchPostEffect追踪它
  console.log('content变化了,watchPostEffect执行')
  console.log('watchPostEffect:', divRef.value.offsetWidth, divRef.value.offsetHeight)
})
// 用watch + flush: 'pre'(默认),会获取到旧的宽高!
watch(content, () => {
  console.log('watch pre(默认):', divRef.value.offsetWidth, divRef.value.offsetHeight)
}, {
  immediate: true
})
// 用watch + nextTick,和flush: 'post'效果一样,但更啰嗦
watch(content, async () => {
  await nextTick()
  console.log('watch pre + nextTick:', divRef.value.offsetWidth, divRef.value.offsetHeight)
}, {
  immediate: true
})
</script>
<template>
  <button @click="content = content + ',新增内容'">新增内容</button>
  <div ref="divRef" style="border: 1px solid #ccc; padding: 10px;">{{ content }}</div>
</template>

你可以把这段代码复制到Vue SFC Playground里跑一下,看看控制台的输出顺序和数值——watchPostEffect确实是最简洁的获取最新DOM的方式。

极简速查表:忘记刚才的长篇大论,看这个就行

讲了这么多,怕你记不住,给你列一个极简的速查表,下次敲代码前扫一眼就行:

对比维度 watch watchEffect(含语法糖)
依赖指定方式 必须手动指定 自动读取+动态更新
获取新旧值 ✅ 支持(newVal, oldVal) ❌ 不支持(需手动存)
初始化执行 ❌ 默认懒(加immediate: true✅) ✅ 默认立即(加lazy: true✅3.4+)
深度监听 ✅ 加deep: true(注意性能) ✅ 隐式(仅监听读取到的属性)
DOM操作最佳实践 flush: 'post' + nextTick watchPostEffect(最简洁)
最佳场景 需要新旧值、需要手动控制依赖、需要deep监听具体属性组合 需要自动依赖、需要动态依赖、需要立即执行、需要清理机制、需要操作DOM

避坑指南:90%的开发者都会踩的5个坑

讲了这么多用法和场景,现在咱们说说避坑指南——这些坑我自己踩过,也见过很多同事踩过,希望你能避开。

坑1:watch监听reactive对象时,直接传属性值而不是getter函数

错误写法:

<script setup>
import { ref, reactive, watch } from 'vue'
const user = reactive({ name: '张三' })
// 错!user.name是个普通字符串,不是响应式的,所以watch根本不会监听任何东西
watch(user.name, (newVal) => {
  console.log(newVal)
})
</script>

正确写法:用getter函数包裹属性值

<script setup>
import { ref, reactive, watch } from 'vue'
const user = reactive({ name: '张三' })
// 对!getter函数每次执行都会返回最新的user.name,Vue会监听getter函数返回值的变化
watch(() => user.name, (newVal) => {
  console.log(newVal)
})
</script>

坑2:watch监听整个reactive对象,以为能拿到新旧值的差异

错误写法:

<script setup>
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 25 })
// 错!oldVal和newVal是同一个对象的引用,根本对比不出变化
watch(user, (newVal, oldVal) => {
  console.log(newVal === oldVal) // 永远输出true!
})
</script>

正确写法1:用getter函数监听具体的属性

<script setup>
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 25 })
watch(() => user.name, (newVal, oldVal) => {
  console.log(newVal, oldVal) // 能拿到正确的新旧值
})
</script>

正确写法2:如果必须监听整个对象,加deep: true并手动深拷贝

<script setup>
import { reactive, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
const user = reactive({ name: '张三', age: 25 })
// 先手动存一个初始的深拷贝旧值
let oldUser = cloneDeep(user)
watch(user, (newVal) => {
  console.log('旧值:', oldUser)
  console.log('新值:', newVal)
  // 更新旧值
  oldUser = cloneDeep(newVal)
}, {
  deep: true
})
</script>

不过这个写法性能很差,除非万不得已,不要用。

坑3:watchEffect里忘记读取响应式数据,导致依赖没有被追踪

错误写法:

<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
// 错!这里根本没有用到count.value,所以count变化时watchEffect不会执行
watchEffect(() => {
  console.log('count是多少?')
})
</script>

正确写法:必须在函数内部读取到响应式数据

<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
  console.log('count是:', count.value) // 用到了count.value,依赖被追踪了
})
</script>

还有一种情况:在watchEffect里调用了一个外部函数,外部函数里用到了响应式数据,但外部函数不是在watchEffect的第一次执行时被调用的——这时候依赖也不会被追踪,所以尽量把用到响应式数据的代码直接写在watchEffect的函数内部,或者确保外部函数在第一次执行时就被调用。

坑4:在watchEffect里修改被追踪的响应式数据,导致死循环

错误写法:

<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
// 错!这里用到了count.value,然后又修改了count.value,导致无限循环执行
watchEffect(() => {
  console.log(count.value)
  count.value++
})
</script>

这个死循环会一直跑下去,直到浏览器崩溃——所以千万不要在watch/watchEffect的副作用函数里修改被当前副作用函数追踪的响应式数据!如果必须修改,要么用条件判断限制次数,要么用另一个响应式变量作为开关,要么把修改的逻辑放在其他地方。

坑5:随便加deep: true,导致性能下降

刚才已经提过很多次了,这里再强调一遍:deep: true的性能成本非常高! 如果你监听的是一个包含100个元素的数组,每个元素又是一个包含10个属性的对象,那Vue会递归给1000个属性建立依赖收集器,每次任何一个属性变化,都会触发watch的回调——这会极大地消耗浏览器的CPU和内存,导致页面卡顿、响应变慢。

所以除非万不得已,不要随便加deep: true,尽量用getter函数监听具体的属性组合,或者用watchEffect只监听你需要用到的属性。

记住三句话,永远不会选错

给你总结三句话,记住这三句话,下次敲代码不用看速查表,直接选对工具:

  1. 需要新旧值、需要手动控制依赖、需要懒执行——选watch;
  2. 需要自动依赖、需要动态依赖、需要立即执行、需要操作DOM、需要清理机制——选watchEffect(操作DOM用watchPostEffect);
  3. 不确定选哪个?先想能不能用watchEffect,不能用再用watch——因为watchEffect更简洁,代码量更少,而且动态依赖的功能有时候会给你惊喜。

好啦,今天的内容就到这里啦!如果你还有什么疑问,或者有其他Vue3的问题,欢迎在评论区留言哦~

版权声明

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

热门