解释 Vue 3 源码中 `watch` 和 `watchEffect` 的实现差异,以及它们在依赖收集和副作用执行上的不同策略。

Vue 3 源码深度解析:watch vs watchEffect – 依赖收集与副作用执行的终极对决

各位听众,大家好!欢迎来到本次关于 Vue 3 源码的深度解析讲座。今天我们要聊的是 Vue 中两个至关重要的响应式 API:watchwatchEffect。它们都用于监听响应式数据的变化并执行副作用,但它们的工作方式却大相径庭。搞清楚它们之间的差异,不仅能让你更有效地使用 Vue,还能让你对 Vue 的响应式系统有更深刻的理解。

我们今天的目标是:彻底搞懂 watchwatchEffect 的内部实现,理解它们在依赖收集和副作用执行上的策略差异。

Part 1: 响应式系统的基石 – 依赖收集

在深入 watchwatchEffect 之前,我们需要回顾一下 Vue 3 响应式系统的核心概念:依赖收集。Vue 3 使用 Proxy 来拦截对响应式对象的访问和修改。当我们在组件的模板中或者在计算属性中访问响应式数据时,Vue 会追踪到这些访问,并将当前激活的 effect 函数(也就是 watchEffectwatch 的回调函数)与被访问的响应式数据建立关联,这就是依赖收集。

// 模拟 Vue 3 的依赖收集
let activeEffect = null; // 当前激活的 effect
const targetMap = new WeakMap(); // 存储响应式对象与依赖关系的映射

function track(target, key) {
  if (activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      depsMap = new Map();
      targetMap.set(target, depsMap);
    }

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

    deps.add(activeEffect); // 收集依赖
    activeEffect.deps.push(deps); // 反向记录依赖,方便清理
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }

  const deps = depsMap.get(key);
  if (!deps) {
    return;
  }

  const effectsToRun = new Set(deps); // 避免循环触发
  effectsToRun.forEach(effect => {
    effect(); // 触发 effect
  });
}

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      track(target, key); // 依赖收集
      return res;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const res = Reflect.set(target, key, value, receiver);
      if (value !== oldValue) {
        trigger(target, key); // 触发更新
      }
      return res;
    }
  });
}

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    fn(); // 执行副作用,触发依赖收集
    activeEffect = null;
  };

  effectFn.deps = []; // 存储 effect 依赖的 deps

  function cleanup(effectFn) {
      for (let i = 0; i < effectFn.deps.length; i++) {
          const deps = effectFn.deps[i];
          deps.delete(effectFn);
      }
      effectFn.deps.length = 0;
  }

  effectFn(); // 立即执行一次
  return effectFn;
}

// 示例
const data = reactive({ count: 0 });
effect(() => {
  console.log("count:", data.count);
});
data.count++; // 触发更新

这段代码模拟了 Vue 3 响应式系统的核心逻辑。reactive 函数将普通对象转换为响应式对象,track 函数负责依赖收集,trigger 函数负责触发更新, effect 函数负责执行副作用。

Part 2: watchEffect – 立即执行,自动追踪

watchEffect 的核心特点是立即执行自动追踪依赖。它会在第一次调用时立即执行传入的回调函数,并在执行过程中自动收集所有被访问的响应式依赖。只要这些依赖发生变化,watchEffect 就会重新执行。

// Vue 3 源码 (简化版)
function watchEffect(effect, options) {
  return doWatch(effect, null, options);
}

function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source); // 递归访问所有属性,触发依赖收集
  }

  let cleanup;
  const onInvalidate = (fn) => {
    cleanup = fn;
  };

  let job = () => {
    if (cleanup) {
      cleanup(); // 清理上一次的副作用
    }
    effect();
  };

  // 创建 scheduler,控制更新时机
  const scheduler = (job) => {
      if (flush === 'post') {
          queuePostRenderEffect(job)
      } else {
          job()
      }
  }

  const runner = effect(job, {
    scheduler,
    onTrack,
    onTrigger,
    onStop: () => {}
  });

  return () => {
      // ... stop the watcher
  }
}

function traverse(value) {
  if (typeof value === 'object' && value !== null) {
    for (const key in value) {
      traverse(value[key]);
    }
  }
  return value;
}
  • getter: watchEffect 内部通过 getter 函数来执行传入的回调函数。如果传入的是一个函数,则直接使用该函数;如果传入的是一个响应式对象,则使用 traverse 函数递归地访问该对象的所有属性,从而触发依赖收集。
  • effect: watchEffect 内部使用 Vue 3 的 effect 函数来创建响应式 effect。这个 effect 会自动追踪依赖,并在依赖发生变化时重新执行。
  • cleanup: watchEffect 允许你在回调函数中注册一个 onInvalidate 函数。这个函数会在下一次回调函数执行之前被调用,用于清理上一次的副作用。
  • scheduler: 用于控制更新时机,可以通过 flush 选项设置更新的时机(prepostsync)。

示例:

<template>
  <div>
    <p>Count: {{ count }}</p>
  </div>
</template>

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

const count = ref(0);

watchEffect(() => {
  console.log('Count changed:', count.value);
});

setInterval(() => {
  count.value++;
}, 1000);
</script>

在这个例子中,watchEffect 会立即执行一次,输出 "Count changed: 0"。然后,每当 count.value 发生变化时,watchEffect 都会重新执行,输出新的 count.value 值。

Part 3: watch – 精确控制,按需监听

watchEffect 相比,watch 提供了更精确的控制。它允许你显式地指定要监听的响应式数据源,并在数据源发生变化时执行回调函数。watch 还可以访问新值和旧值,并提供更多选项来控制行为。

// Vue 3 源码 (简化版)

function watch(source, cb, options) {
  return doWatch(source, cb, options)
}

function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = {}) {
  let getter;
  let forceTrigger = false;
  if (isRef(source)) {
    getter = () => source.value;
    forceTrigger = isShallow(source)
  } else if (isReactive(source)) {
    getter = () => source;
    deep = true
  } else if (isArray(source)) {
    forceTrigger = source.some(s => isReactive(s) || isRef(s))
    getter = () => source.map(s => {
      if (isRef(s)) {
        return s.value
      } else if (isReactive(s)) {
        return traverse(s)
      } else if (isFunction(s)) {
        return callWithErrorHandling(s, null, null, 4 /* WATCH */)
      } else {
        warnInvalidSource(s)
        return undefined
      }
    })
  } else if (isFunction(source)) {
    if (cb) {
      // watch(fn, cb)
      getter = () => callWithErrorHandling(source, null, null, 2 /* GETTER */)
    } else {
      // watchEffect(fn)
      getter = () => {
        if (cleanup) {
          cleanup()
        }
        return callWithErrorHandling(source, null, null, 2 /* GETTER */)
      }
    }
  } else {
    getter = NOOP
    warnInvalidSource(source)
  }

  let cleanup;
  const onInvalidate = (fn) => {
    cleanup = fn;
  };

  let oldValue = isArray(source) ? [] : undefined;
  let applyingCb = false;
  const job = () => {
    if (cleanup) {
      cleanup(); // 清理上一次的副作用
    }
    const newValue = runner();
    if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
      applyingCb = true;
      callWithAsyncErrorHandling(cb, null, 3 /* WATCH_CALLBACK */, [
        newValue,
        oldValue === NO_CHANGE ? undefined : oldValue,
        onInvalidate
      ]);
      oldValue = newValue;
      applyingCb = false;
    }
  };

   // 创建 scheduler,控制更新时机
  const scheduler = (job) => {
      if (flush === 'post') {
          queuePostRenderEffect(job)
      } else {
          job()
      }
  }

  const runner = effect(getter, {
    lazy: true,
    scheduler,
    onTrack,
    onTrigger,
    onStop: () => {}
  });

  if (immediate) {
    job();
  } else {
    oldValue = runner();
  }

  return () => {
      // ... stop the watcher
  }
}
  • source: watch 的第一个参数 source 可以是一个 ref、一个 reactive 对象、一个 getter 函数,或者一个包含这些类型的数组。
  • cb: watch 的第二个参数 cb 是回调函数,它会在 source 发生变化时被调用。回调函数接收两个参数:新值和旧值。
  • options: watch 的第三个参数 options 是一个可选的对象,用于配置 watch 的行为。常用的选项包括:
    • immediate: 是否在 watch 创建时立即执行回调函数。
    • deep: 是否深度监听响应式对象的变化。
    • flush: 控制回调函数的执行时机 (prepostsync)。
    • onTrack: 用于调试依赖收集过程。
    • onTrigger: 用于调试触发更新过程。

示例:

<template>
  <div>
    <p>Count: {{ count }}</p>
    <input type="text" v-model="message">
  </div>
</template>

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

const count = ref(0);
const message = ref('');

watch(count, (newValue, oldValue) => {
  console.log('Count changed:', newValue, oldValue);
});

watch(message, (newValue, oldValue) => {
  console.log('Message changed:', newValue, oldValue);
});

setInterval(() => {
  count.value++;
}, 1000);
</script>

在这个例子中,我们使用 watch 分别监听 countmessage 的变化。当 count 发生变化时,回调函数会输出新值和旧值。当 message 发生变化时,回调函数也会输出新值和旧值。

Part 4: watch vs watchEffect – 关键差异对比

特性 watchEffect watch
依赖收集 自动追踪所有被访问的响应式依赖 需要显式指定要监听的响应式数据源
首次执行 立即执行 默认不立即执行,可以通过 immediate 选项设置为立即执行
新旧值访问 无法直接访问新旧值 可以访问新值和旧值
灵活性 简单易用,适合简单的副作用场景 更加灵活,可以精确控制监听的数据源和回调函数的行为
适用场景 不需要访问新旧值,只需要根据依赖自动执行副作用 需要访问新旧值,或者需要精确控制监听的数据源和回调函数的行为的场景
源码实现 内部基于 effect 函数,自动追踪依赖 内部基于 effect 函数,但需要根据 source 类型进行不同的处理

依赖收集策略的不同:

  • watchEffect: 采用“先执行,后收集”的策略。它会先执行回调函数,然后在执行过程中自动收集所有被访问的响应式依赖。这意味着,只有在回调函数中实际访问的响应式数据才会被追踪。

  • watch: 采用“显式指定,按需监听”的策略。它需要你显式地指定要监听的响应式数据源。Vue 只会监听你指定的数据源的变化,而不会自动追踪回调函数中访问的其他响应式数据。

副作用执行策略的不同:

  • watchEffect: 副作用的执行完全依赖于依赖的变化。只要任何一个被追踪的依赖发生变化,watchEffect 就会重新执行。

  • watch: 副作用的执行取决于你指定的 source 是否发生变化。如果 source 是一个 ref 或 reactive 对象,那么只有当它的值发生变化时,回调函数才会被执行。如果 source 是一个 getter 函数,那么只有当 getter 函数的返回值发生变化时,回调函数才会被执行。

Part 5: 源码细节深入剖析

让我们更深入地看看 watchwatchEffect 的源码,以便更好地理解它们的工作方式。

watchEffect 的源码分析:

function watchEffect(effect, options) {
  return doWatch(effect, null, options);
}

watchEffect 实际上只是 doWatch 函数的一个简化版本,它将回调函数作为 source 传递给 doWatch,并将 cb 设置为 null

watch 的源码分析:

watch 的源码更加复杂,因为它需要处理各种不同类型的 source

  • isRef(source): 如果 source 是一个 ref,那么 getter 函数会返回 source.value
  • isReactive(source): 如果 source 是一个 reactive 对象,那么 getter 函数会返回 source,并且 deep 选项会被设置为 true,以便深度监听对象的变化。
  • isArray(source): 如果 source 是一个数组,那么 getter 函数会返回一个包含数组中所有元素的值的数组。如果数组中包含 reactive 对象,那么会递归地访问这些对象的所有属性,以便触发依赖收集。
  • isFunction(source): 如果 source 是一个函数,那么 getter 函数会执行该函数,并返回其返回值。

doWatch 函数内部,会根据 source 的类型创建不同的 getter 函数,并将 getter 函数传递给 effect 函数。当 getter 函数的返回值发生变化时,effect 函数会重新执行,并调用回调函数 cb

Part 6: 最佳实践与注意事项

  • 选择合适的 API: 根据你的需求选择合适的 API。如果只需要根据依赖自动执行副作用,而不需要访问新旧值,那么可以使用 watchEffect。如果需要精确控制监听的数据源和回调函数的行为,或者需要访问新旧值,那么可以使用 watch

  • 避免过度依赖收集: watchEffect 会自动追踪所有被访问的响应式依赖,这可能会导致过度依赖收集,从而影响性能。尽量避免在 watchEffect 的回调函数中访问不必要的响应式数据。

  • 手动停止 watcher: 当不再需要监听数据变化时,应该手动停止 watcher,以避免内存泄漏。watchwatchEffect 都会返回一个停止函数,你可以调用该函数来停止 watcher。

  • 谨慎使用 deep 选项: 深度监听响应式对象的变化可能会带来性能问题,因为它需要递归地访问对象的所有属性。只有在必要时才使用 deep 选项。

  • 理解 flush 选项: flush 选项可以控制回调函数的执行时机。pre 表示在组件更新之前执行回调函数,post 表示在组件更新之后执行回调函数,sync 表示同步执行回调函数。根据你的需求选择合适的 flush 选项。

总结:

watchwatchEffect 是 Vue 3 中非常重要的响应式 API。watchEffect 简单易用,适合简单的副作用场景,但可能会导致过度依赖收集。watch 更加灵活,可以精确控制监听的数据源和回调函数的行为,但需要手动指定要监听的数据源。理解它们之间的差异,可以帮助你更有效地使用 Vue,并编写出更健壮、更高效的代码。

希望今天的讲座对大家有所帮助!感谢各位的聆听。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注