watch、watchEffect 用法详解 - vue3
watchEffect
watchEffect
接收一个回调函数,会在组件渲染时立即调用一次回调,同时自动追踪回调中的响应式依赖,当响应式依赖数据变更时会重新执行回调。
第一个参数,侦听器回调,该回调提供了一个参数
onCleanup
(可清除过期的副作用函数)参考 过期的副作用
第二个为可选参数,是一个配置对象,有以下属性:
立即调用
侦听器回调默认会在 组件挂载前 立即调用一次,(可改变配置对象属性 flush
更改执行时机,后边会讲到:回调触发时机)
例子
const person = reactive({
count: 0,
});
onBeforeMount(() => {
console.log("onBeforeMount");
});
onMounted(() => {
console.log("onMounted");
});
watchEffect(
() => {
const el = document.querySelector(".h1");
console.log(el);
console.log(person.count);
}
);
例子中,回调会立即执行,并且是在 onBeforeMount
之前执行的,所以获取不到 DOM .h1
元素。而且执行期间,它会自动追踪 person.count
作为依赖。person.count
变化会再次执行回调。
异步回调
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await
正常工作前访问到的属性才会被追踪。
例子
const API_URL = `https://api.github.com/repos/vuejs/core/commits?per_page=3&sha=`
const branches = ['main', 'v2-compat']
const currentBranch = ref(branches[0])
const count = ref(0);
watchEffect(async () => {
commits.value = await (await fetch(`${API_URL}${currentBranch.value}`)).json()
count.value
})
在 currentBranch.value
改变时,异步回调会重新运行,而 count.value
改变时,异步回调是不会重新运行的。
停止监听器
调用 watchEffect
返回值会停止监听,或 组件卸载时也会自动停止监听。(异步创建的监听器,组件卸载时是不会自动停止监听的,所以需要手动调用,防止内存泄漏)
例子:
const stop = watchEffect(() => {
console.log(person.count);
});
stop()
// 注意!异步创建,组件卸载是不会自动停止监听的
setTimeout(() => {
watchEffect(() => {})
}, 100)
回调触发时机
Vue 默认会缓存侦听器回调,进行异步刷新,当有多个依赖数据同时改变时,也只会触发一次。并且会监听组件更新函数,在 组件挂载前 或 组件更新前 触发回调。意味着你在侦听器回调中访问的 DOM 将是被 Vue 组件挂载前 或 组件更新前 的状态。
可给 watchEffect
传递第二个可选参数,配置对象,并给定 flush
属性,改变 副作用函数触发时机,和执行次数。该属性有以下三个值:
-
pre
: 默认配置,组件挂载前 或 组件更新前 执行侦听器回调,并且会缓存侦听器回调,异步刷新,所以同时改变多个依赖数据只会调用一次侦听器回调。 -
post
: 组件挂载后 或 组件更新后 执行侦听器回调,并且会缓存侦听器回调,异步刷新,所以同时改变多个依赖数据只会调用一次侦听器回调。 -
sync
: 组件挂载前 或 组件更新前 执行侦听器回调,并且不会缓存侦听器回调,同步刷新,所以同时改变多个依赖数据会多次调用侦听器回调。(性能不好)
假设有以下例子:
<template>
<button @click="change2">点击1</button>
<h1 class="h1">{{ state }}</h1>
</template>
<script setup lang="ts">
import { onBeforeMount, onMounted, watchEffect, onBeforeUpdate, onUpdated, ref, reactive } from "vue";
onBeforeMount(() => {
console.log("onBeforeMount");
});
onMounted(() => {
console.log("onMounted");
});
onBeforeUpdate(() => {
console.log("onBeforeUpdate");
});
onUpdated(() => {
console.log("onUpdated");
});
const state = ref(0);
const person = reactive({
count: 0,
});
const change2 = () => {
state.value++;
person.count++;
};
</script>
当 flush
属性值为 pre
时(默认配置)
watchEffect(() => {
console.log('----------------');
const el = document.querySelector(".h1");
console.log('Dom 内容:', el?.textContent);
console.log(person.count);
console.log(state.value);
});
例子中,侦听器回调会在 组件挂载前 立即调用,所以获取不到 DOM .h1
的内容。响应式依赖更改后,会在 组件更前 调用回调,所以获取到的 DOM .h1
内容是没更新前的状态。
并且调用 change2
同时改变 state
和 person.count
,只调用了一次侦听器回调,说明会缓存回调,并异步刷新。
当 flush
属性值为 post
时
watchEffect(
() => {
console.log('----------------');
const el = document.querySelector(".h1");
console.log('Dom 内容:', el?.textContent);
console.log(person.count);
console.log(state.value);
},
{
flush: "post"
}
);
// --------等同于
// watchPostEffect(
// () => {
// console.log('----------------');
// const el = document.querySelector(".h1");
// console.log('Dom 内容:', el?.textContent);
// console.log(person.count);
// console.log(state.value);
// }
// );
例子中,侦听器回调会在 组件挂载后 立即调用,所以获取到了 DOM .h1
的内容。响应式依赖更改后,会在 组件更新后 调用回调,所以获取到的 DOM .h1
内容是最新的。
并且调用 change2
同时改变 state
和 person.count
,只调用了一次侦听器回调,说明会缓存回调,并异步刷新。
当 flush
属性值为 sync
时
watchEffect(
() => {
console.log('----------------');
const el = document.querySelector(".h1");
console.log('Dom 内容:', el?.textContent);
console.log(person.count);
console.log(state.value);
},
{
flush: "sync"
}
);
// --------等同于
// watchSyncEffect(
// () => {
// console.log('----------------');
// const el = document.querySelector(".h1");
// console.log('Dom 内容:', el?.textContent);
// console.log(person.count);
// console.log(state.value);
// }
// );
例子中,侦听器回调会在 组件挂载前 立即调用,所以获取不到 DOM .h1
的内容。响应式依赖更改后,会在 组件更前 调用回调,所以获取到的 DOM .h1
内容是没更新前的状态。
并且调用 change2
同时改变 state
和 person.count
,调用了多次侦听器回调,说明不会缓存回调,并同步刷新。
过期的副作用
有时侦听器会执行一些异步操作,可能会发生竞态问题。
例子:
const API_URL = `https://api.github.com/repos/vuejs/core/commits?per_page=3&sha=`
const branches = ['main', 'v2-compat']
const currentBranch = ref(branches[0])
const finalData, = ref(null)
const change = () => {
currentBranch.value = currentBranch.value === "v2-compat" ? 'main' : 'v2-compat'
};
watchEffect(async () => {
const url = `${API_URL}${currentBranch.value}`
finalData.value = await (await fetch(url)).json()
})
例子中,如果调用了change
函数,改变数据依赖 currentBranch
,会重新调用侦听器回调,可能会发生如下图的问题:
由于请求 A 比请求 B 响应慢,所以导致 finalData
最终赋值的是请求 A,所以我们必须把失效的副作用(将 A 的结果设置给 finalData
)给清除掉。
侦听器回调提供了一个 onCleanup
参数,可解决这个问题。
onCleanup
onCleanup
接收一个回调函数,这个回调会在响应式依赖变更后,重新执行侦听器回调之前执行。
例子:
const API_URL = `https://api.github.com/repos/vuejs/core/commits?per_page=3&sha=`
const branches = ['main', 'v2-compat']
const currentBranch = ref(branches[0])
const finalData, = ref(null)
const change = () => {
currentBranch.value = currentBranch.value === "v2-compat" ? 'main' : 'v2-compat'
};
watchEffect(async (onCleanup) => {
// 定义一个标志,代表当前副作用函数是否过期,默认 false,代表没有过期
let expired = false;
// 调用 onCleanup 函数,注册一个过期回调
onCleanup(()=>{
// 过期时,将 expired 赋值为 true
expired = true
})
const url = `${API_URL}${currentBranch.value}`;
const res = await (await fetch(url)).json();
// 只有该副作用函数的执行没过期时,才会执行后续操作
if(!expired) {
finalData.value = res
}
})
现在,无论 currentBranch
改变多少次,每次请求快慢,finalData
最终设置的结果会是我们预期的最后一次请求的结果。
onCleanup
实现原理:
其实很简单,把通过 onCleanup
函数注册的过期回调存起来,响应式依赖变更后,在侦听器回调重新执行之前调用,仅此而已。
如下 源码 所示:
侦听器停止的时候也会调用过期函数(组件卸载前也会调用,因为组件卸载就会停止侦听器)
上面图片中给 cleanup
赋值过期回调的时候,也同时赋值给了侦听器 onStop
属性,侦听器停止的时候会去调用该属性
侦听器调试
侦听器配置对象中,onTrack
和 onTrigger
选项可用于调试侦听器行为。
只会在开发模式下工作
onTrack
:在响应式数据被追踪的时候被调用。
onTrigger
: 在响应式依赖更改时被调用。
例子
const state = ref(0);
const person = reactive({
count: 0,
});
const change = () => {
state.value++;
person.count++;
};
watchEffect(
() => {
state.value
person.count
},
{
onTrack(e) {
console.log(e, "track");
},
onTrigger(e) {
console.log(e, "trigger");
}
}
)
立即执行侦听器回调,获取响应式数据依赖,触发 onTrack
调用 change
,更改响应式数据依赖,触发 onTrigger
watch
watch
可侦听特定的数据源,数据源改变会调用侦听器回调,默认只有数据源改变才会执行副作用函数, 不会立即调用副作用函数。
第一个参数,所侦听的数据源,该数据源可以是:
- 一个
ref
(包括计算属性) - 一个
reactive
- 一个
getter
函数 - 多个数据源组成的数组。
第二个参数,侦听器回调,该回调提供了三个参数
- 数据源新值,当侦听多个数据源时为数组,元素为数据源
- 数据源旧值,当侦听多个数据源时为数组,元素为数据源
onCleanup
(可清除过期的副作用函数)
第三个为可选参数,是一个配置对象:
immediate
:在侦听器创建时立即触发回调。第一次调用时旧值是undefined
。deep
:如果源是对象,强制深度遍历,进行深层侦听。参考 深层侦听flush
:调整回调函数的刷新时机。参考 回调的刷新时机onTrack / onTrigger
:调试侦听器的依赖。参考 调试侦听器
侦听数据源
以下是侦听各种数据源,和侦听器回调提供的数据源新、旧值用例。(onCleanup
上面已经介绍过了
例子
const count = ref(0);
const panda = reactive({ foo: 0 });
// 直接侦听 ref
watch(count, (newV, oldV) => {
console.log("count: ", oldV, '=>', newV);
});
// 直接侦听 reactive
watch(panda, (newV, oldV) => {
console.log("panda: ", oldV, '=>', newV);
});
// 侦听一个 getter 函数
watch(
() => panda.foo,
(newV, oldV) => {
console.log("panda: ", oldV, '=>', newV);
}
);
// 侦听多个数据源
watch(
[count, () => panda.foo],
([count, foo], [preCount,preFoo]) => {
console.log("count and panda: ", count, foo, '=>', preCount, preFoo);
}
);
配置对象
这里主要讲 immediate
属性和 deep
属性,flush
和 onTrack / onTrigger
可参考上文介绍。
立即执行 immediate
在侦听器创建时立即触发回调。第一次调用时旧值是 undefined
const count = ref(0);
watch(count, (newV, oldV) => {
console.log("count: ", oldV, '=>', newV);
}, {
immediate: true
});
深层侦听器 deep
深度侦听的 watch
会递归跟踪数据源的所有属性。以下情况会触发侦听器深层侦听:
- 直接侦听一个
reactive
- 配置对象有
deep
属性
例子
const panda = reactive({
foo: {
bar: 0
},
});
const change = () => {
panda.foo.bar++;
};
watch(panda, (newV, oldV) => {
console.log("panda: ", oldV, '=>', newV);
});
例子中,直接侦听一个 reative
,并且没有配置对象,调用 change
的时候,会触发侦听器回调。
除了 reative
外的侦听数据源,想要深层侦听,都必须添加配置对象 deep
属性值为 true
。
// 深层侦听 ref
const panda = ref({
foo: 0,
});
const change = () => {
panda.value.foo++;
};
watch(panda, (newV, oldV) => {
console.log("panda: ", oldV, '=>', newV);
}, {
deep: true
});
// 深层侦听 getter 函数
const deer = reactive({
foo: 0
});
const change2 = () => {
deer.foo++
};
watch(() => deer, (newV, oldV) => {
console.log("deer: ", oldV, '=>', newV);
}, {
deep: true
});
下图来自 vue 源码:
深层侦听原理,其实很简单,当有侦听器回调和 deep: true
的时候,调用一个 traverse
函数,传递侦听数据源 source
,递归获取 source
的所有属性,触发属性访问器 getter
,达到跟踪效果。如下图:
深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。
新值和旧值为同一引用
当侦听数据源为对象,并且是深层侦听的时候,属性的变更,会发生侦听器回调给予的新值和旧值为同一引用的问题。
解决方法:通过拷贝一个新对象侦听此新对象,并使用 getter
写法。
const deer = reactive({
foo: 0
});
const change = () => {
deer.foo++
};
watch(deer, (newV, oldV) => {
console.log("deer: ", oldV === newV);
});
watch(() => JSON.parse(JSON.stringify(deer)), (newV, oldV) => {
console.log("deer2: ", oldV === newV);
});
源码:
watchEffect
vs. watch
什么时候使用 watchEffect
,而又什么时候使用 watch
呢?
需要以下需求的请使用 watch
:
-
更加明确是应该由哪个状态触发侦听器重新执行
-
访问监听数据变化前后状态
需要以下需求的请使用 watchEffect
:
-
有多个依赖项时,因为当有多个依赖项时,
watch
传入一个数组,增加了得手动维护这个依赖列表的负担。 -
需要侦听一个嵌套数据结构中的其中几个属性时,
watchEffect
会比添加options.deep
的watch
有效。因为watchEffect
只跟踪回调中使用到的属性,而深度侦听的watch
会递归跟踪所有的属性。(上面有解释)
转载自:https://juejin.cn/post/7235547967112167461