React中有一个常见的问题,数据获取之后,组件已经销毁,此时会有这样一段警告:
Can't perform a React state update on an unmounted component
相应地,也有一些常见的解决方案,如:
useEffect(() => {
let isMounted = true; // note mutable flag
someAsyncOperation().then(data => {
if (isMounted) setState(data); // add conditional check
})
return () => { isMounted = false }; // use cleanup to toggle value, if unmounted
}, []); // adjust dependencies to your needs
但是却鲜少有人提及另外一种场景,useEffect异步操作之后,虽然组件仍然处于挂载状态,但是当初的条件已经发生了改变,这种情况下容易出现bug。
举例来说,对于如下的代码:
import React, { FC, useState, useEffect } from 'react'
import axios from 'axios'
const App = (props) => {
const { id } = props
const [dataSource, setDataSource] = useState(null)
useEffect(() => {
const fetchData = async () => {
if (id) {
const { data } = await axios.get(`/api/mydata/${id}`)
setDataSource(data)
} else {
setDataSource(null)
}
}
}, [id])
}
当id
有值的时候,这个组件的useEffect
会先请求数据,再设置状态(setDataSource),在id
为空的情况下,则会直接清空dataSource
。
但是因为id
不为空时,effect代码是异步代码(axios.get
),而在id
为空时,则是一个简单的同步代码。
那么就有可能出现这样的问题:假如id先有值,后清空,那么第二次useEffect
(无数据获取)会更快执行完,而第一次useEffect
则执行较慢一些,导致最后dataSource
是有值的,反映的是第一次useEffect
对应的老数据。
为了解决这个问题,我提取了一个公共的npm包:use-outdated-effect
,这里简单介绍下它的用法:
- 将
useEffect
换成useOutdatedEffect
- 为
useEffect
中的callback函数增加两个参数:outdated
和unmounted
,均为函数,分别返回effect dependencies和组件装载状态的当前情况。
对于上面的问题,可以用如下的方式改进:
const App = (props) => {
const { id } = props
const [dataSource, setDataSource] = useState(null)
useOutdatedEffect((outdated, unmounted) => {
const fetchData = async () => {
const { data } = await axios.get(`/api/mydata/${id}`)
if (outdated()) { // check whether dependencies changed. In this example, it's the id variable
// id changed, stop the current operations
return
}
if (unmounted()) { // check whether component is unmounted
// component destroied, stop the current operations
return
}
setDataSource(data)
}
}, [id])
}
在自定义hooks的内部使用useRef
维护一个 myRef 的变量,每当dependencies发生变化,自动设置myRef.current
为一个新的值。
因为多个useEffect
中的代码同时生效时是按顺序执行的,所以可以在自定义hooks中使用两个useEffect
,第一个改myRef.current
的值,第二个先记录myRef.current
的旧值,在异步逻辑完成之后,再重新判断一次获取最新值,只有校验通过之后,才继续进行下一步。
关键代码:
const useAsyncEffect = (
effect: (outdated: () => boolean, unmounted: () => boolean, ) => ReturnType<EffectCallback>,
inputs?: DependencyList,
) => {
const asyncFlag = useRef<number>(0);
useEffect(() => { asyncFlag.current += 1 }, inputs);
useEffect(function () {
let result: ReturnType<EffectCallback> | undefined;
let unmounted = false;
const innerAsyncFlag = asyncFlag.current;
const maybePromise = effect(
() => asyncFlag.current !== innerAsyncFlag,
() => unmounted,
);
Promise.resolve(maybePromise).then((value) => { result = value });
return () => { unmounted = true; result && result?.() };
}, inputs);
};
npm i use-outdated-effect
useOutdatedEffect((outdated, unmounted) => {
const fetchData = async () => {
const { data } = await axios.get(`/api/mydata/${id}`)
if (outdated()) { // check whether dependencies changed. In this example, it's the id variable
// id changed, stop the current operations
return
}
if (unmounted()) { // check whether component is unmounted
// component destroied, stop the current operations
return
}
setDataSource(data)
}
}, [id])
更多细节,参考Github仓库。