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

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

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

相关推荐
哒哒哒5285208 分钟前
HTTP缓存
前端·面试
T___10 分钟前
从入门到放弃?带你重新认识 Headless UI
前端·设计模式
wordbaby11 分钟前
React Router 中调用 Actions 的三种方式详解
前端·react.js
黄丽萍18 分钟前
前端Vue3项目代码开发规范
前端
葬送的代码人生20 分钟前
AI Coding→像素飞机大冒险:一个让你又爱又恨的小游戏
javascript·设计模式·ai编程
curdcv_po21 分钟前
🏄公司报销,培养我成一名 WebGL 工程师⛵️
前端
Jolyne_32 分钟前
前端常用的树处理方法总结
前端·算法·面试
wordbaby34 分钟前
后端的力量,前端的体验:React Router Server Action 的魔力
前端·react.js
Alang35 分钟前
Mac Mini M4 16G 内存本地大模型性能横评:9 款模型实测对比
前端·llm·aigc
林太白35 分钟前
Rust-连接数据库
前端·后端·rust