在 React 组件中,我们会在 useEffect()
中执行方法,并返回一个函数用于清除它带来的副作用影响。以下是我们业务中的一个场景,该自定义 Hooks 用于每隔 2s 调用接口更新数据。
import { useState, useEffect } from 'react';
export function useFetchDataInterval(fetchData) {
const [list, setList] = useState([]);
useEffect(() => {
const id = setInterval(async () => {
const data = await fetchData();
setList(list => list.concat(data));
}, 2000);
return () => clearInterval(id);
}, [fetchData]);
return list;
}
🐚 问题
该方法的问题在于没有考虑到 fetchData()
方法的执行时间,如果它的执行时间超过 2s 的话,那就会造成轮询任务的堆积。而且后续也有需求把这个定时时间动态化,由服务端下发间隔时间,降低服务端压力。
所以这里我们可以考虑使用 setTimeout
来替换 setInterval
。由于每次都是上一次请求完成之后再设置延迟时间,确保了他们不会堆积。以下是修改后的代码。
import { useState, useEffect } from 'react';
export function useFetchDataInterval(fetchData) {
const [list, setList] = useState([]);
useEffect(() => {
let id;
async function getList() {
const data = await fetchData();
setList(list => list.concat(data));
id = setTimeout(getList, 2000);
}
getList();
return () => clearTimeout(id);
}, [fetchData]);
return list;
}
不过改成 setTimeout
之后会引来新的问题。由于下一次的 setTimeout
执行需要等待 fetchData()
完成之后才会执行。如果在 fetchData()
还没有结束的时候我们就卸载组件的话,此时 clearTimeout()
只能无意义的清除当前执行时的回调,fetchData()
后调用 getList()
创建的新的延迟回调还是会继续执行。
在线示例:CodeSandbox
可以看到在点击按钮隐藏组件之后,接口请求次数还是在继续增加着。那么要如何解决这个问题?以下提供了几种解决方案。
🌟如何解决
🐋 Promise Effect
该问题的原因是 Promise 执行过程中,无法取消后续还没有定义的 setTimeout()
导致的。所以最开始想到的就是我们不应该直接对 timeoutID
进行记录,而是应该向上记录整个逻辑的 Promise 对象。当 Promise 执行完成之后我们再清除 timeout,保证我们每次都能确切的清除掉任务。
在线示例:CodeSandbox
import { useState, useEffect } from 'react';
export function useFetchDataInterval(fetchData) {
const [list, setList] = useState([]);
useEffect(() => {
let getListPromise;
async function getList() {
const data = await fetchData();
setList((list) => list.concat(data));
return setTimeout(() => {
getListPromise = getList();
}, 2000);
}
getListPromise = getList();
return () => {
getListPromise.then((id) => clearTimeout(id));
};
}, [fetchData]);
return list;
}
🐳 AbortController
上面的方案能比较好的解决问题,但是在组件卸载的时候 Promise 任务还在执行,会造成资源的浪费。其实我们换个思路想一下,Promise 异步请求对于组件来说应该也是副作用,也是需要”清除“的。只要清除了 Promise 任务,后续的流程自然不会执行,就不会有这个问题了。
清除 Promise 目前可以利用 AbortController
来实现,我们通过在卸载回调中执行 controller.abort()
方法,最终让代码走到 Reject 逻辑中,阻止了后续的代码执行。
在线示例:CodeSandbox
import { useState, useEffect } from 'react';
function fetchDataWithAbort({ fetchData, signal }) {
if (signal.aborted) {
return Promise.reject("aborted");
}
return new Promise((resolve, reject) => {
fetchData().then(resolve, reject);
signal.addEventListener("aborted", () => {
reject("aborted");
});
});
}
function useFetchDataInterval(fetchData) {
const [list, setList] = useState([]);
useEffect(() => {
let id;
const controller = new AbortController();
async function getList() {
try {
const data = await fetchDataWithAbort({ fetchData, signal: controller.signal });
setList(list => list.concat(data));
id = setTimeout(getList, 2000);
} catch(e) {
console.error(e);
}
}
getList();
return () => {
clearTimeout(id);
controller.abort();
};
}, [fetchData]);
return list;
}
🐬 状态标记
上面一种方案,我们的本质是让异步请求抛错,中断了后续代码的执行。那是不是我设置一个标记变量,标记是非卸载状态才执行后续的逻辑也可以呢?所以该方案应运而生。
定义了一个 unmounted
变量,如果在卸载回调中标记其为 true
。在异步任务后判断如果 unmounted === true
的话就不走后续的逻辑来实现类似的效果。
在线示例:CodeSandbox
import { useState, useEffect } from 'react';
export function useFetchDataInterval(fetchData) {
const [list, setList] = useState([]);
useEffect(() => {
let id;
let unmounted;
async function getList() {
const data = await fetchData();
if(unmounted) {
return;
}
setList(list => list.concat(data));
id = setTimeout(getList, 2000);
}
getList();
return () => {
unmounted = true;
clearTimeout(id);
}
}, [fetchData]);
return list;
}
🎃 后记
问题的本质是一个长时间的异步任务在过程中的时候组件卸载后如何清除后续的副作用。
这个其实不仅仅局限在本文的 Case 中,我们大家平常经常写的在 useEffect
中请求接口,返回后更新 State 的逻辑也会存在类似的问题。
只是由于在一个已卸载组件中 setState 并没有什么效果,在用户层面无感知。而且 React 会帮助我们识别该场景,如果已卸载组件再做 setState 操作的话,会有 Warning 提示。
再加上一般异步请求都比较快,所以大家也不会注意到这个问题。
所以大家还有什么其他的解决方法解决这个问题吗?欢迎评论留言~
注: 题图来自《How To Call Web APIs with the useEffect Hook in React》