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

Vue3 watch函数到底有多少隐藏用法?新手避坑+高阶实战怎么玩?

terry 1小时前 阅读数 29 #Vue

你有没有发现,在Vue3项目里写代码,除了setup语法糖自带的自动响应式,watch和watchEffect几乎是每天都要碰的函数?新手刚上手时,可能只会用它监听ref的变化,但写着写着就会踩坑:比如监听reactive数组/对象的属性没生效、侦听时拿到的旧值和新值一样、异步请求里旧值复用错了,甚至连和computed、watchEffect的区别都搞混。

Vue3 watch和watchEffect、computed的核心边界到底在哪里?

很多人一开始会觉得这三个“响应式工具人”功能重叠,乱用一通反而增加代码复杂度,其实它们各有各的适用场景,核心区别就三个维度:触发时机、回调执行逻辑、是否需要返回值。

先看最基础的,watch和computed的关系:computed本质是“带缓存的响应式计算属性”,重点在“计算结果的复用”,只要依赖的响应式数据没变,再次调用computed就不会重新执行回调;而watch是“响应式变化的监听器”,重点在“变化后的副作用处理”——比如请求接口、修改DOM、存储localStorage这些,都不适合放在computed里,因为computed的回调应该是“纯函数”,不能有副作用。

那watch和watchEffect呢?第一个不同是“依赖收集的时机”:watch需要你主动指定要监听的数据源,可以是ref、reactive、getter函数,甚至数组组合;而watchEffect会自动追踪回调里用到的所有响应式数据,只要有一个变,就会触发,第二个不同是“初始执行”:watch默认不会在组件挂载时执行,除非你显式设置immediate: true;而watchEffect默认会在setup执行到它的时候立刻跑一次,用来收集初始依赖,第三个不同是“获取旧值”:watch默认能拿到数据变化前的旧值和变化后的新值(但要注意数组和对象的坑);而watchEffect只有在显式调用onInvalidate清理函数时,才能间接拿到上一次执行的状态,没办法直接取新旧值

举个小例子,新手常犯的错误就是把“搜索框防抖请求接口”放在computed里:

// 错误写法:computed有副作用,而且每次输入框字符变就会触发,没有防抖
const searchKeyword = ref('')
const searchResult = computed(() => {
  fetch(`/api/search?q=${searchKeyword.value}`)
    .then(res => res.json())
    .then(data => { /* 这里要赋值给另一个ref,更麻烦 */ })
})

换成watch就对了:

// 正确写法:主动监听关键词,设置防抖和immediate
const searchKeyword = ref('')
const searchResult = ref([])
let timer = null
watch(searchKeyword, (newVal) => {
  clearTimeout(timer)
  timer = setTimeout(() => {
    if (newVal.trim()) {
      fetch(`/api/search?q=${newVal.trim()}`)
        .then(res => res.json())
        .then(data => searchResult.value = data.list)
    } else {
      searchResult.value = []
    }
  }, 300)
}, { immediate: true }) // immediate让页面刚打开时如果有默认值就请求

如果场景更简单,当用户点击‘记住密码’后,自动把账号密码存到localStorage”,用watchEffect反而更简洁,因为它自动追踪两个ref:

const rememberMe = ref(false)
const account = ref('')
const password = ref('')
watchEffect(() => {
  if (rememberMe.value) {
    localStorage.setItem('account', account.value)
    localStorage.setItem('password', password.value)
  } else {
    localStorage.removeItem('account')
    localStorage.removeItem('password')
  }
})

Vue3 watch监听reactive相关的三个致命坑,你踩过几个?

很多Vue3新手刚从Vue2转过来,或者刚学完ref就碰reactive,一用watch监听就踩雷,总结下来最常见的有三个。

第一个坑:直接监听整个reactive,新旧值永远一样?

这是最容易遇到的,比如你有一个表单对象:

const form = reactive({
  username: '',
  age: 18
})
watch(form, (newVal, oldVal) => {
  console.log('新值', newVal) // { username: '张三', age: 18 }
  console.log('旧值', oldVal) // 居然也是 { username: '张三', age: 18 }!
})
form.username = '张三'

为什么会这样?因为reactive返回的是一个Proxy代理对象,整个对象的引用地址没变,而watch在默认情况下(deep: false),监听的是“对象的引用地址变化”,不是内部属性;就算你设置deep: true,它监听的是内部属性的“值变化”,但新旧值仍然是同一个Proxy对象的引用,所以打印出来看起来一模一样——但这只是表象,你用Object.is或者JSON.stringify对比的话,其实内容已经变了,但地址没变,所以变量指向的是同一个地方。

那怎么拿到真正的旧值呢?有两个办法: 第一个办法是把整个reactive转换成ref,或者监听reactive的JSON序列化结果(但后者只适合浅对比,复杂对象可能性能差):

// 办法1:用toRefs转成ref后监听整个ref的value,但要注意toRefs会保留响应式
const formRefs = toRefs(form)
const formRef = ref(form) // 这里的ref包裹reactive,.value还是同一个Proxy,没用!
// 哦不对,应该用computed转成一个getter,每次取的时候生成新的对象副本
const formCopy = computed(() => JSON.parse(JSON.stringify(form)))
watch(formCopy, (newVal, oldVal) => {
  console.log('真正的旧值', oldVal) // 旧username是空的
  console.log('真正的新值', newVal) // 新username是张三
}, { deep: true }) // computed的getter每次返回新对象,其实deep可以省,但加了更保险

第二个办法是监听reactive的具体属性,用getter函数包裹,这样不仅能拿到正确的新旧值,性能还更好(因为不用深度遍历整个对象):

// 办法2:推荐!监听具体属性,getter写法更灵活
watch(
  () => form.username,
  (newVal, oldVal) => {
    console.log('username旧值', oldVal) // ''
    console.log('username新值', newVal) // '张三'
  }
)

第二个坑:监听reactive数组的push/pop/splice等方法,默认不生效?

不对不对,其实Vue3的Proxy代理比Vue2的Object.defineProperty强多了,监听整个reactive数组,默认(deep: false)下push/pop/splice这些修改数组长度或索引的方法都会生效,但为什么有时候新手会觉得不生效? 哦,可能是两种情况: 第一种情况是你把reactive数组赋值成了一个新的普通数组,而不是用push/splice这些方法修改原数组:

// 错误写法:直接替换整个reactive数组的引用?不,reactive不能直接替换!
const list = reactive([1,2,3])
watch(list, (newVal) => console.log(newVal)) // 这里没问题,能触发
list = [4,5,6] // 报错!因为list是const声明的,不能重新赋值
// 哦不对,就算你用let声明list = reactive([1,2,3]),然后list = [4,5,6],这时候list已经不是Proxy对象了,当然不会触发watch

那怎么正确替换整个reactive数组的内容?要用Object.assign或者Array.prototype.splice

// 正确替换方式1:Object.assign
Object.assign(list, [4,5,6])
// 正确替换方式2:splice清空后push
list.splice(0, list.length, ...[4,5,6])

第二种情况是你监听的是数组的某个索引对应的ref,但数组索引变化了,ref没更新?

const list = reactive([{ id: 1, name: '苹果' }, { id: 2, name: '香蕉' }])
// 错误做法:直接取list[0]转成ref,但list[0]的引用可能会被替换
const firstItemRef = toRef(list, 0) // toRef(list, index)其实没问题!因为它会追踪list的index变化
// 哦对,toRef(list, 0)不管list[0]怎么变,都是响应式的,那什么时候索引监听会有问题?
// 是你手动把list[0]赋值成了一个非响应式的普通对象,但toRef仍然能追踪到索引对应的新值
// 可能新手之前看的Vue2的坑,移植过来混淆了

不过这里还是要提醒一下:监听具体数组元素的getter写法更稳妥,尤其是嵌套对象的属性:

// 推荐写法:监听数组第一个元素的name
watch(
  () => list[0]?.name, // 加个可选链防止数组为空报错
  (newVal) => console.log(newVal)
)

第三个坑:监听reactive的属性时,用ref包裹的属性和不用的,写法有区别吗?

很多人喜欢在reactive里放ref,

const count = ref(0)
const state = reactive({
  count // 这里会自动解包ref,变成state.count = 0,但仍然是响应式的,因为Vue3会把reactive内部的ref属性自动转换成getter/setter
})

这时候监听state.count的话,用getter和直接传state.count有区别吗? 直接传state.count是普通数值0,不是响应式的数据源,watch根本不会监听;必须用getter函数包裹,或者监听原来的count ref:

// 错误写法:state.count解包后是普通值,不是响应式
watch(state.count, () => {}) // 永远不会触发
// 正确写法1:监听getter
watch(() => state.count, () => {})
// 正确写法2:监听原来的count ref
watch(count, () => {})

这里还有个进阶的小细节:如果state里的ref是用shallowReactive包裹的,就不会自动解包顶层的ref了,必须加.value

const shallowState = shallowReactive({
  count: ref(0)
})
// 这时候监听getter也要加.value
watch(() => shallowState.count.value, () => {})

Vue3 watch的高阶用法,你用过几个?

除了新手常用的“监听单个ref/单个getter”,watch还有几个进阶的隐藏用法,能大大提升开发效率。

高阶用法1:同时监听多个数据源,用数组组合

这个其实不算太隐藏,但很多人不知道数组组合里可以混合ref、getter、甚至另一个数组?

const a = ref(1)
const b = reactive({ value: 2 })
const c = computed(() => a.value + b.value)
// 混合监听:ref、getter、computed
watch([a, () => b.value, c], ([newA, newB, newC], [oldA, oldB, oldC]) => {
  console.log('变化的新值数组', [newA, newB, newC])
  console.log('变化的旧值数组', [oldA, oldB, oldC])
})

这里要注意:只要数组里有一个数据源变了,整个watch的回调就会触发,不管其他的变没变;而且新旧值的数组顺序和你监听的数据源顺序完全一致。

如果想只在“所有数据源都满足某个条件”时才触发回调?可以在回调里自己加判断,或者用computed先包装一个总的触发条件。

高阶用法2:设置flush选项,精确控制回调的执行时机

watch默认的flush选项是'pre',意思是“在Vue的DOM更新之前执行回调”;还有两个选项:'post''sync''post'是“在Vue的DOM更新之后执行回调”,适合用来操作更新后的DOM——比如你监听了一个列表的长度变化,变化后要滚动到列表底部,这时候如果用默认的'pre',DOM还没渲染新的列表项,滚动到底部就没用:

const list = ref([])
const listContainerRef = ref(null)
// 监听list变化,DOM更新后滚动到底部
watch(
  list,
  async () => {
    // 等Vue的微任务队列清空?或者直接用flush: 'post'更稳妥
    // 以前Vue2用$nextTick,Vue3可以用nextTick,也可以用flush: 'post'
    listContainerRef.value?.scrollTo({
      top: listContainerRef.value.scrollHeight,
      behavior: 'smooth'
    })
  },
  { flush: 'post' } // 等价于watchPostEffect的默认行为
)

'sync'是“在响应式数据变化的同步时刻立刻执行回调”,这个选项非常危险,因为它会绕过Vue的响应式批量更新机制——比如你在一个同步代码块里修改了10次监听的数据源,就会触发10次watch回调,不仅性能差,还可能导致逻辑混乱,除非是非常特殊的场景(比如实时同步某个响应式数据到Web Worker),否则千万不要用。

高阶用法3:使用onInvalidate清理上一次的副作用

这个用法和watchEffect的清理函数是一样的,但在watch里用得可能更多——比如刚才提到的搜索框防抖,如果用户在300ms内连续输入,就需要清理上一次的定时器;再比如监听路由变化,请求新页面的接口,如果用户在接口还没返回时就跳转到了另一个页面,就需要取消上一次的请求,否则可能会导致数据错乱(比如先请求的接口后返回,覆盖了后请求的正确数据)。 这里用axios的取消请求举个例子:

import axios from 'axios'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const article = ref(null)
const loading = ref(false)
const error = ref(null)
watch(
  () => route.params.id,
  async (newId) => {
    // 先重置状态
    article.value = null
    loading.value = true
    error.value = null
    // 创建axios的取消令牌
    const controller = new AbortController()
    const signal = controller.signal
    // 清理函数:当上一次的watch回调被中断(比如数据源再次变化、组件卸载)时执行
    onInvalidate(() => {
      controller.abort() // 取消上一次的请求
    })
    try {
      const res = await axios.get(`/api/article/${newId}`, { signal })
      article.value = res.data
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log('请求被取消,不用处理')
      } else {
        error.value = err.message
      }
    } finally {
      loading.value = false
    }
  },
  { immediate: true } // 页面刚打开时根据初始路由请求
)

这里要注意清理函数的触发时机

  1. 当监听的数据源再次变化时,会先执行上一次的清理函数,再执行新的回调;
  2. 当组件卸载时,会执行最后一次的清理函数;
  3. 如果你手动调用了watch的返回值(一个停止侦听的函数),也会执行最后一次的清理函数。

高阶用法4:手动停止侦听,释放内存

watch的返回值是一个函数,调用这个函数就可以停止侦听——比如你有一个弹窗组件,弹窗打开时才需要监听某个数据,弹窗关闭时就停止监听,这样可以释放内存,提升性能:

const dialogVisible = ref(false)
const stopWatch = null
const openDialog = () => {
  dialogVisible.value = true
  // 弹窗打开时开始监听
  stopWatch = watch(
    () => someData.value,
    (newVal) => { /* 弹窗内的逻辑 */ }
  )
}
const closeDialog = () => {
  dialogVisible.value = false
  // 弹窗关闭时停止监听
  if (stopWatch) {
    stopWatch()
    stopWatch = null // 避免重复调用
  }
}
// 为了保险起见,组件卸载时也检查一下是否需要停止
onUnmounted(() => {
  if (stopWatch) {
    stopWatch()
  }
})

Vue3 watch的最佳实践

说了这么多用法和坑,最后给大家整理几条最佳实践:

  1. 优先监听具体属性的getter,而不是整个reactive/数组:不仅能拿到正确的新旧值,性能还更好;
  2. 副作用处理选watch,自动依赖选watchEffect,纯计算选computed:严格区分三个工具人的边界,不要混用;
  3. 涉及异步操作时,一定要加onInvalidate清理函数:比如定时器、接口请求、事件监听器;
  4. 不需要长期侦听时,手动调用返回值停止侦听:尤其是弹窗、抽屉等临时组件;
  5. flush选项默认用'pre',需要操作更新后的DOM用'post',尽量不用'sync'
  6. 监听多个数据源时,用数组组合,顺序要对应好新旧值

其实Vue3的watch还有一些更细的用法,比如设置once: true只触发一次(虽然Vue3官方文档里没直接写这个选项,但你可以在回调里手动调用停止侦听的函数,或者用第三方库比如VueUse的watchOnce),但日常开发中上面的这些用法已经足够覆盖99%的场景了。

如果你还有其他关于Vue3 watch的问题,或者踩过其他的坑,欢迎在评论区留言讨论!

版权声明

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

热门