返回

高效监听 Vue 3 数组变化:无需 deep: true 的解决方案

javascript

好的,这是符合要求的博客文章内容:

Vue 3 如何监听数组结构变化(无需 deep: true)

写 Vue 的时候,我们经常需要盯住(watch)一个数组,看看它里面是不是加了东西,或者少了东西。比如,列表项的增删操作后,可能需要触发一些别的逻辑。

在 Vue 2 的选项式 API 里,直接 watch 一个数组,像 pushpop 这种直接修改原数组结构的操作,是能被侦听到的,哪怕你没加 deep: true

看这段 Vue 2 的代码,简简单单:

<template>
  <button @click="addItem">加一个</button>
  <button @click="removeItem">减一个</button>
  <br><br>
  <div>列表:</div>
  <div v-for="(item, index) in items" :key="index">{{item}}</div>
</template>

<script>
export default {
  data() {
    return {
      items: [],
    };
  },
  watch: {
    items: {
      handler(newVal, oldVal) {
        // 注意:即使不用 deep: true, 
        // 在 Vue 2 中 push/pop 也能触发 handler
        console.log('数组变了:', newVal);
      },
      // deep: true // Vue 2 里不加这个,push/pop 也能触发
    }
  },
  methods: {
    addItem() {
      // 直接修改原数组
      this.items.push({ i: this.items.length });
    },
    removeItem() {
      // 直接修改原数组
      this.items.pop();
    }
  }
};
</script>

这段代码在 Vue 2 里跑得好好的,addItemremoveItem 一执行,watch 里的 handler 就会打印信息。

但是!当你把类似逻辑搬到 Vue 3,尤其是用了组合式 API(Composition API)或者即使用了选项式 API,可能会发现:欸?不加 deep: truepushpop 这些操作好像触发不了 watch 了?

// Vue 3 <script setup> 示例 (选项式API也有类似问题)
import { ref, watch } from 'vue';

const items = ref([]);

// 不加 deep: true
watch(items, (newVal) => {
  // 在 Vue 3 中,直接 push/pop 不会触发这个 watcher!
  console.log('数组变了 (普通 watch):', newVal); 
});

// 如果加上 deep: true
watch(items, (newVal) => {
  console.log('数组变了 (deep watch):', newVal); 
  // 这个能触发,但如果数组里的对象很复杂,性能就惨了
}, { deep: true });

function addItem() {
  // 直接修改 ref 内部的数组
  items.value.push({ i: items.value.length });
  console.log('加了一个, 现在是:', items.value);
}

function removeItem() {
  // 直接修改 ref 内部的数组
  items.value.pop();
   console.log('减了一个, 现在是:', items.value);
}

强行加上 deep: true 倒是能解决问题,侦听器确实会响应 push/pop。但如果你的数组元素是复杂的对象,里面嵌套N层,那 deep: true 就像开了个无底洞扫描仪,会递归地侦听所有嵌套属性的变化。一旦数组变大或者对象变复杂,页面性能可能直接崩掉,变得卡顿无比。

有人可能会说,那每次增删都创建一个新数组呢?像这样:

function addItem() {
  // 创建新数组,改变 ref 的引用
  items.value = [...items.value, { i: items.value.length }];
}

function removeItem() {
  // 创建新数组 (这里用 slice 举例,pop 的等效写法稍微麻烦点)
  items.value = items.value.slice(0, -1);
}

这种方式确实能触发不带 deep: truewatch,因为 items 这个 ref 指向的内存地址 变了(赋了一个全新的数组)。但这种做法的代价也不小,每次操作都复制整个数组,元素少还行,元素多了效率同样堪忧,内存开销也噌噌涨。

那有没有办法,既能响应 pushpopsplice 这类修改数组结构的操作,又不用 deep: true 拖垮性能,还能避免每次都生成新数组的浪费呢?

答案是:有!下面介绍几种在 Vue 3 里更优雅、高效的解决方案。

为啥会这样?Vue 2 和 Vue 3 的小差异

在深入解决方案之前,稍微聊聊为啥会有这个行为差异。

Vue 2 的响应式系统主要是通过 Object.defineProperty 来实现的。对于数组,它会“劫持”那些能改变数组自身的 стандарт 方法(push, pop, shift, unshift, splice, sort, reverse)。当你调用这些方法时,Vue 2 不仅执行了原始操作,还会额外通知相关的观察者(watcher)更新。所以,即使不指定 deep,对数组结构的直接修改也能被捕捉到。

Vue 3 则改用了基于 Proxy 的响应式系统。Proxy 提供了更底层、更全面的拦截能力。当使用 ref 包裹一个数组时,默认情况下,watch 主要关注的是 ref 本身的 .value 是否被重新赋值 。直接调用 .value 上的 pushpop 方法,修改的是数组内部ref.value 指向的还是那个同一个 数组对象,引用没变。因此,不加 deep: true,Vue 3 的 watch 默认不会认为发生了需要响应的变化。而 deep: true 会指示 Vue 深入到数组内部去侦测变化。reactive 对数组的处理则稍有不同,我们后面会讲到。

了解了这个背景,就能更好地理解下面的解决方案了。

解决方案来了

下面介绍几种常用的方法,可以根据你的具体场景和偏好选择。

方案一:改用 reactive

对于数组和对象这类引用类型,Vue 3 推荐使用 reactive 来创建响应式状态。reactive 会返回一个对象的响应式代理,它与 ref 不同,修改 reactive 对象的属性(包括数组的索引或调用 push/pop 等方法)通常能被直接侦测到,无需 deep: true

原理:

reactive 返回的是一个 Proxy 对象。对这个 Proxy 对象进行的操作(比如调用 push 方法)会被 Proxy 拦截,Vue 的响应式系统就能知道发生了变化,并通知相关的侦听器。它天生就更适合处理非原始值的响应式。

代码示例 (<script setup>):

<template>
  <button @click="addItem">加一个 (reactive)</button>
  <button @click="removeItem">减一个 (reactive)</button>
  <br><br>
  <div>列表 (reactive):</div>
  <div v-for="(item, index) in items" :key="index">{{item}}</div>
</template>

<script setup>
import { reactive, watch } from 'vue';

// 使用 reactive 替代 ref
const items = reactive([]); // 直接是数组,不是 { value: [...] }

watch(items, (newVal, oldVal) => {
  // 使用 reactive 时,通常不需要 deep: true 就能监听到 push/pop
  console.log('数组变了 (reactive watch):', newVal);
  // 注意: newVal 和 oldVal 可能指向同一个数组代理,
  // 如果你需要比较前后差异,可能需要其他方式,比如 watch 的第三个参数 onCleanup
  // 或者在 handler 内部获取快照。但对于“发生了变化”这个信号,是可靠的。
});

function addItem() {
  // 直接在 reactive 数组上操作
  items.push({ i: items.length });
  console.log('加了一个 (reactive), 现在是:', items);
}

function removeItem() {
  // 直接在 reactive 数组上操作
  items.pop();
   console.log('减了一个 (reactive), 现在是:', items);
}
</script>

优点:

  • 代码更自然,直接操作数组即可。
  • 通常不需要 deep: true 就能响应结构变化,性能比 ref + deep: true 好。
  • 是 Vue 3 处理对象和数组响应式的推荐方式之一。

注意事项:

  • 不能直接替换 reactive 变量本身(例如 items = reactive([]) 之后再 items = []),这会丢失响应性。如果需要完全替换,可以考虑 items.length = 0; items.push(...newArray) 或者将 reactive 包裹在 ref 或另一个 reactive 对象里。
  • watchhandlernewValoldVal 可能是同一个代理对象引用,尤其是在修改操作后。

对于“只想监听数组结构变化,不在乎内部对象属性细微变动”的场景,reactive 通常是首选且最简洁的方案。

方案二:watch 搭配 getter 函数返回数组副本或长度

如果你坚持使用 ref,或者有其他原因不能用 reactive,可以利用 watch 的第一个参数可以是一个getter 函数 的特性。

原理:

watch 在检查变化时,会调用这个 getter 函数,并比较本次调用上次调用 的返回值。如果返回值不同,则触发 handler。我们可以巧妙地设计这个 getter 函数。

方式一:Getter 返回数组浅拷贝

让 getter 每次都返回数组的一个新浅拷贝 ([...items.value])。这样,只要数组的结构(元素的增删、顺序)发生变化,新拷贝和旧拷贝就会不同(引用不同,或者内容不同),从而触发 watch

代码示例 (<script setup>):

<script setup>
import { ref, watch } from 'vue';

const items = ref([]);

// watch 的第一个参数是一个 getter 函数
watch(
  () => [...items.value], // 每次检查时都创建一个新的浅拷贝数组
  (newVal, oldVal) => {
    // 只有数组结构变化 (push/pop/splice等) 导致浅拷贝不同时,才会触发
    console.log('数组结构变了 (watch getter [...]):', newVal);
    // 注意:这里的 newVal 是本次的浅拷贝,oldVal 是上次的浅拷贝
    // 它们总是不相同的引用,但可以用来对比内容差异
  }
  // 这里不需要 deep: true
);

function addItem() {
  items.value.push({ i: items.value.length });
  console.log('加了一个 (getter [...]), 现在是:', items.value);
}

function removeItem() {
  items.value.pop();
   console.log('减了一个 (getter [...]), 现在是:', items.value);
}
</script>

方式二:Getter 返回数组长度

如果你的需求仅仅 是关心数组元素的数量 是否变化(对应 push, pop, shift, unshift 和部分 splice),一个更轻量的方法是让 getter 只返回数组的 length

代码示例 (<script setup>):

<script setup>
import { ref, watch } from 'vue';

const items = ref([]);

// watch 的第一个参数是一个只返回 length 的 getter
watch(
  () => items.value.length, // 每次检查时只比较长度
  (newLength, oldLength) => {
    // 只有数组长度变化时才会触发
    console.log(`数组长度变了 (watch getter length): 从 ${oldLength}${newLength}`);
    // 可以在这里访问 items.value 获取当前完整数组
    console.log('当前数组:', items.value); 
  }
  // 这里也不需要 deep: true
);

function addItem() {
  items.value.push({ i: items.value.length });
  console.log('加了一个 (getter length), 现在是:', items.value);
}

function removeItem() {
  items.value.pop();
   console.log('减了一个 (getter length), 现在是:', items.value);
}

// 试试 splice 修改长度
function replaceItem() {
  if (items.value.length > 0) {
    items.value.splice(0, 1, { i: 'replaced' }); // 替换第一个,长度不变
    console.log('替换了一个 (splice, 长度不变), 现在是:', items.value); 
    // 这个 splice 不会触发 watch length!
  }
  if (items.value.length >= 2) {
      items.value.splice(0, 2, { i: 'new single' }); // 删除2个,插入1个,长度改变
      console.log('替换并改变长度 (splice), 现在是:', items.value); 
      // 这个 splice 会触发 watch length!
  }
}

</script>

优点:

  • 可以继续使用 ref
  • deep: true 性能好得多,因为它不关心数组内部对象的属性变化。
  • 返回 length 的方式尤其高效,只比较一个数字。

缺点/注意事项:

  • 返回浅拷贝 ([...items.value]) 的方式,每次检查都有创建新数组的开销,虽然通常比 deep: true 小,但在极端高频场景下仍需注意。
  • 返回 length 的方式无法侦测到不改变数组长度 的结构修改,例如 splice 替换等数量元素,或者直接修改某个索引处的值 items.value[0] = ...。你需要明确你的场景是否只关心长度变化。

选择哪种 getter 取决于你需要侦听的精确范围。监听 length 最轻量,监听浅拷贝 ([...]) 能覆盖更多结构变化。

方案三:使用 shallowReftriggerRef

这是一个更“手动挡”的方式,给予你最大的控制权。shallowRef 创建的 ref 只对 .value赋值 操作是响应式的,内部值的改变不会自动触发更新。但你可以手动使用 triggerRef 来强制触发依赖于这个 shallowRef 的副作用(比如 watch)。

原理:

shallowRef 包裹数组。进行 push, pop 等操作后,数组内容变了,但 shallowRef 本身不会触发更新。在你认为需要通知更新的时候,手动调用 triggerRef

代码示例 (<script setup>):

<template>
  <button @click="addItem">加一个 (shallow + trigger)</button>
  <button @click="removeItem">减一个 (shallow + trigger)</button>
  <br><br>
  <div>列表 (shallow + trigger):</div>
  <!-- 注意:模板渲染也依赖 items,所以 triggerRef 很重要 -->
  <div v-for="(item, index) in items" :key="index">{{item}}</div> 
</template>

<script setup>
import { shallowRef, watch, triggerRef } from 'vue';

// 使用 shallowRef
const items = shallowRef([]);

watch(items, (newVal) => {
  // 这个 watch 只有在 items.value 被重新赋值,
  // 或者手动调用 triggerRef(items) 时才会被触发
  console.log('数组变了 (shallowRef watch triggered):', newVal);
}, {
  // 即使是 shallowRef,如果想在 triggerRef 时拿到最新的值,
  // 可能仍需 deep: false (默认) 或者特定配置。
  // 但这里的重点是让 watch 被“唤醒”。
});

function addItem() {
  // 直接修改 shallowRef 内部数组
  items.value.push({ i: items.value.length });
  // 手动触发依赖于 items 的更新 (包括 watcher 和模板)
  triggerRef(items); 
  console.log('加了一个 (shallow + trigger), 现在是:', items.value);
}

function removeItem() {
  // 直接修改 shallowRef 内部数组
  items.value.pop();
  // 手动触发更新
  triggerRef(items);
   console.log('减了一个 (shallow + trigger), 现在是:', items.value);
}
</script>

优点:

  • 性能极佳,因为响应式追踪开销最小。
  • 完全控制何时触发更新,适合需要精细控制更新时机的复杂场景。

缺点:

  • 需要手动调用 triggerRef,容易忘记,增加了代码复杂度。
  • 不仅 watch,模板中对 items 的渲染也依赖于 triggerRef 的调用才能更新。

这种方法适合对性能有极致要求,并且不介意增加手动操作的场景。

方案四:试试 watchEffect

watchEffect 会自动追踪其回调函数中访问到的所有 响应式依赖。如果回调函数中用到了数组的结构(比如访问 items.value.length 或直接遍历 items.value),那么当这些结构改变时,watchEffect 会自动重新运行。

原理:

watchEffect 在首次运行时,会执行一次回调函数,并记录下函数执行期间碰到的所有响应式源。之后,每当这些源发生变化,回调函数就会被重新执行。

代码示例 (<script setup>):

<script setup>
import { ref, watchEffect } from 'vue';

const items = ref([]);

watchEffect(() => {
  // 在这里访问会影响结果的数组属性,比如 length
  console.log(`Effect 检测到变化, 当前长度: ${items.value.length}`);
  // 或者直接使用数组,例如用于计算某个值
  // const total = items.value.reduce((sum, item) => sum + (item.i || 0), 0);
  // console.log(`Effect 检测到变化, 当前总和: ${total}`);
  
  // 重要: 只要回调内部依赖了 items.value 的结构 (如 .length 或元素本身),
  // push/pop 等操作就能触发 watchEffect 重新运行。
});

function addItem() {
  items.value.push({ i: items.value.length });
  console.log('加了一个 (watchEffect), 现在是:', items.value);
}

function removeItem() {
  items.value.pop();
   console.log('减了一个 (watchEffect), 现在是:', items.value);
}
</script>

优点:

  • 代码简洁,不需要明确指定要 watch 的源。
  • 自动追踪依赖,比较智能。

缺点:

  • 依赖追踪是隐式的,有时可能不太好预测到底是什么导致了 watchEffect 的重新运行,尤其在复杂回调中。
  • 无法像 watch 那样方便地获取变化前后的值(oldVal)。
  • 首次渲染时就会立即执行一次。

如果你的逻辑是“当数组结构变化时,执行某段依赖这个数组的代码”,watchEffect 是一个不错的选择。

好了,下次在 Vue 3 里遇到只想监听数组 push/pop 而非内部元素深度变化的需求时,就不用再头疼 deep: true 的性能问题,也不必无奈地每次创建新数组了。试试 reactive、巧妙的 watch getter、手动 triggerRef 或者智能的 watchEffect,总有一款适合你!