Vue 3 源码深度解析:watch
vs watchEffect
– 依赖收集与副作用执行的终极对决
各位听众,大家好!欢迎来到本次关于 Vue 3 源码的深度解析讲座。今天我们要聊的是 Vue 中两个至关重要的响应式 API:watch
和 watchEffect
。它们都用于监听响应式数据的变化并执行副作用,但它们的工作方式却大相径庭。搞清楚它们之间的差异,不仅能让你更有效地使用 Vue,还能让你对 Vue 的响应式系统有更深刻的理解。
我们今天的目标是:彻底搞懂 watch
和 watchEffect
的内部实现,理解它们在依赖收集和副作用执行上的策略差异。
Part 1: 响应式系统的基石 – 依赖收集
在深入 watch
和 watchEffect
之前,我们需要回顾一下 Vue 3 响应式系统的核心概念:依赖收集。Vue 3 使用 Proxy 来拦截对响应式对象的访问和修改。当我们在组件的模板中或者在计算属性中访问响应式数据时,Vue 会追踪到这些访问,并将当前激活的 effect 函数(也就是 watchEffect
或 watch
的回调函数)与被访问的响应式数据建立关联,这就是依赖收集。
// 模拟 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
选项设置更新的时机(pre
、post
、sync
)。
示例:
<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
: 控制回调函数的执行时机 (pre
、post
、sync
)。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
分别监听 count
和 message
的变化。当 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: 源码细节深入剖析
让我们更深入地看看 watch
和 watchEffect
的源码,以便更好地理解它们的工作方式。
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,以避免内存泄漏。
watch
和watchEffect
都会返回一个停止函数,你可以调用该函数来停止 watcher。 -
谨慎使用
deep
选项: 深度监听响应式对象的变化可能会带来性能问题,因为它需要递归地访问对象的所有属性。只有在必要时才使用deep
选项。 -
理解
flush
选项:flush
选项可以控制回调函数的执行时机。pre
表示在组件更新之前执行回调函数,post
表示在组件更新之后执行回调函数,sync
表示同步执行回调函数。根据你的需求选择合适的flush
选项。
总结:
watch
和 watchEffect
是 Vue 3 中非常重要的响应式 API。watchEffect
简单易用,适合简单的副作用场景,但可能会导致过度依赖收集。watch
更加灵活,可以精确控制监听的数据源和回调函数的行为,但需要手动指定要监听的数据源。理解它们之间的差异,可以帮助你更有效地使用 Vue,并编写出更健壮、更高效的代码。
希望今天的讲座对大家有所帮助!感谢各位的聆听。