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

Vue watch怎么监听循环遍历的数据才不踩坑?教你3种实用方法+避坑指南

terry 10小时前 阅读数 142 #Vue

很多刚接触Vue的开发者都会遇到这样的问题:项目里有个从接口返回的商品列表或者配置数组,是一个个对象组成的,直接写watch监听数组名没反应,或者只监听数组长度变化,内部属性改了根本察觉不到;有时候想用watch监听单个遍历项的特定值,又不知道怎么给循环出来的每一项单独绑定,其实这些都不是Vue的bug,是没搞懂watch的核心机制和Vue对响应式数据的处理规则,接下来咱们就一点点拆解,从原理到实操,把这个问题彻底解决。

为什么直接watch数组/对象没反应?搞懂响应式是第一步

要解决监听问题,得先知道Vue的响应式更新是怎么触发的。

先看Vue 2.x的情况,很多老项目现在还在用这个版本,踩坑概率更高,Vue 2.x对数组的响应式处理,是通过重写push、pop、shift、unshift、splice、sort、reverse这7个“变异方法”实现的——你用这些方法改数组,Vue能直接捕捉到并更新视图,同时触发对应数组的watch(前提是watch配对了),但要是你直接改数组的索引,比如list[0].price = 99里的索引覆盖只是第一步,这里的核心问题是:数组本身是个引用类型,Vue 2.x默认只监听数组的引用变化,还有那7个变异方法触发的内部指针调整,不会深度遍历数组里的每一层对象属性;如果数组里的对象是在初始化时就添加好的,那它的属性可能是响应式的,但如果你是后来用list[0] = {name:'新商品'}这种索引赋值替换整个对象,那新对象的属性就不是响应式的了,自然watch不到。

Vue 3.x虽然用Proxy重写了响应式系统,解决了索引赋值和对象属性增删的直接监听问题,但还是有个关键点:如果watch监听的是响应式数组或对象的原始引用,它默认还是浅监听,只有引用本身变了才触发,内部属性改了的话,要加配置才行。

所以不管是Vue 2还是3,基础逻辑都是:要监听循环数据的内部变化,必须做“深度监听”或者“针对特定项/特定路径的监听”。

方法1:deep深度监听,适合需要监听整个数组/对象所有变化的场景

这是最常用的方法,不管是Vue 2还是3通用,只需要在watch的配置项里加个deep: true就行。

举个Vue 3的小例子,比如咱们有个购物车数组:

// script setup
import { ref, watch } from 'vue'
const cartList = ref([
  { id: 1, name: '薯片', count: 2 },
  { id: 2, name: '可乐', count: 3 }
])
// 直接监听cartList,加deep:true
watch(cartList, (newVal, oldVal) => {
  console.log('购物车有变化!')
  console.log('新购物车:', newVal)
  // 这里可以做更新本地存储、同步到接口等操作
}, { deep: true })

这样的话,不管是用push加商品、splice删商品、直接改cartList.value[0].count,还是给某个商品对象加新属性(比如discount: 0.8,Vue 3里没问题,Vue 2里得用$set或者Vue.set),watch都会触发。

那Vue 2的写法差不多,只是没有ref的.value,加个this就行:

// Vue 2.x
data() {
  return {
    cartList: [
      { id: 1, name: '薯片', count: 2 },
      { id: 2, name: '可乐', count: 3 }
    ]
  }
},
watch: {
  cartList: {
    handler(newVal, oldVal) {
      console.log('购物车有变化!')
    },
    deep: true
  }
}

深度监听的小坑

这个方法虽然简单,但要注意性能问题,如果你的数组很大,比如有几百上千个商品对象,每个对象又有十几个属性,那deep:true会让Vue在每次修改时都遍历整个数组的每一层对象属性,判断有没有变化,这会消耗不少CPU资源,导致页面卡顿,所以如果不是必须监听所有变化,尽量别用这个方法。

方法2:监听数组的特定属性路径/计算属性,精准监听更高效

要是只需要监听数组里所有商品的总价变化,或者只监听id为1的那个商品的count,用deep:true就太浪费了,这时候可以用“监听特定路径”或者“监听计算属性”的方法。

监听计算属性(推荐,逻辑清晰、性能可控)

先看总价的例子,不管是Vue 2还是3,计算属性本身就是响应式的,而且会自动缓存结果,只有依赖项变了才会重新计算,这时候监听计算属性,既精准又高效。

Vue 3的写法:

// script setup
import { ref, computed, watch } from 'vue'
const cartList = ref([...])
// 计算总价
const totalPrice = computed(() => {
  return cartList.value.reduce((sum, item) => sum + item.price * item.count, 0)
})
// 只监听总价变化
watch(totalPrice, (newVal, oldVal) => {
  console.log('总价变了!新总价:', newVal)
  // 比如可以触发满减优惠计算、更新运费等
})

监听单个商品的特定路径(适合少量特定项)

如果只需要监听id固定的某个商品,比如购物车第一个商品或者活动指定的商品,可以用字符串路径或者箭头函数返回路径的方式监听。

Vue 3可以用箭头函数返回某个属性,更灵活:

// 监听id为1的商品的count
watch(() => cartList.value.find(item => item.id === 1)?.count, (newVal, oldVal) => {
  if (newVal !== undefined) { // 避免find不到时触发
    console.log('薯片数量变了!')
  }
})

这里加个可选链是为了防止数组里暂时没有id为1的商品时,访问count报错,而且find不到返回undefined,watch第一次执行或者找不到时可以用if过滤掉。

Vue 2因为可选链支持不太好(除非用了Babel),可以用字符串路径,但前提是这个商品的位置是固定的,比如始终是第一个:

// Vue 2.x,监听第一个商品的count
watch: {
  'cartList.0.count'(newVal, oldVal) {
    console.log('第一个商品数量变了!')
  }
}

但如果商品位置会变(比如用户删除了第一个商品,第二个变成第一个),这个字符串路径就失效了,所以还是推荐用计算属性或者箭头函数找id的方式,更稳定。

方法3:给循环出来的每个子组件传props,子组件自己watch

要是你的商品列表是用v-for渲染成一个个子组件的,比如CartItem,那最好的方法是把每个商品对象作为props传给子组件,然后在子组件里自己watch这个props,这样每个子组件只处理自己的监听逻辑,完全独立,性能最好,也符合组件化的思想。

举个Vue 3的子组件例子:

<!-- CartItem.vue -->
<template>
  <div class="cart-item">
    <p>{{ item.name }}</p>
    <input type="number" v-model.number="item.count" min="1">
  </div>
</template>
<script setup>
import { watch } from 'vue'
const props = defineProps(['item'])
// 子组件直接watch自己的item
watch(() => props.item.count, (newVal, oldVal) => {
  console.log(`商品${props.item.id}的数量变了,现在是${newVal}`)
  // 可以在这里做单个商品的库存检查、小计计算等
})
</script>

父组件只要直接传item就行:

<!-- 父组件 -->
<template>
  <div class="cart">
    <CartItem v-for="item in cartList" :key="item.id" :item="item" />
  </div>
</template>

这里一定要注意加:key,而且key必须是唯一的(比如id),不能用索引,不然Vue的虚拟DOM diff算法会出问题,导致子组件复用错误,watch也可能触发异常。

额外避坑:Vue 2.x里的非响应式数据问题

刚才提到过Vue 2.x的两个小细节,这里单独说一下,避免踩坑:

  1. 索引赋值替换对象非响应式:要是你用this.cartList[0] = {name:'新商品'}替换数组里的某个对象,新对象的属性不是响应式的,这时候watch就算加了deep:true也没用,解决方法是用this.$set(this.cartList, 0, {name:'新商品'})或者this.cartList.splice(0, 1, {name:'新商品'})
  2. 直接给对象添加新属性非响应式:要是你给数组里的某个商品加新属性,比如this.cartList[0].discount = 0.8,这个discount不是响应式的,解决方法是用this.$set(this.cartList[0], 'discount', 0.8)

Vue 3.x因为用了Proxy,这两个问题都自动解决了,直接赋值就行,不用$set。

怎么选方法?

  • 要是需要监听整个数组/对象的所有变化,数组不大,用deep深度监听;
  • 要是只需要监听特定的结果(比如总价)或者少量特定项,用计算属性或者箭头函数路径监听;
  • 要是列表是用子组件渲染的,直接给子组件传props,子组件自己watch,这是最推荐的组件化做法。

最后再提醒一句,不管用哪种方法,v-for的key一定要加唯一值,不然很多问题都会莫名其妙地出现,希望这篇文章能帮你解决Vue watch循环遍历数据的所有困扰!

版权声明

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

热门