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

Vue3的watch语法到底有几种写法?新手怎么选最顺手?

terry 2小时前 阅读数 20 #Vue

刚开始摸Vue3时,我也是抱着Vue2 watch的老习惯不放:直接套对象参数加deep/immediate,结果偶尔会遇到Vue3提示的“请避免直接监听原始值的属性引用”或者“computed缓存失效触发的watch重复执行”这类小问题,后来才慢慢搞懂,Vue3的watch不止对象式写法这一种,它还做了挺多细节优化,不同场景用不同写法,不仅代码更短,还能减少踩坑概率,今天就整理了自己踩过坑后总结的所有Vue3 watch语法,结合具体例子讲清楚每种写法的适用场景,新手看完应该能直接上手用。

Vue3 watch有哪些写法?先从最容易上手的单一值监听开始讲

很多人转Vue3第一个碰到的watch,可能就是监听响应式数据的变化,这个时候最简单的写法,就是参数一放要监听的响应式引用,参数二放回调函数——这个和Vue2有点像,但区别是必须要监听ref包装后的原始值(或者computed的返回值),直接监听普通变量是没用的。

举个例子哈:比如我们有个计数器,显示当前点击的次数,当次数超过10次时,就弹出个提示“点击量达标啦!”,这个场景用单一ref监听就刚好,代码大概长这样:

<script setup>
import { ref, watch } from 'vue'
const clickCount = ref(0)
// 单一值监听,第一个参数是ref对象
watch(clickCount, (newVal, oldVal) => {
  if (newVal > 10 && oldVal <= 10) {
    console.log('点击量达标啦!')
    alert('恭喜!点击量已经超过10次了~')
  }
})
</script>

这里要注意两个细节:第一个是回调函数的参数顺序,永远是新值在前,旧值在后,和Vue2是一样的;第二个是如果只监听一次变化后就不想再监听了,可以在watch里加第三个参数{ once: true },比如弹窗提示只弹一次的话,就加这个配置。

监听多个值怎么办?数组式监听帮你搞定

如果我们需要监听两个或更多个响应式数据的变化,比如监听“输入的用户名”和“输入的密码”,只要有一个变了就打印当前的输入状态,这个时候用数组式监听就特别方便,不用写好几个watch函数。

数组式监听的写法也很简单:把第一个参数换成要监听的所有响应式引用组成的数组,回调函数的参数也对应变成两个数组,第一个是所有新值的数组,第二个是所有旧值的数组,顺序和你放的引用顺序一致。

还是拿用户名密码来举例子:

<script setup>
import { ref, watch } from 'vue'
const username = ref('')
const password = ref('')
// 数组式监听,第一个参数是ref数组
watch([username, password], ([newUser, newPwd], [oldUser, oldPwd]) => {
  console.log('输入状态变化了!')
  console.log('新用户名:', newUser, '旧用户名:', oldUser)
  console.log('新密码:', newPwd, '旧密码:', oldPwd)
})
</script>

这里有个小技巧:如果只想监听数组里的某一个值变化,其他值变了不触发回调怎么办?那可以用computed先处理一下,但更直接的是,如果你用的是Vue 3.4及以上的版本,watch的回调函数里新增了一个trigger参数,里面有changedIndices属性,就是变化的那个值在数组里的索引,你可以通过判断索引来过滤触发条件,比如只想监听密码变化:

// Vue 3.4+ 新增的trigger参数
watch([username, password], (newVals, oldVals, trigger) => {
  if (trigger.changedIndices.includes(1)) { // 密码在数组索引1的位置
    console.log('密码修改了,请确认是否需要重新登录?')
  }
})

这个trigger参数在Vue 3.4之前是没有的,之前如果要实现类似的过滤,可能要单独写watch,或者手动对比新旧值数组,所以有条件的话尽量用最新版的Vue3,功能会更全。

监听对象/数组的属性怎么办?有三种思路,用对性能才好

这应该是新手踩坑最多的地方了!因为Vue3里的响应式分为浅层响应式和深层响应式:ref包装对象/数组时,默认是深层响应式(不管嵌套多少层都能监听变化),但reactive本身就是深层响应式;不过watch默认监听的是引用地址的变化,如果只是修改对象/数组的属性或元素,引用地址没变,watch默认是不会触发的!

那怎么解决这个问题呢?根据不同的场景,有三种常用的思路:

监听对象/数组的某个具体属性(或嵌套属性)

如果我们只需要监听对象/数组里的某一个具体属性,比如监听reactive对象userInfo里的age,或者嵌套属性userInfo.address.city,这个时候用函数式返回值作为第一个参数是最好的选择——因为这样不会监听整个对象的变化,只会监听返回的那个具体值,性能会更高。

这里要注意,如果是reactive对象的直接属性,直接在函数里返回就行;如果是嵌套属性,或者是ref包装的对象的属性,也可以在函数里返回,但如果是ref的原始值包装,就直接传ref对象就行,不用函数

举个具体的例子:

<script setup>
import { ref, reactive, watch } from 'vue'
// 场景1:监听reactive的直接属性age
const userInfo = reactive({
  name: '张三',
  age: 20,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
// 函数式返回具体属性,直接传userInfo.age是可以的吗?
// 早期的Vue3版本好像可以,但后来的官方文档建议尽量用函数式,避免某些边界情况的bug
watch(() => userInfo.age, (newAge, oldAge) => {
  console.log('年龄变了!从', oldAge, '变成了', newAge)
})
// 场景2:监听reactive的嵌套属性city
watch(() => userInfo.address.city, (newCity, oldCity) => {
  console.log('城市变了!从', oldCity, '搬到了', newCity)
})
// 场景3:监听ref包装的对象的某个属性
const book = ref({ 'Vue3实战指南',
  price: 59.9
})
// 这里也可以用函数式返回book.value.price,不过直接传book.price会不会有问题?
// 直接传book.price的话,其实传的是初始值的引用,不会触发监听,必须要函数式返回book.value.price
watch(() => book.value.price, (newPrice, oldPrice) => {
  console.log('书的价格变了!从', oldPrice, '调整为', newPrice)
})
</script>

这个思路的核心就是哪里变监听哪里,不要浪费性能监听整个对象,如果对象有100个属性,你只需要监听1个,用这个思路比deep监听快得多。

用deep配置监听整个对象/数组的所有变化

如果我们需要监听整个对象/数组的所有变化,不管是修改属性、添加属性还是删除属性(Vue3的reactive默认支持添加删除属性的响应式,ref包装的对象要修改value里的属性才会有响应式),这个时候就可以用第三个参数加{ deep: true }配置——这个和Vue2的deep配置是一样的,但同样要注意性能问题,deep监听会遍历对象的所有嵌套属性,创建很多监听器,如果对象特别大(比如有几千个属性的数组对象),会占用比较多的内存和CPU。

举个例子哈:比如我们有一个购物车数组,不管是添加商品、删除商品、修改商品数量还是修改商品价格,都要重新计算购物车的总价,这个时候就可以用deep监听购物车数组:

<script setup>
import { ref, watch, computed } from 'vue'
// 购物车数组
const cartList = ref([
  { id: 1, name: '苹果', price: 5.9, count: 2 },
  { id: 2, name: '香蕉', price: 3.9, count: 3 }
])
// 计算总价
const totalPrice = computed(() => {
  return cartList.value.reduce((sum, item) => sum + item.price * item.count, 0).toFixed(2)
})
// deep监听整个购物车数组的变化,只要有变化就打印总价
watch(cartList, (newCart, oldCart) => {
  console.log('购物车发生变化了!')
  console.log('新购物车:', newCart)
  console.log('旧购物车:', oldCart)
  console.log('当前总价:', totalPrice.value)
}, {
  deep: true
})
// 测试一下添加商品
const addItem = () => {
  cartList.value.push({ id: 3, name: '橙子', price: 4.9, count: 1 })
}
// 测试一下修改商品数量
const updateCount = (id, count) => {
  const item = cartList.value.find(i => i.id === id)
  if (item) {
    item.count = count
  }
}
</script>

这里要注意一个边界情况:如果是用ref包装的原始值数组,比如const numbers = ref([1,2,3]),然后直接修改数组的元素,比如numbers.value[0] = 10,或者用push、pop、splice这些方法修改数组,用deep监听是可以触发的,但如果是直接替换整个数组,比如numbers.value = [4,5,6],引用地址变了,不管有没有deep都会触发监听,这个时候其实不用加deep也行,但加了也不会有问题。

用shallowRef/shallowReactive配合deep监听?或者反向操作?

这个思路可能用得少一点,但在某些特定场景下很有用:比如我们有一个超级大的数组对象,平时只需要监听数组的长度变化,不需要监听里面每个对象的属性变化,这个时候就可以用shallowRef或者shallowReactive——shallowRef只会监听value的引用地址变化,不会监听value里面的属性变化;shallowReactive只会监听对象的直接属性变化,不会监听嵌套属性变化。

如果用了shallowRef/shallowReactive之后,偶尔又需要监听里面某个嵌套属性的变化,那可以用思路一的函数式返回值;如果偶尔需要监听整个大对象的变化,那可以用deep监听——不过这个时候deep监听只会临时创建深层监听器吗?不对,其实只要加了deep,不管是shallowRef还是普通ref,都会遍历所有嵌套属性,所以这个思路其实更适合“大部分时候不需要深层监听,偶尔需要监听具体属性”的场景,这样平时不会占用太多内存。

想在组件挂载时就立即执行watch回调?immediate配置来帮忙

这个和Vue2的immediate配置也是一样的,加了{ immediate: true }之后,watch会在组件挂载(setup执行完之后,DOM渲染之前)就立即执行一次回调函数,这个时候的旧值会是undefined(如果是单一值监听)或者undefined组成的数组(如果是数组式监听)或者undefined对应的属性值(如果是函数式返回嵌套属性?不对,函数式返回嵌套属性的话,如果是组件挂载时,reactive的嵌套属性已经有值了,那旧值还是undefined)。

举个例子哈:比如我们有一个搜索框,需要从localStorage里读取上次搜索的关键词,然后填充到搜索框里,同时搜索框的内容变化时,要把新的关键词保存到localStorage里,这个时候就可以用immediate配置,让watch在组件挂载时就执行一次,把上次的关键词读出来:

<script setup>
import { ref, watch, onMounted } from 'vue'
const searchKeyword = ref('')
// 监听搜索关键词的变化,立即执行一次
watch(searchKeyword, (newKeyword) => {
  if (newKeyword) {
    localStorage.setItem('lastSearchKeyword', newKeyword)
    console.log('搜索关键词已保存到localStorage:', newKeyword)
  }
}, {
  immediate: true
})
// 这里其实不用onMounted了,因为immediate已经在组件挂载前执行了一次
// onMounted(() => {
//   const lastKeyword = localStorage.getItem('lastSearchKeyword')
//   if (lastKeyword) {
//     searchKeyword.value = lastKeyword
//   }
// })
</script>

这里要注意一个小细节:如果immediate和once同时用,那组件挂载时执行一次之后,后面的变化就不会再触发了;如果immediate和deep同时用,那组件挂载时会先执行一次,然后再监听深层变化。

不想每次都手动写watch的清理函数?flush配置帮你选择合适的执行时机

Vue3的watch还有一个Vue2没有的flush配置,用来选择watch回调函数的执行时机,有三个可选值:'pre'(默认值)、'post''sync'

flush: 'pre'(默认)

默认的flush值是'pre',意思是在DOM更新之前执行回调函数,这个时候你可以在回调函数里修改DOM的相关数据,或者获取DOM更新前的状态,比如获取DOM更新前的元素高度、宽度之类的。

flush: 'post'

flush: 'post'的意思是在DOM更新之后执行回调函数,这个时候你可以在回调函数里获取DOM更新后的状态,比如获取DOM更新后的元素高度、宽度,或者操作DOM元素(比如给某个元素添加动画类名)。

举个例子哈:比如我们有一个可折叠的面板,点击按钮可以展开或折叠,我们需要在面板展开或折叠之后,获取面板的高度,然后调整旁边的侧边栏的高度,这个时候就可以用flush: 'post':

<template>
  <div class="container">
    <div class="panel" ref="panelRef">
      <button @click="isCollapsed = !isCollapsed">
        {{ isCollapsed ? '展开' : '折叠' }}面板
      </button>
      <div v-if="!isCollapsed" class="panel-content">
        这是面板的内容<br>
        这是面板的内容<br>
        这是面板的内容<br>
        这是面板的内容<br>
      </div>
    </div>
    <div class="sidebar" ref="sidebarRef">
      这是侧边栏
    </div>
  </div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const isCollapsed = ref(true)
const panelRef = ref(null)
const sidebarRef = ref(null)
// 用flush: 'post'在DOM更新之后执行回调函数
// 其实也可以用nextTick,不过flush: 'post'更简洁一点
watch(isCollapsed, () => {
  if (panelRef.value && sidebarRef.value) {
    const panelHeight = panelRef.value.offsetHeight
    sidebarRef.value.style.height = `${panelHeight}px`
    console.log('侧边栏高度已调整为:', panelHeight)
  }
}, {
  flush: 'post'
})
</script>

这里可以对比一下flush: 'post'和nextTick的区别:nextTick是在下一个DOM更新周期结束后执行,而flush: 'post'是在当前这个DOM更新周期结束后立即执行,性能上稍微好一点,而且代码更简洁,不用再套一层nextTick。

flush: 'sync'

flush: 'sync'的意思是同步执行回调函数,也就是只要响应式数据变化了,就立即执行回调函数,不管DOM有没有更新,这个用得比较少,因为可能会导致回调函数执行得太频繁,影响性能,比如循环修改响应式数据,每次修改都会触发回调函数,而不是等循环结束后统一触发(默认的flush: 'pre'或者'post'会等同步代码执行完,DOM更新前后统一触发一次)。

想自己控制什么时候停止watch?手动调用stop函数就好

Vue3的watch会返回一个stop函数,调用这个stop函数之后,watch就不会再监听响应式数据的变化了,这个和Vue2的this.$watch返回的unwatch函数是一样的,但在