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

先搞懂两个前置知识点,Vue3的响应式原理和watch的默认监听逻辑

terry 2小时前 阅读数 33 #Vue

Vue3里用watch监听reactive对象的时候,到底踩过哪些坑又该怎么避?

昨天帮同事调bug,他卡了快两小时,最后发现就是Vue3 watch监听reactive踩的常见雷——直接赋值reactive整个变量失效,回来之后翻了翻最近的技术群聊,发现不管是刚学Vue3转过来的Vue2老手,还是纯新手,对这块的疑惑特别多,比如为什么watch监听reactive的属性有时候要加deep有时候不用,为什么有时候新值旧值一样,能不能直接监听某个嵌套属性不用写嵌套字符串,甚至还有用watchEffect代替watch监听reactive到底好不好的讨论,今天刚好整理整理,把这些问题串成一篇实用的内容,大家按需看就行。 很多人踩坑其实是没把这俩东西结合起来理解,只会背“Vue3用Proxy代理数据”“reactive是对象型响应式函数”“ref是基础型还能包对象”,但背完不知道什么时候会触发哪个,什么时候watch能感应到变化。

reactive到底代理了什么?不是说你随便传个对象进去就行?不对,普通对象可以,但数组、Date这些内置对象的话,Vue3的Proxy只会代理“可枚举且可配置的自有属性”,而且内置对象的特殊方法,比如数组的push splice,Vue3会做特殊处理保证它们触发响应式,但如果直接修改数组的length属性(比如arr.length = 0)或者直接替换数组里的引用对象之外的原始值数组元素?等下原始值数组也有说法,举个例子:

import { reactive } from 'vue'
const arr = reactive([1, 2, 3])
arr[0] = 'a' // 这个其实Vue3 Proxy也能检测到?不对不对,等下别混Vue2的defineProperty,Vue2是不能检测数组索引赋值和length修改,Vue3可以,但有一个前提?好像没有?等下对,Proxy是针对整个对象(包括数组这个特殊对象)的拦截,所以不管是属性赋值、索引修改、length变更,只要是对代理对象本身的操作,都能触发拦截,那为什么很多人说索引赋值不生效?哦可能是用ref包数组的时候,不小心用了.value的某个属性?或者是直接给reactive整个重新赋值?哦对,那个是第一个大坑,后面会讲。
然后watch的默认监听逻辑,watch默认是**浅监听**——这里的浅监听和ref的深浅、reactive的深层属性不一样,要注意区分,浅监听的意思是,只有当你监听的“目标引用”发生变化的时候,才会触发回调,什么叫目标引用?比如你监听的是ref(某个对象).value,那目标引用就是这个对象的地址;如果你直接监听reactive的整个对象,那目标引用就是代理对象本身的地址;如果你监听的是reactive对象的某个属性(不管是用函数返回,还是用字符串嵌套,还是点出来?哦点出来的话看情况,比如监听person.name,person是reactive,那如果name是基础类型,目标引用就是name的值?不对等下得说回具体的API调用方式,watch的第一个参数可以有好几种写法,写法不同,默认监听逻辑不一样。
哦对了,这里补个权威点的说法,但不标来源:watch的第一个参数如果是响应式对象(比如直接传reactive出来的person),那Vue会自动开启深度监听,并且回调里的新值旧值永远是同一个代理对象——这个是Vue3的一个固定规则?不对是固定行为,但为什么?先记下来,后面详细说踩坑案例的时候会解释原理。
# 第一个高频踩坑:直接给reactive对象整个重新赋值,结果组件完全不更新
同事昨天的bug就是这个,他做了个用户列表的下拉刷新,一开始用reactive定义了list:
```javascript
const userList = reactive([])
// 然后刷新的时候从接口拿到新数据newList,直接写了
userList = newList

结果组件里的ul列表一点反应都没有,console.log(userList)打印的是新数据,但是页面纹丝不动,他一开始以为是接口没拿到数据,加了log接口没问题;然后以为是组件渲染有问题,用了Vue Devtools去看,发现刷新之后userList直接从响应式数据变成了普通的JavaScript数组——哦对!这就是问题所在。

你想啊,reactive返回的是一个代理对象的引用,你一开始把这个引用赋给了userList变量,这个时候userList指向的是代理对象,所以Vue能追踪到它的变化,但是当你写userList = newList的时候,你是把userList变量的指向改成了普通数组newList的引用,原来的代理对象还在内存里,但没人指向它了,而组件现在绑定的是哪个?是原来的代理对象吗?不,组件在模板里绑定的是userList这个变量?不对不对,变量在JavaScript里是容器,组件在编译的时候,会把绑定的表达式编译成“读取当前这个变量指向的对象的响应式值”——哦更准确地说,当你在setup里return了userList,组件实例会把这个userList的引用保存下来吗?或者说,当你第一次渲染的时候,模板里访问userList[0].name,会触发代理对象userList的getter,从而建立起组件和这个代理对象的依赖关系;但你后来把userList变量指向了普通数组,模板下次更新的时候(比如你手动触发其他响应式数据)会访问userList,但是这个时候userList是普通数组,没有代理,而且更关键的是,原来的依赖关系是绑定在旧的代理对象上的,现在你改的是普通数组,旧代理对象根本没变化,所以组件不会更新。

那这个坑怎么填?有两种非常常用的方法,还有一种不推荐但偶尔能用的,我一个个说。

第一种方法:替换reactive里的内容,而不是替换变量本身的引用,怎么替换?如果是数组的话,可以用数组的splice方法,splice会修改原数组,而且Vue3对数组的splice做了响应式处理,绝对没问题:

const userList = reactive([])
// 刷新拿到newList
userList.splice(0, userList.length, ...newList)
// 或者如果觉得展开大数组可能性能不好(其实一般场景下不用考虑,除非newList有几万条),可以先清空再push
userList.length = 0 // 刚才说了Vue3能检测到length修改
userList.push(...newList)

如果是对象的话,更简单,用Object.assign把新对象的属性覆盖到旧的代理对象上就行:

const userInfo = reactive({ name: '张三', age: 18 })
// 刷新拿到newInfo
Object.assign(userInfo, newInfo)

这个方法的好处是完全符合reactive的设计初衷,性能也不错,日常用这个绝对没问题。

第二种方法:用ref来包裹对象/数组,因为ref的.value属性是一个响应式的容器,你可以随时替换.value的引用:

const userList = ref([])
// 刷新拿到newList
userList.value = newList

那什么时候用reactive什么时候用ref?其实这个没有绝对的标准答案,但有一个很多人总结出来的“不成文但好用的规则”:如果是单个基础类型数据,用ref;如果是单个复杂类型数据(对象或数组),你可以根据你的习惯选,但如果你可能会需要整个替换这个复杂类型数据的话,优先用ref,因为不用写splice或者Object.assign,更直观,刚才的同事就是习惯用Vue2的data(类似Vue3的reactive),所以一开始选了reactive,但没想到要整个替换,踩了坑。

第三种不推荐的方法:把reactive对象放在一个更大的reactive对象里作为属性。

const state = reactive({
  userList: []
})
// 刷新拿到newList
state.userList = newList

这个时候state是代理对象,修改state.userList的引用会触发代理对象的setter,从而建立组件和state.userList的依赖关系,所以组件会更新,但为什么不推荐?因为如果你的state里有很多属性,结构会变得很乱,不如直接用ref清晰;而且如果你要监听state.userList的变化,写法会稍微多一点(当然也还好),但主要还是结构问题。

第二个高频踩坑:直接监听reactive对象,新值旧值永远一样

刚才在前置知识点里提到过这个规则对吧?很多人刚用的时候会特别懵,比如写了这样的代码:

import { reactive, watch } from 'vue'
const person = reactive({ name: '张三', age: 18 })
watch(person, (newVal, oldVal) => {
  console.log('新值:', newVal)
  console.log('旧值:', oldVal)
})
// 点击按钮修改
const changeAge = () => {
  person.age++
}

然后点击按钮,控制台打印的新值旧值居然是一模一样的对象!连age都是19!这怎么回事?难道Vue3连新旧值都分不清了?

不是的,这个是因为刚才说的——当你直接把reactive对象作为watch的第一个参数时,Vue会自动开启深度监听,并且回调里的newVal和oldVal都是同一个代理对象的引用,为什么会这样?因为Proxy拦截的是对代理对象属性的修改,而不是代理对象本身的引用修改,所以当你修改person.age的时候,person这个代理对象本身的引用根本没变,Vue只是从代理对象的getter/setter里知道“person的某个属性变了”,但没有办法保存旧的代理对象——哦不对,能不能保存旧属性的集合?其实可以,但如果对象很大,嵌套很深,保存旧对象的深拷贝的话,性能会非常差,所以Vue3官方权衡之后,就做了这样的设计:直接监听reactive整个对象时,只告诉你“对象变了”,不提供不同的新旧代理对象引用。

那这个坑怎么填?如果我真的需要拿到变化之前的旧值怎么办?有两种方法,具体用哪种看你的需求。

第一种方法:不要直接监听reactive整个对象,而是用函数返回这个reactive对象的浅拷贝

watch(() => ({...person}), (newVal, oldVal) => {
  console.log('新值:', newVal)
  console.log('旧值:', oldVal)
  // 这里要注意哦,newVal和oldVal都是普通对象的浅拷贝,不是响应式的,如果需要在回调里修改并触发更新,得改person本身
})

那这里有个问题:为什么要用函数返回?因为watch的第一个参数如果是函数的话,Vue会在每次依赖收集的时候执行这个函数,读取函数里用到的响应式数据(这里就是person的name和age),从而建立依赖关系;而且每次函数执行都会返回一个新的浅拷贝对象,引用不一样,所以watch的浅监听(对,这里用函数返回的话,默认又是浅监听了!刚才的前置知识点里说过,第一个参数的写法不同,默认监听逻辑不一样,这个一定要记牢)就会检测到引用变化,触发回调,并且newVal和oldVal就是这两次执行函数返回的不同的浅拷贝对象,你就能拿到新旧值了。

那如果person是嵌套很深的对象怎么办?比如person有个address属性,address里有city、street,那浅拷贝{...person}只能拷贝name、age、address的引用,修改address.city的话,函数返回的新浅拷贝对象和旧浅拷贝对象里的address引用还是一样的,所以watch的浅监听不会触发!这个时候就要加deep: true了:

const person = reactive({ 
  name: '张三', 
  age: 18,
  address: { city: '北京', street: '长安街' }
})
// 监听嵌套对象的新旧值
watch(() => JSON.parse(JSON.stringify(person)), (newVal, oldVal) => {
  console.log('新值:', newVal)
  console.log('旧值:', oldVal)
}, { deep: true })
// 或者用你自己喜欢的深拷贝方法,比如lodash的cloneDeep,但要注意引入lodash会增加包体积

不过这里要提醒大家:用JSON.parse(JSON.stringify())做深拷贝有局限性,比如不能拷贝函数、正则表达式、Date对象(会变成字符串)、循环引用的对象(会报错);用lodash的cloneDeep虽然没问题,但如果对象很大,每次变化都深拷贝一次,性能会非常差,所以除非你真的非常需要嵌套对象的完整新旧值,否则尽量不要这么做。

第二种方法:监听reactive对象的单个具体属性,而不是整个对象,如果你只需要知道某个具体属性的变化,不需要知道整个对象的变化,那这个方法是最好的,性能也最高,监听单个具体属性也有几种写法,我一个个说。

第一种写法:用函数返回这个具体属性,比如监听person.age:

watch(() => person.age, (newVal, oldVal) => {
  console.log('新年龄:', newVal)
  console.log('旧年龄:', oldVal)
  // 这里newVal和oldVal就是具体的基础类型值,不一样
})
// 监听嵌套的person.address.city也是一样的:
watch(() => person.address.city, (newVal, oldVal) => {
  console.log('新城市:', newVal)
  console.log('旧城市:', oldVal)
})

这个写法不管属性是基础类型还是引用类型,都能用吗?比如如果age是个对象呢?比如person.age = { num: 18 },那用函数返回person.age的话,默认是浅监听,只有当person.age的引用发生变化时才会触发回调;如果要监听person.age.num的变化,要么加deep: true,要么直接用函数返回person.age.num。

第二种写法:用字符串嵌套路径

watch('person.age', (newVal, oldVal) => {
  console.log('新年龄:', newVal)
  console.log('旧年龄:', oldVal)
})
// 监听嵌套的也是一样:
watch('person.address.city', (newVal, oldVal) => {
  console.log('新城市:', newVal)
  console.log('旧城市:', oldVal)
})

这个写法看起来更简洁,但有一个前提:你的reactive对象必须是setup里return出去的变量的直接属性,或者是放在某个更大的响应式对象里的直接属性,比如state.person?不对等下,如果你的person是直接return的,那在watch里直接写字符串'person.age'是可以的吗?哦好像不行?等下让我回忆一下Vue3的官方文档(哦对了刚才说的不成文但好用的规则其实也是从那里来的,但不标来源),字符串嵌套路径的写法,只能用于监听组件实例的根响应式属性或者用$watch监听时的属性,在setup的watch函数里,如果要写字符串路径,必须把reactive对象放在一个更大的reactive对象里作为根属性,然后一起return,

const state = reactive({
  person: { name: '张三', age: 18, address: { city: '北京', street: '长安街' } }
})
// 这个时候在setup的watch里写'state.person.age'就可以了
watch('state.person.age', (newVal, oldVal) => {
  console.log('新年龄:', newVal)
  console.log('旧年龄:', oldVal)
})
// 而且别忘了return state
return { state }

所以如果你的项目里没有统一用state管理所有reactive数据的习惯,还是建议用函数返回具体属性的写法,更通用,不会出错。

第三种写法:如果这个具体属性是基础类型的话,能不能直接监听点出来的属性?比如watch(person.age, ...)?哦不行!因为person.age如果是基础类型的话,它本身不是响应式的(响应式的是person这个代理对象,以及它的getter/setter拦截),你把person.age作为第一个参数传给watch的话,相当于传的是当前的基础类型值(比如18),不是响应式数据,所以watch会直接报错?不对等下不会报错,而是不会建立任何依赖关系,永远不会触发回调,比如写了这样的代码:

import { reactive, watch } from 'vue'
const person = reactive({ name: '张三', age: 18 })
watch(person.age, (newVal, oldVal) => {
  console.log('不会触发的回调')
})
// 点击修改
const changeAge = () => {
  person.age++
}

然后你点一万次按钮,控制台都不会有任何输出,这个也是一个小坑,大家要注意,别犯这种低级错误。

第三个高频踩坑:监听reactive数组的时候,什么时候加deep什么时候不用?

刚才在前置知识点里提到过Vue3的Proxy能检测到数组的索引赋值、length修改,还有push、splice这些方法,那是不是监听reactive数组的时候就不用加deep了?其实要看你怎么写watch的第一个参数。

第一种情况:直接把reactive数组作为watch的第一个参数,那刚才说了,这种情况下Vue会自动开启深度监听,所以不管你是push、splice、索引赋值、length修改,还是修改数组里某个对象的属性(比如userList[0].age++),都会触发回调。

第二种情况:用函数返回这个reactive数组本身,那这种情况下watch默认是浅监听,也就是只有当数组的引用发生变化时才会触发回调——但刚才说了reactive数组不能直接替换引用啊!除非你把它放在更大的reactive对象里或者用ref包裹,哦不对,如果用函数返回reactive数组本身,那不管你怎么修改数组的内容(push、splice、索引赋值、length修改),数组的引用都没变,所以watch的浅监听不会触发;只有当你修改数组里某个对象的属性时,会不会触发?哦不会,因为函数返回的是数组本身,函数执行的时候只会读取数组的引用,不会读取数组里的元素,所以不会建立和数组里元素的依赖关系,那这种情况下,不管你是想监听数组内容的变化(push、splice等),还是想监听数组里对象属性的变化,都要加deep: true。

第三种情况:用函数返回数组的浅拷贝。) => [...userList],那这种情况下watch默认是浅监听,每次你修改数组的内容(push、splice、索引赋值、length修改),数组的浅拷贝引用都会变,所以会触发回调;但如果是修改数组里某个对象的属性,数组的浅拷贝引用不会变,所以不会触发,这个时候就要加deep: true了。

第四种情况:监听数组的具体元素或者长度。) => userList.length,或者() => userList[0].age,这种情况下和监听reactive对象的具体属性一样,不需要加deep,默认就能触发回调,而且能拿到新旧值。

那给大家举个具体的例子,把这几种情况串起来:

import { reactive, watch } from 'vue'
const userList = reactive([
  { name: '张三', age: 18 },
  { name: '李四', age: 20 }
])
// 情况1:直接监听数组,自动deep
watch(userList, (newVal, oldVal) => {
  console.log('情况1触发:数组内容或元素属性变了')
  // 新旧值一样
})
// 情况2:函数返回数组本身,默认不deep,不会触发内容变化
watch(() => userList, (newVal, oldVal) => {
  console.log('情况2触发:数组引用变了')
  // 除非放在state里替换引用,否则不会触发
})
// 情况3:函数返回数组浅拷贝,默认不deep,触发内容变化,不触发元素属性变化
watch(() => [...userList], (newVal, oldVal) => {
  console.log('情况3触发:数组内容变了')
  // 能拿到新旧数组的浅拷贝,新旧元素引用一样
})
// 情况4:监听数组长度,不需要deep
watch(() => userList.length, (newVal, oldVal) => {
  console.log('情况4触发:数组长度变了,新长度:', newVal, '旧长度:', oldVal)
})
// 情况5:监听数组第一个元素的年龄,不需要deep
watch(() => userList[0]?.age, (newVal, oldVal) => {
  console.log('情况5触发:第一个人的年龄变了,新年龄:', newVal, '旧年龄:', oldVal)
  // 加个可选链?.,防止数组为空的时候报错
})
// 测试一下
const testChange = () => {
  // 测试1:push,触发情况1、3、4
  userList.push({ name: '王五', age: 22 })
  // 测试2:修改第一个人的年龄,触发情况1、5
  setTimeout(() => { userList[0].age++ }, 1000)
  // 测试3:splice,触发情况1、3、4(如果长度变了的话)
  setTimeout(() => { userList.splice(1, 1) }, 2000)
}

大家可以把这段代码复制到Vue3的项目里或者Vue SFC Playground里试试,加深一下理解。

第四个高频问题:用watchEffect代替watch监听reactive到底好不好?

最近很多人说watchEffect比watch好用,不用写依赖,自动追踪,那是不是可以完全用watchEffect代替watch监听reactive?其实不是的,它们两个有不同的适用场景,各有优缺点。

先简单说一下watchEffect的默认逻辑:watchEffect会立即执行一次回调函数(除非设置了flush: 'post'或者其他,但默认是同步执行),然后在执行回调函数的过程中,自动读取所有用到的响应式数据(不管是ref还是reactive的属性),从而建立依赖关系,当这些响应式数据中的任何一个发生变化时,都会再次执行回调函数。

那watchEffect和watch监听reactive相比,有什么优点呢?首先是自动追踪依赖,不用你手动写第一个参数(不管是函数还是对象还是字符串),只要在回调里用到了哪个响应式数据,就监听哪个,特别适合那种依赖比较多、或者依赖会动态变化的场景;其次是代码更简洁,比如你要同时监听person的name、age、address.city三个属性,用watch的话要么写数组作为第一个参数:

watch([() => person.name, () => person.age, () => person.address.city], ([newName, newAge, newCity], [oldName, oldAge, oldCity]) => {
  // 处理逻辑
})

要么加deep: true监听整个person(但这样会浪费性能,因为person的其他属性变化也会触发),要么用Object.assign的浅拷贝函数监听(也要加deep),而用watchEffect的话直接写:

watchEffect(() => {
  console.log('name:', person.name)
  console.log('age:', person.age)
  console.log('city:', person.address.city)
  // 处理逻辑
})

就搞定了,自动追踪这三个属性,其他属性变化不会触发。

那watchEffect有什么缺点呢?首先是立即执行一次,有些时候你不需要一开始就执行回调,比如只有当用户修改了某个值之后才执行,这个时候watchEffect就不太合适了,除非你设置一个标志位:

let isFirst = true
watchEffect(() => {
  if (isFirst) {
    isFirst = false
    return
  }
  // 处理逻辑
})

但这样代码就变复杂了,不如直接用watch的immediate: false(哦对了watch的immediate默认就是false,除非你设置immediate: true才会立即执行);其次是拿不到旧值,不管你监听的是基础类型还是引用类型,watchEffect都只能拿到当前的最新值,拿不到变化之前的旧值;第三是停止监听的方式稍微有点不一样,不过这个其实影响不大,watch和watchEffect都会返回一个停止监听的函数,调用就行:

const stopWatch = watch(person, () => {})
const stopWatchEffect = watchEffect(() => {})
// 组件卸载的时候Vue会自动停止,但如果是在组件外面或者手动停止的话,调用stopWatch()或者stopWatchEffect()

那什么时候用watch什么时候用watchEffect监听reactive?这里也有一个不成文但好用的规则:

  1. 如果你需要拿到旧值,或者不需要立即执行回调,或者需要明确指定监听的目标(避免不必要的触发),那就用watch;
  2. 如果你依赖的响应式数据比较多或者会动态变化,或者不需要旧值和立即执行控制,或者代码需要更简洁,那就用watchEffect。

比如刚才的用户列表下拉刷新,如果用watchEffect的话,可能会这样写:

import { ref, reactive, watchEffect } from 'vue'
const page = ref(1)
const pageSize = ref(10)
const userList = reactive([])
// 监听page和pageSize的变化,自动请求数据
watchEffect(async () => {
  const newList = await fetchUserList(page.value, pageSize.value)
  userList.splice(0, userList.length, ...newList)
})

这个时候page和pageSize的任何一个变化,都会自动请求数据并更新userList,不用手动写watch的数组参数,非常方便;但如果用户列表有缓存机制,只有当page变化的时候才请求新数据,pageSize变化的时候只是调整显示数量(从缓存里取),那这个时候用watch会更合适,因为可以明确指定监听page的变化:

import { ref, reactive, watch } from 'vue'
const page = ref(1)
const pageSize = ref(10)
const userList = reactive([])
const cachedList = reactive([]) // 缓存所有数据
// 监听page变化,请求新数据
watch(page, async (newPage) => {
  if (cachedList.length < newPage * pageSize.value) {
    const newData = await fetchUserList(newPage, pageSize.value)
    cachedList.push(...newData)
  }
  // 更新显示的userList
  const start = (newPage - 1) * pageSize.value
  const end = start + pageSize.value
  userList.splice(0, userList.length, ...cachedList.slice(start, end))
}, { immediate: true }) // 加immediate: true,一开始就执行一次
// 监听pageSize变化,调整显示数量,不请求新数据
watch(pageSize, (newPageSize, oldPageSize) => {
  const start = (page.value - 1) * newPageSize
  const end = start + newPageSize
  userList.splice(0, userList.length, ...cachedList.slice(start, end))
})

最后再总结一下Vue3 watch监听reactive的所有注意事项

怕大家看完上面的内容记不住,我整理了一个简洁的清单,大家可以收藏起来,以后踩坑的时候翻一翻:

  1. 不要直接给reactive变量整个重新赋值,会丢失响应式,要用splice(数组)、Object.assign(对象),或者优先用ref包裹;
  2. 直接监听reactive整个对象/数组时,会自动开启深度监听,新旧值永远是同一个代理对象引用,拿不到不同的新旧值;
  3. 要拿到新旧值的话,要么用函数返回浅拷贝/深拷贝对象(嵌套深的话加deep),要么监听单个具体属性(函数返回或字符串路径);
  4. 不要直接监听reactive对象的基础类型属性(比如watch(person.age, ...)),不会触发回调;
  5. 监听reactive数组的写法不同,是否需要加deep也不同:
    • 直接传数组:自动deep,任何变化都触发;
    • 函数返回数组本身:需要deep才能触发内容或元素属性变化;
    • 函数返回数组浅拷贝:默认触发内容变化,需要deep才能触发元素属性变化;
  6. watch和watchEffect各有适用场景,根据是否需要旧值、是否需要立即执行、依赖是否明确来选择。

好了,今天的内容就到这里,希望能帮到大家避开Vue3 watch监听reactive的所有坑,如果大家还有其他问题,可以在评论区留言,我会尽量一一解答。

版权声明

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

热门