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

Vue3 watch为什么经常要加deep:true?不加行不行?有什么坑?

terry 2小时前 阅读数 44 #Vue

最近在群里帮新手改Vue3代码,碰到十有八九的都是“明明数据变了页面也没更新watch没反应”,一问watch里的参数,果然要么直接传了个对象,要么只写了immediate忘了deep,今天就把这个大家天天挂嘴边但可能没完全摸透的点掰碎了讲,从底层响应式、适用场景、踩过的坑到不用deep的替代方案,全给你们安排明白。

先从根本搞懂:Vue3 watch的默认监听逻辑

要说清楚deep的作用,得先回忆下Vue3的响应式系统,别嫌麻烦,搞懂原理才不会只会死记硬背参数。

Vue3用Proxy替代了Vue2的Object.defineProperty做响应式拦截,Proxy是ES6原生的,能直接代理整个对象,不用像defineProperty那样循环遍历所有属性重写getter/setter,也能监听数组下标变更和对象新增/删除属性——这俩是Vue2老用户的噩梦吧?不过就算有Proxy这么强的工具,watch的默认行为还是“浅监听”,也就是只监听引用类型的地址变化,不监听内部属性/元素的变化

举个最简单的例子,你们肯定都见过这种代码:

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })
// 只写immediate不加deep
watch(user, (newVal, oldVal) => {
  console.log('user变了', newVal, oldVal)
}, { immediate: true })
// 测试1:修改整个ref.value的地址
const changeUser = () => {
  user.value = { name: '李四', age: 21 }
}
// 测试2:只修改name属性,地址不变
const changeName = () => {
  user.value.name = '王五'
}
</script>
<template>
  <button @click="changeUser">换整个用户</button>
  <button @click="changeName">只改名字</button>
</template>

你们猜点击哪个按钮会触发watch?只有第一个!因为changeUser把user.value这个对象的引用地址给换掉了,Proxy能捕捉到ref.value的赋值操作;但changeName只是动了对象内部的name属性,Proxy虽然也拦截到了(页面的name会变,因为模板里的插值是默认深度追踪的),但watch的默认逻辑是不管内部的,除非你显式告诉它“帮我盯着里面所有东西”——这时候deep:true就该出场了。

哦对了,补充个小细节:如果你们用的是reactive而不是ref定义对象,默认监听的也是浅吗?其实不一样!直接传reactive对象给watch,Vue3内部会自动给你开启deep!不信你们把刚才的ref改成reactive试试:

<script setup>
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('user变了', newVal, oldVal)
})
const changeName = () => {
  user.name = '王五'
}
</script>

这次点击changeName直接触发watch!那为啥新手还是经常忘加?因为新手可能刚开始分不清ref和reactive的区别,或者习惯了不管什么都用ref包一层,又或者监听的是ref数组的元素、嵌套对象的子属性——这些场景哪怕用reactive也得注意。

deep:true的适用场景,别瞎加!

虽然deep能解决大部分“不触发”的问题,但也不是万能药,更不是随便什么情况都要加的,瞎加deep可能会带来性能问题,这个后面会讲,先明确什么时候必须用deep:

  1. 用ref定义的引用类型,且只修改内部属性/元素不换地址 这个刚才举过例子了,ref包裹的引用类型,value本身是一个Proxy吗?不对不对,ref.value如果是对象/数组,Vue3会自动把它转换成reactive对象,也就是ref.value其实是个Proxy实例!那为啥直接传ref.user(哦不对是user.value?哦刚才传的是user本身)?哦等下传watch的第一个参数是user这个ref,而不是user.value,对吧?对哦这里是关键:如果第一个参数是ref引用,不管value是不是引用类型,默认都只监听.value的变更;如果第一个参数是函数返回的reactive属性,) => user.name,那就是精准监听那个属性;如果第一个参数是reactive对象本身,默认是deep的!这点很多文章没讲清楚,导致新手经常混淆。
  2. 需要监听嵌套非常深的对象/数组的任意层级变化 比如后台返回了一个包含10层嵌套的配置对象,你需要在这个配置的任何地方发生变化时,都保存到localStorage里,这时候一个个写函数返回子属性太麻烦,用deep:true就比较合适。
  3. 用ref定义的数组,且进行push/pop/splice等不改变数组地址的操作 刚才讲的是对象,数组其实同理:用ref定义的数组,默认只监听.value的替换,push进去新元素、splice删除/修改中间元素都不会触发watch,除非加deep;但如果是直接传reactive数组,这些操作都会默认触发。

不加deep行不行?当然行!这几个替代方案更高效

刚才说了,deep:true会带来性能损耗,特别是监听的对象/数组嵌套层级深、数据量大的时候,Vue3要递归遍历整个对象/数组,给每个属性/元素都加上临时的监听器(每次数据变化都会触发一次递归,监听器用完就销毁?不对不对,查过内部源码,其实是在调用watch时开启deep的话,会对整个被监听的Proxy进行深度依赖收集,之后只要内部任意被收集的依赖变化,就会触发回调,不过依赖收集本身如果数据量大的话,初始化和每次变更后的触发对比都会有开销),所以如果不需要监听所有层级的变化,尽量用替代方案:

替代方案1:精准监听某个/某几个子属性

如果只需要监听对象的name或者数组的第0个元素,直接用函数返回那个子属性就行,这样Vue3只会追踪这一个/几个属性的依赖,性能好很多。 比如刚才的例子,只改名字要触发的话:

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })
// 函数返回子属性,精准监听
watch(() => user.value.name, (newVal, oldVal) => {
  console.log('name变了', newVal, oldVal)
})
// 监听多个子属性,用数组
watch([() => user.value.name, () => user.value.age], ([newName, newAge], [oldName, oldAge]) => {
  console.log('name或age变了', newName, oldName, newAge, oldAge)
})
const changeName = () => {
  user.value.name = '王五'
}
const changeAge = () => {
  user.value.age = 25
}
</script>

这里不管user是ref还是reactive,函数返回子属性的方式都是通用的,而且精准,不会有多余的性能损耗。

替代方案2:用computed先做转换,再监听computed

有时候需要监听的不是单个属性,而是属性组合后的变化,比如监听user对象的name+age的拼接字符串,或者数组的长度变化,这时候可以先写个computed,再监听computed,同样比deep高效。 比如监听数组的长度:

<script setup>
import { ref, computed, watch } from 'vue'
const list = ref([1,2,3])
const listLength = computed(() => list.value.length)
// 只监听computed的返回值,数组内部元素变化(但长度不变的话)不会触发
watch(listLength, (newLen, oldLen) => {
  console.log('数组长度变了', newLen, oldLen)
})
const pushItem = () => {
  list.value.push(4) // 长度变了,触发
}
const changeItem = () => {
  list.value[0] = 99 // 长度不变,不触发
}
</script>

这个在处理分页、列表展示的时候特别有用,比如只有列表长度变化时才去更新滚动条位置或者分页组件的状态。

替代方案3:用watchEffect(注意watch和watchEffect的区别)

watchEffect和watch不一样,它不需要显式指定监听的源,会自动追踪回调函数里用到的所有响应式依赖,包括嵌套属性,那它和deep:true的watch有啥区别? watchEffect是立即执行的,相当于watch加了immediate:true;watchEffect只能拿到新值,拿不到旧值;如果只是要追踪几个嵌套属性的组合,watchEffect也会比deep:true的watch高效,因为它只会收集回调里实际用到的依赖,不会递归整个对象。 比如刚才的保存配置到localStorage的例子,如果只需要保存配置里的theme和fontSize这两个嵌套子属性,用watchEffect就行:

<script setup>
import { reactive, watchEffect } from 'vue'
const config = reactive({
  theme: 'light',
  fontSize: 14,
  other: { // 这里的嵌套属性不会被追踪
    autoSave: true,
    language: 'zh-CN'
  }
})
// 自动追踪回调里用到的config.theme和config.fontSize
watchEffect(() => {
  localStorage.setItem('appConfig', JSON.stringify({
    theme: config.theme,
    fontSize: config.fontSize
  }))
})
const changeTheme = () => {
  config.theme = 'dark' // 触发
}
const changeLanguage = () => {
  config.other.language = 'en-US' // 不会触发,因为没在watchEffect里用
}
</script>

替代方案4:手动替换整个引用

如果数据量不大,或者需要重置到某个状态,直接替换整个ref.value或者reactive的包裹对象(哦reactive不能直接替换,要用Object.assign或者ref包一层reactive?不对Object.assign是对的,因为Object.assign是浅拷贝,会改变原reactive对象的属性,但如果你想要完全替换引用,那最好还是用ref包一层),这样不需要加deep,而且能拿到完整的oldVal(这点加deep的watch经常做不到,后面讲坑的时候会说)。

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('user变了', newVal, oldVal) // 这里oldVal是完整的旧对象,newVal是新对象
})
const changeUser = () => {
  // 手动创建新对象替换引用
  user.value = {...user.value, name: '王五'}
}
</script>

手动替换引用的好处是能拿到完整的旧值,适合做对比或者回滚操作。

用deep:true时一定要避开这3个坑!

刚才说了适用场景和替代方案,现在讲讲新手甚至老鸟都可能踩的坑:

坑1:拿不到正确的oldVal

这个是最常见的坑!不管是用ref还是reactive,只要加了deep:true,watch回调里的oldVal引用地址和newVal是一样的!因为Vue3的响应式系统是“可变数据”的模式(虽然比Vue2的defineProperty灵活,但在oldVal这块还是有小缺点),当你修改内部属性时,oldVal其实是和newVal指向同一个对象/数组的,所以你打印出来的newVal和oldVal看起来完全一样,没法做对比。

<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: '张三', age: 20 })
watch(user, (newVal, oldVal) => {
  console.log('newVal:', newVal) // { name: '王五', age: 20 }
  console.log('oldVal:', oldVal) // 也是{ name: '王五', age: 20 }!
  console.log('newVal === oldVal:', newVal === oldVal) // true!
}, { deep: true })
const changeName = () => {
  user.value.name = '王五'
}
</script>

这时候如果要做对比,比如之前的name是不是张三,怎么办?可以用computed先保存旧值,或者用lodash的cloneDeep在watch回调执行前先把oldVal深拷贝一份存起来(不过深拷贝也有性能损耗,特别是数据量大的时候)。 比如用computed存旧值的思路:

<script setup>
import { ref, watch, computed, shallowRef } from 'vue'
const user = ref({ name: '张三', age: 20 })
// shallowRef只追踪.value的替换,不追踪内部属性,用来存旧值刚好,不会有多余的依赖
const oldUser = shallowRef({...user.value})
watch(user, (newVal) => {
  console.log('newVal:', newVal)
  console.log('oldVal:', oldUser.value)
  // 更新旧值
  oldUser.value = {...newVal}
}, { deep: true })
const changeName = () => {
  user.value.name = '王五'
}
</script>

这里用了shallowRef,因为只需要保存旧值的引用,不需要追踪旧值的内部变化,性能更好。

坑2:性能损耗严重

这个刚才提过,再强调一遍:如果监听的是一个有1000个元素的数组,每个元素又是一个有10个属性的对象,加了deep:true后,每次修改任何一个元素的任何一个属性,Vue3都要对比整个数组的所有属性变化(虽然有优化,但还是有开销),会导致页面卡顿,特别是在频繁修改数据的场景(比如搜索框实时输入、拖拽排序),这时候尽量用精准监听或者watchEffect替代。

坑3:监听函数返回的reactive对象时的行为不一致

刚才讲过,如果直接传reactive对象给watch,默认是deep的;但如果传的是函数返回的reactive对象,) => userReactive,那默认就是浅的,必须加deep:true才会监听内部变化!这点很多人容易搞混,一定要注意。

<script setup>
import { reactive, watch } from 'vue'
const user = reactive({ name: '张三', age: 20 })
// 直接传reactive对象,默认deep
watch(user, () => { console.log('直接传user触发') })
// 函数返回reactive对象,默认浅,不会触发内部变化
watch(() => user, () => { console.log('函数返回user触发') })
const changeName = () => {
  user.name = '王五' // 只有第一个watch触发
}
const changeUserRef = () => {
  // 哦reactive不能直接替换,所以这个操作不存在
}
</script>

为什么会有这种区别?因为如果直接传reactive对象,Vue3会判断源是Proxy实例,自动开启deep;但如果传的是函数,不管返回什么,默认都是浅的,只有显式加deep才会递归追踪返回值的内部变化。

如何正确使用Vue3 watch deep

最后给大家整理一个简单的判断流程,下次写watch的时候按这个来,肯定不会错:

  1. 首先确定要监听的是什么:
    • 单个/几个子属性 → 用函数返回子属性(精准监听,最高效)
    • 子属性组合后的变化 → 用computed转换后再监听(次高效)
    • 需要自动追踪回调里所有用到的依赖 → 用watchEffect(灵活,注意立即执行和拿不到旧值)
    • 数据量小且需要完全替换引用 → 手动替换(能拿到完整旧值)
  2. 只有当嵌套层级深、需要监听任意层级变化、且没有更合适的替代方案时,才加deep:true
  3. 加了deep:true后,注意拿不到正确oldVal的问题,提前用shallowRef或cloneDeep备份

Vue3的watch deep是个好用的工具,但绝对不是“万金油”,合理使用才能让代码既高效又易维护。

版权声明

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

热门