监听一个对象,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. 由于对象是引用类型,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)
}

这个实现思路是:

  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 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理7 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
专业程序开发源7 小时前
springboot简历管理系统81389-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·php·课程设计
专业程序开发源7 小时前
springboot社区养老系统44071-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·php·课程设计
小呆呆6668 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
凤城老人8 小时前
从 PyQt6 到 Electron:给 Edge TTS 做一个“多角色配音机“的踩坑手记
javascript·typescript·electron
Dovis(誓平步青云)8 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan8 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_9 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒9 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端