在 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》