监听一个对象,vue watch 新旧值怎么会相等呢

大家好,我是奈德丽。

昨天在写乘客行李选择功能的时候,被 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 这一行。

当我们监听一个对象时:

  1. effect.run() 执行我们的 getter 函数,返回 passengerStore.passengerArr
  2. 这个返回值就是 newValue
  3. 回调执行后,oldValue = newValue
  4. 由于对象是引用类型,oldValuenewValue 指向同一个内存地址

所以下次触发时,虽然对象内容变了,但 newValueoldValue 还是同一个引用!

深度监听的真相

继续看源码,发现 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。但它不会 改变 newValueoldValue 的传递方式!

我是怎么解决的

既然知道了原理,解决方案就很明确了。我用计算属性提取关键信息:

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)
}

这个实现思路是:

  1. 初始化时用 structuredClone 保存对象的深拷贝
  2. 每次变化时,先把之前的快照作为 oldValue 传给回调
  3. 然后更新快照为当前的深拷贝

用起来就是这样:

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 的源码写得还是很清晰的,推荐大家有空可以翻翻。

你们有没有遇到过类似的问题?都是怎么解决的?评论区聊聊吧。

恩恩......懦夫的味道。

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax