你真的了解 watchEffect 吗?深入理解 watch 和 watchEffect

1,025 阅读7分钟

前言

提到 Vue 的 watchwatchEffect,写 Vue 的前端一定比较熟悉。网上也有很多关于 watchwatchEffect 的区别的文章。但无非也是官网介绍的几点:

watch 和 watchEffect 都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式:

  • watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。
  • watch 会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。
  • watch 回调函数中可以获取到追踪依赖的新值和旧值,watchEffect 不行。
  • watchEffect 会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。

总之,watch 的使用更加精准和灵活,而 watchEffect 有时会很方便。

这篇文章当然不是要全面的讲 watch 和 watchEffect 的用法与区别,而是深入了解一下 watchEffect

问题

问题源于这样一段简单的代码,子组件状态 isFavorite 取自 props.detail.isFavorite,同时使用 watch 侦听父组件传参的变化:

const isFavorite = ref(props.detail.isFavorite);

watch(
  () => props.detail.isFavorite,
  (newVal, oldVal) => {
    if (oldVal !== newVal) {
      console.log("watch 触发,oldVal: ", oldVal, ",newVal: ", newVal);
      isFavorite.value = newVal;
    }
  }
);

但是这里面有一个问题,当子组件更新了状态 isFavorite,就可能扰乱 watch 追踪依赖。例如父组件传参 detail.isFavoritefalse,子组件更新 isFavoritetrue。当父组件重新进行了传参 false,这子组件自身的状态 isFavorite 将得不到更新,仍然为 true,因为 watch 认为追踪的依赖没有变化,不会执行回调。

有很多办法解决这个问题,比如官网props-单向数据流所说的抛出一个事件,也就是把子组件更新的结果告诉父组件。

image.png

或者父组件重新传参之前应该重置子组件状态,例如查看详情弹框,关闭弹框时重置状态。

但是,我偶然注意到一个问题,当我将 watch 替换为 watchEffect 时,问题就解决了!

watchEffect(() => {
  console.log('watchEffect 触发,props.detail.isFavorite:', props.detail.isFavorite);
  isFavorite.value = props.detail.isFavorite
})

问题虽然解决了,我却更加的迷惑和好奇了,因为,它们实现的功能不是一样的吗?不考虑 watch 能拿到新值和旧值、不会立即执行等差别,它们不是完全一样的吗?

但是现在,只要这么写,使用 watchEffect,即使两次传参 props.detail.isFavorite 的值一样,也会触发 watchEffect 的执行。

这是什么原因呢?

测试

父组件传参 props.detail.isFavorite 虽然没变,但是 props.detail 进行了改变。watch 是只追踪明确的数据源,watchEffect 自动追踪能访问到的响应式属性,关于追踪的依赖,它们的本质实现不是一样的吗?

结合 ChatGPT 和动手,进行了以下的尝试:

父组件:

<template>
  <div class="parent">
    <h1>父组件</h1>
    <div>
      <p>
        <button type="button" @click="updateIsFavorite">
          父组件更新 isFavorite
        </button>
        {{ detail.isFavorite }}
      </p>
      <p>
        <button type="button" @click="updateOtherProperty">
          父组件更新 otherProperty
        </button>
        {{ detail.otherProperty }}
      </p>
      <p>
        <button type="button" @click="updateDetail">父组件更新 detail</button>
        {{ detail }}
      </p>
    </div>
    <Child :detail="detail" />
  </div>
</template>

<script setup>
import Child from "./Child.vue";
import { ref } from "vue";

const detail = ref({
  isFavorite: false,
  otherProperty: "test",
});

function updateIsFavorite() {
  detail.value.isFavorite = !detail.value.isFavorite;
}

function updateOtherProperty() {
  detail.value.otherProperty = detail.value.otherProperty + "1";
}

function updateDetail() {
  detail.value = {
    isFavorite: false,
    otherProperty: "test",
  };
}
</script>

<style scoped>
.parent {
  border: 1px solid purple;
  padding: 20px;
}
</style>

子组件:

<template>
  <div class="child">
    <h2>子组件</h2>
    <div>
      <p>
        <button type="button" @click="updateIsFavorite">更新 favorite</button>
        {{ isFavorite }}
      </p>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, watchEffect } from "vue";

const { detail } = defineProps({
  detail: {
    type: Object,
    default: null,
  },
});

const isFavorite = ref(detail.isFavorite);

watchEffect(() => {
  console.log('watchEffect 触发,detail.isFavorite:', props.detail.isFavorite);
  isFavorite.value = props.detail.isFavorite
})

watchEffect(() => {
  const { isFavorite } = detail
  console.log('watchEffect 触发,detail.isFavorite:', isFavorite);
  // isFavorite.value = isFavorite
})

watch(
  () => detail.isFavorite,
  (newVal, oldVal) => {
    if (oldVal !== newVal) {
      console.log("watch 触发,oldVal: ", oldVal, ",newVal: ", newVal);
      isFavorite.value = newVal;
    }
  }
);

function updateIsFavorite() {
  isFavorite.value = !isFavorite.value;
}
</script>

<style scoped>
.child {
  border: 1px solid skyblue;
}
</style>

测试效果:

image.png

测试可知:

  • watch 可以监听 detail.isFavorite 的变化,不能监听 detail 其他属性的变化,也不能直接监听 detail 的变化,也就是 detail 的改变如果改变了 detail.isFavorite,回调会执行,否则不会。
  • watchEffect 可以监听 detail.isFavorite 的变化,不能监听 detail 其他属性的变化,但是会直接监听 detail 的变化,也就是只要 detail 的引用地址改变,回调就会执行,即使 detail.isFavorite 的值并没有变化。

源码分析

runtime-core/apiWatch 中的 watchEffect

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

doWatch 的基本逻辑

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ,
): WatchHandle {
  const baseWatchOptions: BaseWatchOptions = extend({}, options)

  const watchHandle = baseWatch(source, cb, baseWatchOptions)

  return watchHandle
}

doWatch 调用 baseWatchbaseWatch 就是 watch。所以 watchEffect 最终还是调用 watch,此时 watchsource 传入的就是 effect

reactivity/watch 的实现

export function watch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb?: WatchCallback | null,
  options: WatchOptions = EMPTY_OBJ,
): WatchHandle {
  const { immediate, deep, once, scheduler, augmentJob, call } = options

  const reactiveGetter = (source: object) => {
    // traverse will happen in wrapped getter below
    if (deep) return source
    // for `deep: false | 0` or shallow reactive, only traverse root-level properties
    if (isShallow(source) || deep === false || deep === 0)
      return traverse(source, 1)
    // for `deep: undefined` on a reactive object, deeply traverse all properties
    return traverse(source)
  }

  let effect: ReactiveEffect
  let getter: () => any
  let cleanup: (() => void) | undefined
  let boundCleanup: typeof onWatcherCleanup
  let forceTrigger = false
  let isMultiSource = false

  if (isRef(source)) {
    getter = () => source.value
    forceTrigger = isShallow(source)
  } else if (isReactive(source)) {
    getter = () => reactiveGetter(source)
    forceTrigger = true
  } else if (isArray(source)) {
    isMultiSource = true
    forceTrigger = source.some(s => isReactive(s) || isShallow(s))
    getter = () =>
      source.map(s => {
        if (isRef(s)) {
          return s.value
        } else if (isReactive(s)) {
          return reactiveGetter(s)
        } else if (isFunction(s)) {
          return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s()
        } else {
          __DEV__ && warnInvalidSource(s)
        }
      })
  } else if (isFunction(source)) {
    if (cb) {
      // getter with cb
      getter = call
        ? () => call(source, WatchErrorCodes.WATCH_GETTER)
        : (source as () => any)
    } else {
      // no cb -> simple effect
      getter = () => {
        if (cleanup) {
          pauseTracking()
          try {
            cleanup()
          } finally {
            resetTracking()
          }
        }
        const currentEffect = activeWatcher
        activeWatcher = effect
        try {
          return call
            ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup])
            : source(boundCleanup)
        } finally {
          activeWatcher = currentEffect
        }
      }
    }
  } else {
    getter = NOOP
    __DEV__ && warnInvalidSource(source)
  }

  if (cb && deep) {
    const baseGetter = getter
    const depth = deep === true ? Infinity : deep
    getter = () => traverse(baseGetter(), depth)
  }


  effect = new ReactiveEffect(getter)

  effect.run()
}

删掉了部分代码,重点看下 getter 的处理:

  • source 为函数时,有 cbgetter 就是 source,对应 watch 的情况;
const getter = source;  // source 是 watch 中的 source
  • source 为函数时,无 cbgettersource(),对应 watchEffect 的情况;
const getter = () => {
  return source();  // source 是 watchEffect 中的回调
};

ReactiveEffect 构造与执行

effect = new ReactiveEffect(getter) 这一句是对 effect 的构造。

class ReactiveEffect {
  constructor(public fn) {}

  run() {
    activeEffect = this;
    return this.fn(); // 执行 getter
  }
}
  • 构造时保存 getter 函数。
  • 调用 run() 时执行 getter,激活当前副作用。

getter 执行时的依赖追踪

对于 watch

getter = () => detail.isFavorite;

对于 watchEffect

getter = () => {
  console.log('watchEffect 触发,detail.isFavorite:', detail.isFavorite);
  isFavorite.value = detail.isFavorite;
}

依赖收集

const handler = {
  get(target, key, receiver) {
    const effect = activeEffect;  // 当前正在执行的 effect 函数
    if (effect) {
      track(target, key);  // 触发 track,追踪依赖
    }
    return Reflect.get(...arguments);  // 返回原始属性值
  }
};
function track(target, key) {
  if (!activeEffect) return;

  // 收集依赖
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

  dep.add(activeEffect);  // 将当前 effect 加入依赖
}

对比核心区别分析: watch vs watchEffect

1. watch(() => detail.isFavorite) 的依赖追踪过程

  • watch 接受一个显式 getter,即 () => detail.isFavorite

  • getter() 被执行时:

    • 仅执行 detail.isFavorite,这是一个属性访问操作。

    • 读取顺序

      1. 首先查找对象 detail,但这只是一个对象引用。
      2. 然后访问 detail.isFavorite,触发 get 拦截器。
  • 依赖收集结果

    • 由于 getter 中只有 detail.isFavoritetrack(target, 'isFavorite') 被调用。
    • detail 本身不会被追踪,因为没有读取 detail 对象(例如 Object.keys(detail))。

2. watchEffect(() => detail.isFavorite) 的依赖追踪过程

  • watchEffect 自动运行传入的回调:
() => {
  console.log('watchEffect 触发,detail.isFavorite:', detail.isFavorite);
}
  • 依赖收集顺序

    1. 在回调执行之前,effect.run() 激活当前副作用,开启依赖追踪。

    2. 执行 detail.isFavorite

      • 由于是直接运行回调,detail 必须先被读取,才能访问其属性 isFavorite

      • 两次触发 get 拦截器

        • 第一次读取 detail(对象引用,本质上是 get(detail))。
        • 第二次读取 detail.isFavorite(属性读取,本质上是 get(detail, 'isFavorite'))。
  • 依赖收集结果

    • track(detail):读取对象 detail 时,Vue 会收集整个对象作为依赖。
    • track(detail, 'isFavorite'):读取属性时,再次收集该属性。

为什么有这个行为差异?

关键点:属性访问的“范围”

  • watch:精准依赖追踪

    • 只收集显式 getter 中访问的属性。
    • 执行 () => detail.isFavorite 时,仅执行了对 detail.isFavorite 的属性访问,而没有读取整个对象 detail
  • watchEffect:自动依赖收集(包含隐式对象读取)

    • 直接运行回调,Vue 无法知道用户会读取哪些属性。

    • 读取 detail.isFavorite 的必要前提

      • 必须先读取对象 detail
      • track() 逻辑中,Vue 将 detail 标记为整个对象的依赖。
      • 然后读取属性 isFavorite,将该属性单独收集。

关于嵌套数据中的几个属性,watchEffectwatch deep

image.png

这句话怎么理解呢?相比于递归的追踪所有属性,watchEffect 并不会追踪没用到的属性,比如上面示例中的 otherProperty