高效监听 Vue 3 数组变化:无需 deep: true 的解决方案
2025-04-27 01:45:02
好的,这是符合要求的博客文章内容:
Vue 3 如何监听数组结构变化(无需 deep: true)
写 Vue 的时候,我们经常需要盯住(watch)一个数组,看看它里面是不是加了东西,或者少了东西。比如,列表项的增删操作后,可能需要触发一些别的逻辑。
在 Vue 2 的选项式 API 里,直接 watch
一个数组,像 push
、pop
这种直接修改原数组结构的操作,是能被侦听到的,哪怕你没加 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 里跑得好好的,addItem
或 removeItem
一执行,watch
里的 handler
就会打印信息。
但是!当你把类似逻辑搬到 Vue 3,尤其是用了组合式 API(Composition API)或者即使用了选项式 API,可能会发现:欸?不加 deep: true
,push
、pop
这些操作好像触发不了 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: true
的 watch
,因为 items
这个 ref
指向的内存地址 变了(赋了一个全新的数组)。但这种做法的代价也不小,每次操作都复制整个数组,元素少还行,元素多了效率同样堪忧,内存开销也噌噌涨。
那有没有办法,既能响应 push
、pop
、splice
这类修改数组结构的操作,又不用 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
上的 push
或 pop
方法,修改的是数组内部 ,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
对象里。 watch
的handler
中newVal
和oldVal
可能是同一个代理对象引用,尤其是在修改操作后。
对于“只想监听数组结构变化,不在乎内部对象属性细微变动”的场景,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
最轻量,监听浅拷贝 ([...])
能覆盖更多结构变化。
方案三:使用 shallowRef
和 triggerRef
这是一个更“手动挡”的方式,给予你最大的控制权。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
,总有一款适合你!