大家好,我是奈德丽。
昨天在写乘客行李选择功能的时候,被 Vue 的 watch 弄得一团迷糊。明明数据变了,为什么 watch 告诉我没变?Look in my eyes!
事情是这样的
我想监听乘客数组的变化,然后根据不同情况处理:
javascript
watch(() => passengerStore.passengerArr, (newVal, oldVal) => {
console.log('数组变了吗?', newVal === oldVal) // true ???
}, { deep: true })
删了一个乘客,数组从 3 个变成 2 个,结果 newVal === oldVal
还是 true。当时我的表情就是:???
第一反应是不是我哪里写错了,然后各种 console.log,各种怀疑自己...
忍不住翻源码
被这个问题折磨得不行,我决定去看看 Vue 3 的源码,看看 watch 到底是怎么工作的。
在 packages/runtime-core/src/apiWatch.ts
里找到了答案:
typescript
// Vue 3 源码片段
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
// ...
let getter: () => any
let forceTrigger = false
let isMultiSource = false
if (isRef(source)) {
getter = () => source.value
forceTrigger = shallow && isRef(source) ? source.value?.__v_isShallow : false
} else if (isReactive(source)) {
getter = () => source
deep = true
} else if (isArray(source)) {
// ...
} else if (isFunction(source)) {
getter = source // 这就是我们传入的函数
}
// 关键在这里!
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
if (!effect.active) {
return
}
if (cb) {
// 执行 getter 获取新值
const newValue = effect.run()
if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
// 注意这里:直接把 newValue 和 oldValue 传给回调
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onCleanup
])
// 更新 oldValue
oldValue = newValue
}
}
}
}
看到这里我恍然大悟!问题就在 oldValue = newValue
这一行。
当我们监听一个对象时:
effect.run()
执行我们的 getter 函数,返回passengerStore.passengerArr
- 这个返回值就是
newValue
- 回调执行后,
oldValue = newValue
- 由于对象是引用类型,
oldValue
和newValue
指向同一个内存地址
所以下次触发时,虽然对象内容变了,但 newValue
和 oldValue
还是同一个引用!
深度监听的真相
继续看源码,发现 deep: true
的作用:
typescript
// 在 packages/reactivity/src/effect.ts 中
if (deep) {
// 深度递归收集依赖
traverse(source)
}
function traverse(value: unknown, seen = new Set()) {
if (!isObject(value) || seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
// 遍历数组每一项
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isMap(value)) {
value.forEach((_, key) => {
traverse(value.get(key), seen)
})
} else if (isSet(value)) {
value.forEach(v => {
traverse(v, seen)
})
} else if (isPlainObject(value)) {
// 遍历对象每个属性
for (const key in value) {
traverse((value as any)[key], seen)
}
}
return value
}
原来 deep: true
只是让 Vue 递归地收集对象内部所有属性的依赖,这样当任何深层属性变化时都能触发 watcher。但它不会 改变 newValue
和 oldValue
的传递方式!
我是怎么解决的
既然知道了原理,解决方案就很明确了。我用计算属性提取关键信息:
javascript
// 提取我真正关心的信息
const passengerIds = computed(() =>
passengerStore.passengerArr.map(p => p.id)
)
const passengerNames = computed(() =>
passengerStore.passengerArr.map(p => p.name)
)
// 分别监听
watch(passengerIds, () => {
// ID 变了说明有人被删了或者加了
syncPassengerIds()
}, { deep: true })
watch(passengerNames, () => {
// 名字变了说明有人改信息了
initBaggageForPassenger()
}, { deep: true })
这样做的好处是,计算属性返回的是新数组,每次数据变化都会创建新的数组,所以不存在引用相同的问题。
还有一种骚操作
如果你就是想要监听整个对象,并且拿到真正的 oldVal,可以自己实现一个带快照的 watch:
javascript
function myWatch(source, callback, options = {}) {
let oldValue = null
// 先保存一个快照
if (typeof source === 'function') {
oldValue = structuredClone(source())
} else {
oldValue = structuredClone(source.value)
}
return watch(source, (newVal) => {
const snapshot = oldValue
oldValue = structuredClone(newVal) // 更新快照
callback(newVal, snapshot)
}, options)
}
这个实现思路是:
- 初始化时用
structuredClone
保存对象的深拷贝 - 每次变化时,先把之前的快照作为
oldValue
传给回调 - 然后更新快照为当前的深拷贝
用起来就是这样:
javascript
myWatch(() => passengerStore.passengerArr, (newVal, oldVal) => {
console.log('aaa!', newVal === oldVal) // false
if (oldVal.length !== newVal.length) {
console.log('bbb', oldVal.lengt, newVal.length)
}
}, { deep: true })
性能对比
看完源码后,我更加确信第一种方案的优越性:
监听计算属性:
- Vue 只需要对比基础类型数组
- 没有额外的内存开销
- 符合 Vue 响应式设计原理
自定义 myWatch:
- 每次都要深拷贝整个对象
- 内存占用翻倍(保存快照)
- 性能开销大
除非你真的需要完整的变化对比,否则还是推荐第一种方法。
写在最后
很想问一下大家也遇到过这样的问题吗?应该不只有我一个人吧 qaq
说到这里,引发了一些思考,有时候遇到问题,看文档也许会帮我们解决问题,但是看源码会让理解更深入。Vue 3 的源码写得还是很清晰的,推荐大家有空可以翻翻。
你们有没有遇到过类似的问题?都是怎么解决的?评论区聊聊吧。
恩恩......懦夫的味道。