「Vue系列」之Watch、WatchEffect不完全指北!

2,123 阅读6分钟

💥 前言

    因为一直使用Vue搬砖,我觉得自己已经是一个熟练的Api调用工程师了,前两天看到一个Watch、WatchEffect相关的问题却给我好好上了一课。😅

    看下面的代码问:为什么Watch能侦听到,WatchEffect却不行❓

let test = ref({});
watchEffect(() => {
  console.log(test.value);
});
watch(
  () => test.value,
  (newVal) => {
    console.log(newVal);
  },
  {
    deep: true,
  },
);
const testChange = () => {
  test.value.aa = 1;
};
// 打印结果:Proxy {aa: 1}

Tipes:各位大佬可以停下来思考一下,然后带着自己的想法往下看

⚙️ 缘由

    当时下面有一个答案是这样说的:
    Vue内部应该是做了优化,不会盲目检测内部数据,所以WatchEffect侦听不到,而watch 应该是第一个参数能接收对象和数组,所以能侦听到。

    显然这种模棱两可的回答不能说服我,所以我就去找春哥问了问,然后我自以为我理解了春哥给我的答案,然后写出了这样的话:
    在上面代码的写法中,test.value里面包含的只是一个对象的指针,在下面的方法里面修改内容,修改的是内存地址的内容,指针实际上并没有发生变化,而watchEffect收集的依赖就是这个指针,所以回调函数不会被执行。

    「更新:」根据骑自行车大佬的评论,然后我又重新看了看春哥的答复,是我思想出了问题😂,最后的结论是这样的:
    当在代码里面打印 test.value,最多打印出来的就是处理过后的Proxy对象,实际上并没有触发test的 getter,自然也不会添加watcher,所以就不会修改也不会触发watchEffect的回调函数。
验证一下:

const obj = {};
const proObj = new Proxy(obj, {
        get: function() {
                console.log('00')
        }
});
console.log(proObj); // Proxy {}
console.log(proObj.a); // 00 undefined

Tipes:春哥的掘金地址:摸鱼的春哥,关注春哥不仅能解决技术问题,还有精彩的故事可以看哦🤩

    我走神的时候敲出来了这样的代码,结果发现居然也能行🤔️?

watch(test.value, (newVal) => {
  console.log(newVal);
});

    Tipes: Js数据类型分为基本数据类型和引用数据类型。基本数据类型一般保存在栈内存中,引用数据类型分为两部分存储:一部分为指针,一般保存在栈内存中,一部分为内容,一般保存在堆内存中,指针指向内容所在的对内存地址。由此也引发出了深浅拷贝的问题。对于深浅拷贝还掌握不够彻底小伙伴可以看看这两篇文章:

十分钟带你手撕一份"渐进式"JS深拷贝
最新HTML规范——structuredClone深拷贝函数,能取代JSON或者lodash吗?

    为了避免以后还出现这样的问题,所以我们一块去手撕一下源码,弄清楚他是怎么实现的。

🔧 解析

    github1s源码地址
    如果说排除响应式的实现,只看Watch、WatchEffect还是比较简单的:

export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

// ...省略watchEffect的别称

// ...省略watch的重载

// implementation
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>
): WatchStopHandle {
  if (__DEV__ && !isFunction(cb)) {
    // ...省略报错信息
  }
  return doWatch(source as any, cb, options)
}

    WatchEffect和Watch共用doWatch函数实现,接下来我们就带着上面的问题去看看doWatch里面是怎么做具体处理的:

// 简化版
function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {

  // ...省略错误处理

  const instance = currentInstance
  let getter: () => any
  let forceTrigger = false
  let isMultiSource = false

  if (isRef(source)) { // 判断是否是ref对象
    getter = () => source.value 则取得其value
    forceTrigger = isShallow(source)
  } else if (isReactive(source)) { // 判断是否是reactive对象
    getter = () => source
    deep = true // 重点!!!!!!!!!!
  } else if (isArray(source)) {
    isMultiSource = true
    forceTrigger = source.some(isReactive)
    getter = () =>
      source.map(s => {
        if (isRef(s)) {
          return s.value
        } else if (isReactive(s)) {
          return traverse(s)
        } else if (isFunction(s)) {
          return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
        } else {
          __DEV__ && warnInvalidSource(s)
        }
      })
  } else if (isFunction(source)) {
    if (cb) { // 第二个参数有值是Watch
      // getter with cb
      getter = () =>
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else { // 没有值则是WatchEffect
      // no cb -> simple effect
      getter = () => {
        if (instance && instance.isUnmounted) {
          return
        }
        if (cleanup) { // 如果清楚函数存在,先清除
          cleanup()
        }
        return callWithAsyncErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onCleanup]
        )
      }
    }
  } else {
    getter = NOOP
    __DEV__ && warnInvalidSource(source)
  }

  // 省略向下兼容

  if (cb && deep) { // deep 深度侦听
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }


  // 省略SSR操作
  // 省略设置清除监听
  

📑 总结

    看一下简化后的代码,是不是抛开响应式的实现其实Watch和WatchEffect还是很简单的。😄再去看上面这样也行的代码是怎么实现的,关键就是在下面这一步:

if (isReactive(source)) { // 判断是否是reactive对象
    getter = () => source
    deep = true // 重点!!!!!!!!!!
  } 

    当检测到是reactive对象的时候,Vue会帮我们把deep设置为true,开启深度侦听,所以Watch的第一个参数按照上面代码的写法也会被侦听到。
    这时候就会有小伙伴说了,之前的代码明明用的是ref声明的,为什么不走isRef判断,而是走isReactive呐?这就又要涉及到Ref和Reactive的源码了:

Ref源码地址
Reactive源码地址

    有兴趣的小伙伴可以自行了解,这里只放一个关键的地方:当ref入参为对象时会走reactiveproxy的代理,否则走RefImplsetget方法。

const convert = <T extends unknown>(val: T): T => isObject(val) ? reactive(val) : val

📋 收尾

    总体来说,光看Watch、WatchEffect的实现对于各位大佬来说还是洒洒水。不过从面试角度来说,常和Watch一起出现的就是Computed了
    当依赖变量发生变化的时候,计算属性的watcher会将dirty修改为true,代表数据已经脏,但是不会立即求值,当获取计算属性内容的时候「通过 this.dep.subs.length 判断有没有订阅者」,会去判断dirty的值,如果为true,则重新求值「重新求值之后会比较新老值,如果没有变化则不会重新渲染(性能优化)」,如果为false则返回缓存值。

所以大佬们有空了也可以看看Computed的实现,再看看响应式的实现,到时候吊打面试官哈哈哈。

有帮助记得帮我点点赞哦,最后祝各位大佬学习进步,事业有成!🎆🎆

Tipes:往期内容
「Vue系列」之面试官问NextTick是想考察什么?
面试的时候面试官是这样问我Js基础的,角度真刁钻
「算法基础」之二叉树的遍历和搜索
「Vue系列」使用Teleport封装一个弹框组件
「Vue系列」为什么用Proxy取代Object.defineProperty?