最近有空阅读了一下 Vue3 的文档,发现 Vue 也新增了类似 React Hooks 中的 useEffect 的 watchEffect,两者基本很相似,这里就来比较比较。
useEffect
React Hooks 带有一种函数式的设计理念,期望 UI = f(x),UI 是纯函数的渲染结果,useEffect 则用来处理副作用,也就是对外部环境的影响,在 Hooks 中基本取代了生命周期的概念,类似于原本的componentDidMount和componentWillUnmount。
常见的在 useEffect 中请求数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const example = (props) => { const [count, setCount] = useState(0);
useEffect(() => { let hasCancle = false; query("/xxxx").then((data) => { if (!hasCancle) { const { count } = data; setCount(data); } }); return () => { hasCancle = true; }; }, []);
return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> ); };
|
watchEffect
watchEffect 作用基本与 useEffect 作用一致,上面的代码可以很方便的改写为 Vue 版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| setup(props){ const count = ref(0); watchEffect( onInvalidate => { let hasCancle = false; query("/xxxx").then((data) => { if (!hasCancle) { const { count } = data; setCount(data); } });
onInvalidate(()=>{ hasCancle = false }) }) }
|
尽管功能上 watchEffect 与 useEffect 十分相似,但由于实现不一,实质还是有很多不同
调用时机
在 React 中,useEffect被用来处理与界面无关的副作用,会在 React 更新 DOM 后调用,这样可以尽快的展现渲染结果,如果涉及到对 DOM 的副作用操作,则可以使用useLayoutEffect在重渲染期间调用,以避免浏览器重渲染。
而在 Vue 中,watchEffect 的调用时机则由watchEffect函数的第二个参数控制。
1 2 3 4 5 6 7 8
| watchEffect( (onInvalidate) => { }, { flush: "pre", } );
|
watchEffect 的默认调用时机pre,按照官方文档,它有两次调用时机
watchEffect 类似于beforeCreate与beforeUpdate,但是实际还是有不小区别
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| { beforeCreate() { console.log("beforeCreate"); }, created() { console.log("created"); }, setup() { const count = ref(0);
watchEffect( () => { console.log("watchEffect sync"); console.log(count.value); }, { flush: "sync", } );
watchEffect(() => { console.log("watchEffect pre"); console.log(count.value); });
watchEffect( () => { console.log("watchEffect post"); console.log(count.value); }, { flush: "post", } );
onBeforeMount(() => console.log("beforeMount")); onMounted(() => console.log("onMounted")); onBeforeUpdate(() => { console.log("onBeforeUpdate"); });
onUpdated(() => { console.log("onUpdated"); console.log(count.value); });
console.log("sync setup");
return { count, }; }, };
|
以上代码调用顺序会是这样的结果:
1 2 3 4 5 6 7 8
| watchEffect sync watchEffect pre sync setup beforeCreate created beforeMount watchEffect post onMounted
|
我们可以看到 setup 函数里所有的内容都会被首先被调用,调用时机为 pre 和 sync 的 watchEffect 函数也会被同步调用,调用顺序取决于声明顺序,而调用时机为post的 watchEffect 函数 则会在 DOM 元素挂载后调用。
对于三种不同刷新方式,其实可以很简单的理解:
pre 初始化会在 setup 函数中被同步调用,此后总在组件更新前调用
post 初始化会在元素挂载后,此后总在组件更新后调用,总能获取实际的渲染结果
sync 初始化会在 setup 函数中被同步调用,此后总在状态更改时同步调用
当然。在大多数情况下,我们其实不需要关心时机,仅在涉及到对 DOM 的副作用时做下区分,比如获取 ref
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| <template> <button ref="buttonRef" @click="count++">count is: {{ count }}</button> </template>
<script> import { watchEffect, ref } from "vue";
export default { setup() { const count = ref(0); const buttonEL = ref(null); watchEffect(() => { console.log(buttonRef.value?.innerText); // 初始化: undefined 第一次调用: count is 0 });
watchEffect( () => { console.log(buttonRef.value?.innerText); // 初始化: <button> 第一次调用: count is 1 }, { flush: "post", } );
return { count, }; }, }; </script>
|
使用flush: "post",可以保证总是获取到最新的 DOM。
总的来说,Vue 和 React 在处理副作用上走上了不同的道路,React 倾向于尽快的进行重渲染,在渲染结束后执行副作用以避免 UI 线程等待;而 Vue 默认会在渲染更新前调用,更新的结果会直接体现在这次的渲染结果中。
递归调用
在 React 中,useEffect经常出现的错误就是进行了递归的重渲染
1 2 3 4
| const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); }, [count]);
|
在 Vue 中,如果这个值仅被单个副作用函数依赖则并不会导致递归调用
1 2 3 4
| const count = ref(0); watchEffect(() => { count.value++; });
|
值会正确的更新,同时这次的副作用不会被更新。
然而如果被多个副作用函数依赖,或者被异步调用,仍然会导致递归调用
多个依赖
1 2 3 4 5 6 7 8
| const count = ref(0); watchEffect(() => { count.value++; });
watchEffect(() => { count.value++; });
|
异步调用
1 2 3 4 5
| watchEffect(() => { nextTick(() => { count.value++; }); });
|
总而言之,在 watchEffect 应该尽量小心的更新值。
取消副作用
在 React 中,取消副作用的函数通过函数返回值返回
1 2 3 4 5
| useEffect(() => { return () => { }; });
|
然而在异步函数中这会存在问题
1 2 3 4 5 6
| useEffect(async () => { await fetchData(props.id); return () => { }; });
|
异步函数返回的是一个 promise,我们无法在异步调用完成前拿到取消函数。
因此,为了更好地与 asnyc 函数调用兼容,在Vue中,取消函数以回调的方式注册
1 2 3 4 5
| const data = ref(null) watchEffect(async onInvalidate => { onInvalidate(() => {...}) data.value = await fetchData(props.id) })
|
缺陷
Vue 由于响应式原理,可以自动的进行依赖收集,React 则需要手动填写依赖
1 2 3 4
| const [count, setCount] = useState(0); useEffect(() => { console.log(count); }, [count]);
|
1 2 3 4
| const count = ref(0); watchEffect(() => { console.log(count.value); });
|
Vue 会在第一次运行副作用函数执行时进行自动的依赖收集,类似于目前的computed属性。
然而,依赖收集只发生在同步调用时,Vue 无法知道异步调用时使用的依赖,
比如下面这个例子,来自于 github 上的一个issues
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { reactive, watchEffect } from "vue"; function watchEffectTest(reactive, watchEffect) { const obj = reactive({ age: 1 });
watchEffect(async () => { await new Promise((resolve) => { setTimeout(resolve, 100); }); console.log(obj.age); });
const timmer = setInterval(() => { obj.age++; if (obj.age > 5) { clearInterval(timmer); console.log("stop", obj.age); } }, 1000); } export default { setup() { watchEffectTest(reactive, watchEffect); return {}; }, };
|
预期输出:
实际输出
当然,这种缺陷也可以通过手动的读取一次依赖,告诉Vue这是依赖项,仅仅是在编写时会并不优雅。