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

Vue3 watch响应式状态需要注意什么?watch和watchEffect到底选哪个?

terry 2小时前 阅读数 40 #Vue
文章标签 Vue3 watchwatchEffect

日常写Vue3项目时,响应式状态的监听肯定绕不开watch和watchEffect这俩API,新手刚上手可能觉得“这不都是看数据变了干点事吗?随便用哪个都行”,结果写着写着就踩坑:要么监听不到状态变化、要么重复触发回调、要么清理函数忘加导致内存泄漏,更别说搞不清什么时候该用哪个了,今天就掰开揉碎讲清楚这俩家伙的用法、坑点,还有怎么根据场景选,从新手入门到老手避坑都能用到。

首先得搞懂:Vue3的响应式状态到底有哪些?

聊watch怎么用之前,得先把监听的“对象”——Vue3的响应式状态说清楚,不然上来就讲API,肯定云里雾里的,Vue3的响应式状态主要分两类:

第一类是基础类型值的响应式状态,也就是用ref定义的,不管是number、string、boolean,还是null、undefined,ref都会把它们包成一个带.value属性的对象,.value才是真正存值的地方,而且ref是深度响应式吗?不是默认的!默认情况下,ref只监听自身.value的引用变化,比如你给ref.value赋值一个新对象,才会触发;如果是直接修改老对象的某个属性,默认不触发,除非手动设置deep:true。

第二类是引用类型值的响应式状态,也就是用reactive定义的,reactive会把传入的对象、数组变成响应式代理,默认是深度响应式的,不管你改对象的属性、嵌套属性,还是数组的push、pop、splice这些方法,甚至是给数组新增索引、给对象新增属性,都会触发监听,不过要注意,reactive不能直接赋值新对象,不然会失去响应式,比如user = reactive({name:'张三'});后来你写user = {name:'李四'},这时候user就变成普通对象了,再改name也没用。

Vue3 watch的核心用法:看菜下饭选监听源

watch的核心在于“指定监听源,只有源变化时才触发回调”,而且回调里能拿到新值和旧值,这点是它和watchEffect最大的区别,那watch的监听源可以是哪些呢?

监听单个ref

这个最简单,直接把ref的变量名(注意不是.value!不然就变成监听一个普通值了,除非手动转成函数返回)传给watch就行,举个例子:

import { ref, watch } from 'vue'
const count = ref(0)
// 正确写法1:直接传ref变量
watch(count, (newVal, oldVal) => {
  console.log(`count变了:${oldVal} → ${newVal}`)
})
// 正确写法2:传函数返回ref.value(这种写法在多个ref组合监听、或者监听computed里的某个值时常用)
watch(() => count.value, (newVal, oldVal) => {
  console.log(`count变了(函数写法):${oldVal} → ${newVal}`)
})
// 错误写法:直接传count.value,这时候监听的是初始值0,永远不会触发
// watch(count.value, (newVal, oldVal) => {})

这里提一下,如果监听的是ref包的对象,默认不监听属性变化对吧?那加个deep:true就行:

const user = ref({name:'张三', age:18})
watch(user, (newVal, oldVal) => {
  console.log('user里的某个属性变了')
  // 注意:如果加了deep:true,newVal和oldVal是同一个对象的引用,对比不出具体哪里变了!
  console.log(newVal === oldVal) // 输出true
}, {deep:true})

如果想拿到具体变化的属性值,怎么办?要么用watch监听具体的属性() => user.value.age),要么用vue3.2+新增的watchEffect的变体,或者后面提到的computed。

监听单个reactive对象/属性

监听单个reactive对象的话,默认就是深度监听,和ref包对象加deep:true一样,newVal和oldVal是同一个引用,那怎么监听reactive里的某个具体属性呢?也得用函数写法返回这个属性:

import { reactive, watch } from 'vue'
const user = reactive({name:'张三', age:18})
// 监听整个user对象(默认深度)
watch(user, () => {})
// 监听具体的age属性
watch(() => user.age, (newVal, oldVal) => {
  console.log(`age变了:${oldVal} → ${newVal}`)
  // 这里newVal和oldVal是不同的!因为基础类型是值传递
})

这里有个小技巧,如果想监听reactive里的多个属性,但不想写多个watch,也不想监听整个对象导致太泛,可以把函数放在数组里:

watch([() => user.name, () => user.age], ([newName, newAge], [oldName, oldAge]) => {
  console.log(`name或age变了:name(${oldName}→${newName}), age(${oldAge}→${newAge})`)
})

这个数组写法不管是ref组合还是reactive属性组合都能用,非常灵活。

监听computed值

computed本身就是响应式的,而且是缓存的,只有依赖的响应式状态变化时才会重新计算,所以监听computed是非常推荐的做法,既能保证监听的是需要的逻辑值,又能避免不必要的计算和回调触发:

import { ref, computed, watch } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const fullName = computed(() => firstName.value + lastName.value)
watch(fullName, (newVal, oldVal) => {
  console.log(`fullName变了:${oldVal} → ${newVal}`)
})
// 这时候只有firstName或lastName变了,fullName才会重新计算,watch才会触发

watch的避坑指南:90%的新手都会踩这几个雷

刚才讲用法的时候其实已经提到了一些坑,现在专门拎出来整理一下,都是日常开发中高频出现的:

雷区1:监听ref时直接传.value

这个刚才举过错误例子,一定要记住:监听ref本身(变量名)或者传函数返回.value,传.value的话,相当于给watch传了一个静态值,比如初始的0,这个值不会变,所以watch永远不会触发。

雷区2:监听reactive时直接赋值新对象

reactive是返回一个代理对象,如果你后来直接把普通对象赋值给这个代理变量,那变量就变成普通对象了,失去了响应式,后续的修改不管是对对象本身还是属性,都不会触发watch,那如果要替换整个reactive对象怎么办?推荐的做法有两个:一是用ref包对象,替换的时候直接改ref.value;二是用Object.assign或者展开运算符更新原reactive对象的属性:

// 错误做法
let user = reactive({name:'张三'})
user = {name:'李四'} // 失去响应式
// 正确做法1:用ref包
const user = ref({name:'张三'})
user.value = {name:'李四'} // 没问题,加deep:true的话还能触发
// 正确做法2:Object.assign更新
const user = reactive({name:'张三', age:18})
Object.assign(user, {name:'李四', gender:'男'}) // 新增或修改属性都可以,保持响应式

雷区3:加了deep:true后对比不出oldVal和newVal

不管是ref包对象加deep:true,还是直接监听reactive对象,newVal和oldVal都是同一个响应式代理的引用,所以直接用===比较永远是true,没法知道具体哪个属性变了,如果确实需要知道变化的具体值,要么监听具体的属性(用函数写法),要么用vue-devtools调试,或者如果是对象的话,可以用JSON.parse(JSON.stringify())深拷贝一份存起来当oldVal,但这个方法只适合简单对象,复杂对象(比如有函数、循环引用)的话会出问题,而且性能也不好,尽量少用。

雷区4:清理函数忘加导致内存泄漏

什么是watch的清理函数?比如你在watch的回调里做了一些异步操作(比如发请求、加定时器、加事件监听器),如果在异步操作完成之前,监听源又变了,或者组件销毁了,那之前的异步操作、定时器、事件监听器就会变成“孤儿”,占用内存,甚至导致bug(比如旧的请求回来了更新了新的数据),这时候就需要用到watch的清理函数了:

import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal, onCleanup) => {
  if (!newVal) return
  // 加个定时器模拟发请求
  const timer = setTimeout(() => {
    console.log(`搜索${newVal}的结果`)
  }, 1000)
  // 注册清理函数:当监听源再次变化、或者组件销毁时,会先执行这个清理函数
  onCleanup(() => {
    clearTimeout(timer)
    console.log('清理了旧的搜索请求')
  })
})

这个清理函数在处理防抖节流(虽然Vue3有watchPostEffect配合useDebounceFn,但这个写法更基础)、取消axios请求时非常常用,一定要记得加。

雷区5:immediate:true和清理函数的配合问题

immediate:true的作用是让watch在组件挂载后立即执行一次回调,这时候oldVal会是undefined,那如果加了immediate:true,第一次执行回调时会不会触发清理函数?不会!清理函数只有在第二次及以后监听源变化,或者组件销毁的时候才会执行,举个例子:

const count = ref(0)
watch(count, (newVal, oldVal, onCleanup) => {
  console.log(`执行回调:newVal=${newVal}, oldVal=${oldVal}`)
  onCleanup(() => {
    console.log('执行清理函数')
  })
}, {immediate:true})
count.value = 1 // 这时候会先执行第一次回调的清理函数,再执行第二次回调
// 输出顺序:
// 执行回调:newVal=0, oldVal=undefined
// 执行清理函数(第一次的)
// 执行回调:newVal=1, oldVal=0

watch和watchEffect到底选哪个?看这3个场景就够了

终于到了大家最关心的问题:这俩API功能这么像,到底什么时候用哪个?其实它们的核心区别就两个:是否需要指定监听源是否需要拿到新值和旧值,再加上执行时机的细微差异,就可以根据场景选了。

场景1:只关心数据变化后的结果,不需要知道旧值,也不想手动指定监听源——选watchEffect

watchEffect的核心特点是“自动收集依赖”:它会在第一次执行回调时,自动把回调里用到的所有响应式状态都当成监听源,只要其中一个变化,就会重新执行回调,而且不需要手动指定,也拿不到新值和旧值,比如你想在页面上实时显示当前时间,并且时间每秒更新一次,就可以用watchEffect:

import { ref, watchEffect, onMounted, onUnmounted } from 'vue'
const currentTime = ref(new Date())
let timer = null
watchEffect(() => {
  // 这里用到了currentTime.value,所以currentTime变化时会重新执行?
  // 不,这里我们是想主动更新currentTime,所以用定时器
  // 那watchEffect在这里的作用是什么?自动收集timer相关的清理逻辑?不对,清理逻辑还是要手动加onCleanup
  // 哦,举个更贴合的例子:根据搜索关键词实时显示搜索框的高亮
  const keyword = ref('')
  const inputEl = ref(null)
  watchEffect(() => {
    if (!keyword.value || !inputEl.value) return
    // 自动收集keyword和inputEl的依赖,只要其中一个变了,就重新高亮
    const text = inputEl.value.innerText
    const highlightedText = text.replaceAll(keyword.value, `<span style="color:red">${keyword.value}</span>`)
    inputEl.value.innerHTML = highlightedText
  })

再比如,你想在响应式状态变化后,把它同步到localStorage里,也可以用watchEffect,因为不需要知道旧值,只需要把最新的值存进去就行:

import { ref, watchEffect } from 'vue'
const theme = ref(localStorage.getItem('theme') || 'light')
watchEffect(() => {
  localStorage.setItem('theme', theme.value)
  document.documentElement.setAttribute('data-theme', theme.value)
})

watchEffect还有两个变体:watchPostEffect和watchSyncEffect,分别对应不同的执行时机:watchEffect默认是在Vue的响应式更新之前执行(即pre模式),watchPostEffect是在DOM更新之后执行(即post模式,适合操作DOM),watchSyncEffect是同步执行(即sync模式,尽量少用,会影响性能)。

场景2:需要知道旧值,或者只想监听特定的状态变化——选watch

刚才讲的雷区3其实就是这个场景的反面:如果需要对比新值和旧值,比如做数据变更的记录(比如用户修改了年龄,从18变到20,需要记录这个变化),那必须用watch监听具体的属性,拿到newVal和oldVal,再比如,你有一个搜索框,只想在用户输入完整的关键词(比如停止输入500ms)后才发请求,而且需要取消之前的请求,这时候也需要用watch,因为你要指定监听keyword,拿到newVal,还要加清理函数取消旧请求。

场景3:需要控制监听的深度、是否立即执行——选watch

watchEffect默认是深度监听的吗?是的!因为它自动收集依赖,如果依赖的是reactive对象或者ref包的对象的属性,那不管嵌套多深,都会自动监听,但如果你想控制深度(比如只想监听ref包的对象的第一层属性,不想监听嵌套属性),那只能用watch加deep:false(不过ref包对象默认就是deep:false),如果你想让监听在组件挂载后立即执行一次,那可以用watch加immediate:true,而watchEffect默认就是立即执行一次的。

Vue3 watch state的核心要点

  1. 先搞懂监听的对象:ref(基础/引用类型,默认浅监听)和reactive(引用类型,默认深监听)。
  2. watch的监听源可以是单个ref、单个reactive对象/属性(用函数写法)、多个ref/reactive属性组合(用数组写法)、computed值。
  3. watch的避坑指南:不要直接传ref.value、不要直接替换reactive对象、加deep:true后对比不出oldVal/newVal、记得加清理函数、注意immediate:true和清理函数的配合。
  4. watch和watchEffect的选择:不需要指定监听源、不需要旧值→选watchEffect;需要指定监听源、需要旧值、需要控制深度/立即执行→选watch。

希望这篇文章能帮你彻底搞懂Vue3的watch和watchEffect,以后写项目的时候再也不会踩坑啦!如果还有其他问题,欢迎在评论区留言讨论。

版权声明

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

热门