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

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

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

相关推荐
怕浪猫6 小时前
FDE 如何正确解决现场项目工程性的问题
前端·后端·ai编程
西瓜太郎12346 小时前
外层模型卡片与分组详情成功率不一致,应该怎么排查?
前端·typescript·go·软件工程·react
GreenTea7 小时前
vLLM 与 SGLang KV Cache 底层实现机制深度调研报告
前端·后端·算法
毕业设计7039 小时前
(免费领源码) 基于微信小程序的预制菜商城的设计与实现25172-java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
vue.js·python·mysql·微信小程序·pycharm·微信开发者工具·推荐算法
xifangge202510 小时前
AGENTS.md 怎么写?涵盖 Java、Python、Vue、Go 的 8 套开箱即用模板
java·vue.js·python
几何心凉10 小时前
没有万能模型:我用 AiiOnly Token Plan,把多款大模型的长处拼进同一个项目
前端
kyriewen10 小时前
Claude Code 的额度今天缩水了17%——官方公告上写的是"永久提高25%"
前端·ai编程·claude
雪芽蓝域zzs10 小时前
vue解构平铺VS对象包裹
前端·javascript·vue.js
风骏时光牛马11 小时前
从零到实战,完整AI学习路线
前端
三十而立洋12 小时前
深入理解 Monorepo:子包安装依赖
前端·前端工程化