声明:我的环境是 react-v18.2.0
如果你使用 useEffect 来执行异步函数,同时返回的也是一个异步函数,那么这个时候你就需要注意了。
useEffect 用法
其定义为:useEffect(setup, dependencies?)
。首先 useEffect
的执行时机在组件第一次加载时和依赖更新时。
useEffect(() => {
console.log("开始执行")
return () => { // cleanup
console.log("依赖更新后先执行")
}
}, deps)
如果组件第一次加载,那么就会在页面中打印:开始执行
。当 deps
发生改变时就会打印依赖更新后先执行->开始执行
。其中 setup
可以返回一个函数,这个函数被称为 cleanup
,也就是当依赖更新/组件卸载时会先执行 cleanup
函数;这个函数的目的是清除 setup
的副作用,比如事件监听,那么这里 cleanup
就是清除这个事件监听。
如果存在依赖项,那么依赖项改变时先执行 cleanup
,如果 cleanup
函数中包含有依赖项,那么此时的依赖项是之前的值,而执行 setup
里面的值就是最新的值。我们以简单计数器为例。
const [count, setCount] = useState(0);
useEffect(() => {
console.log("开始执行", count);
return () => {
console.log("依赖更新后先执行", count);
};
}, [count]);
当我点击按钮让 count+1
以后,得到的日志为:依赖更新后先执行 0
-> 开始执行 1
。
异步需求
先说说什么情况下,我们需要使用到 cleanup
的场景;如果你的 setup
创建的状态需要在依赖更新后把之前创建的状态重置,那么你使用 cleanup
就是非常的明智,比如 setTimeout
监听。
const [count, setCount] = useState(0);
useEffect(() => {
const time = setTimeout(() => {
console.log("打印了", count);
}, 1000);
return () => {
clearTimeout(time);
};
}, [count]);
假如我们需要一个效果,按钮点击后,按钮不再点击 1s
后的只会生效一次 console.log("打印了", count)
这样的逻辑,而且连续点击都不算。那么就需要每次点击之后先清除之前的定时器。还有一个最常见的应用场景就是,事件监听,比如 react-navigation
中监听页面聚焦。
现在有一个需求,在组件加载的时候需要将本地的 state
存入 AsyncStorage
中,当 state
更新的时候需要把之前保存的删除掉。其中我模拟一下这个 AsyncStorage :
const AsyncStorage = {
items: [] as any[],
setItem(item: any) {
return new Promise((resolve) => {
setTimeout(() => {
this.items.push(item);
resolve(undefined);
}, 500);
});
},
removeItem(item: any) {
return new Promise((resolve) => {
setTimeout(() => {
this.items = this.items.filter((e) => e !== item);
resolve(undefined);
}, 800);
});
},
getItems() {
return this.items;
},
};
功能很简单, setItem
和 removeItem
是异步的,我现在实现上面的效果:
useEffect(() => {
AsyncStorage.setItem(count).then(() => {
console.log(AsyncStorage.items); // 方便查看日志
});
return () => {
AsyncStorage.removeItem(count);
};
}, [count]);
我希望 items 永远都是最新的值,比如当 count 为 0 ,那么这里打印 [0] ,当 count 变成 6 时 ,打印应该是 [6] 。我现在试试点击一次,看看效果:
发现居然是两个。下面我一直连续点击:
由于 cleanup
是异步的,所以 cleanup
还没执行完就会开始执行 setup
,这就是导致这样情况的原因。由于 useEffect
,没办法做到让两者按顺序执行;所以只能靠我们自己来完成。下面是我封装好的组件,能实现其对应的功能:
import { useEffect, useRef } from "react";
const useAsyncEffect = (
setup: () => void | Promise<void | (() => Promise<void>)>
) => {
const cleanupRef = useRef<(() => Promise<void>) | void>();
const runsRef = useRef<(() => Promise<void>)[]>([]);
const runningRef = useRef(false);
useEffect(() => {
const run = async () => {
await cleanupRef.current?.();
cleanupRef.current = await setup?.();
};
runsRef.current.push(run);
const startRun = async () => {
if (runningRef.current) {
return;
}
runningRef.current = true;
while (runsRef.current.length > 0) {
const tempRun = runsRef.current.shift();
await tempRun?.();
}
runningRef.current = false;
};
startRun();
}, [setup]);
};
export default useAsyncEffect;
下面在组件中这样使用:
const [count, setCount] = useState(0);
useAsyncEffect(async () => {
await AsyncStorage.setItem(count).then((res) => {
console.log(AsyncStorage.items);
});
console.log(AsyncStorage.items);
return async () => {
await AsyncStorage.removeItem(count);
};
});
这样即便连续点击也不会有问题,看看日志效果:
在使用这个的过程中,尽量使用 useCallback 包裹传递进去的函数。
useAsyncEffect(
useCallback(async () => {
await AsyncStorage.setItem(count).then((res) => {
console.log(AsyncStorage.items);
});
console.log(AsyncStorage.items);
return async () => {
await AsyncStorage.removeItem(count);
};
}, [count])
);
因为我没办法知道你的依赖,你通过这种我就不需要关心你的依赖,只要发现你传递函数的引用变了就知道肯定是你的依赖变了。设计这个 hooks 需要考虑两点,
- 在
setup
之前需要先执行完cleanup
,添加的时候还没清理完成就会出现添加的时候还有其他值; - 在
cleanup
之前需要保存setup
执行完,有可能还没保存你就清除了,这个时候就是无效的。
对于第一点很好弄,只需要把 cleanup
函数保存起来,下次执行的时候先执行完 cleanup
再执行 setup
就没问题了;第二点由于连续点击导致有可能上一次的 setup
还没执行完,下一次的 cleanup
就开始执行,此时的 cleanup
其实还没之前保存的,所以无效。所以我采用队列的方式来实现,这样每一次都只是先把要做的任务压入队列中,至于执行,让队列自己慢慢的做。