前言
提到 Vue 的 watch
和 watchEffect
,写 Vue 的前端一定比较熟悉。网上也有很多关于 watch
和 watchEffect
的区别的文章。但无非也是官网介绍的几点:
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.isFavorite
为 false
,子组件更新 isFavorite
为 true
。当父组件重新进行了传参 false
,这子组件自身的状态 isFavorite
将得不到更新,仍然为 true
,因为 watch
认为追踪的依赖没有变化,不会执行回调。
有很多办法解决这个问题,比如官网props-单向数据流所说的抛出一个事件,也就是把子组件更新的结果告诉父组件。
或者父组件重新传参之前应该重置子组件状态,例如查看详情弹框,关闭弹框时重置状态。
但是,我偶然注意到一个问题,当我将 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>
测试效果:
测试可知:
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
调用 baseWatch
,baseWatch
就是 watch
。所以 watchEffect
最终还是调用 watch
,此时 watch
的 source
传入的就是 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
为函数时,有cb
,getter
就是source
,对应watch
的情况;
const getter = source; // source 是 watch 中的 source
- 当
source
为函数时,无cb
,getter
是source()
,对应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
,这是一个属性访问操作。 -
读取顺序:
- 首先查找对象
detail
,但这只是一个对象引用。 - 然后访问
detail.isFavorite
,触发get
拦截器。
- 首先查找对象
-
-
依赖收集结果:
- 由于
getter
中只有detail.isFavorite
,track(target, 'isFavorite')
被调用。 detail
本身不会被追踪,因为没有读取detail
对象(例如Object.keys(detail)
)。
- 由于
2. watchEffect(() => detail.isFavorite)
的依赖追踪过程
watchEffect
自动运行传入的回调:
() => {
console.log('watchEffect 触发,detail.isFavorite:', detail.isFavorite);
}
-
依赖收集顺序:
-
在回调执行之前,
effect.run()
激活当前副作用,开启依赖追踪。 -
执行
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
,将该属性单独收集。
- 必须先读取对象
-
关于嵌套数据中的几个属性,watchEffect
与 watch
deep
这句话怎么理解呢?相比于递归的追踪所有属性,watchEffect 并不会追踪没用到的属性,比如上面示例中的 otherProperty
。