解決 React useEffect 的 race condition 問題

2024/01/18閱讀時間約 10 分鐘


在上一篇文章中,我們認識到該如何透過 useEffect 來管理從外部抓取資料所產生的副作用 (side effect)。目前應用程式可以根據使用者輸入的關鍵字,向 OMDB API 抓取電影資料了。

但是!

只要使用者快速打字輸入,我們發現搜尋結果便可能跑出錯誤,或是呈現出過期的 state 資料。簡單來說,兩筆 request 短時間內發出,最終的呈現結果會依據由誰勝出而定,好比多位選手賽跑一樣,因此這樣的現象被稱作競態條件 (race condition)

競態條件極容易於非同步請求時發生,而我們恰好就是用 useEffect 來管理 fetch 非同步請求所產生的副作用,所以遇到了競態條件。

 // Fetch movie data
useEffect(() => {
const fetchMovies = async () => {
try {
// Turn on the loader
setIsLoading(true);
// Reset the error
setError("");
const response = await fetch(
`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`
);

if (!response.ok) {
throw new Error("Something went wrong with fetching movies");
}

const data = await response.json();

// Can not find query result
if (data.Response === "False") throw new Error("Movie not found");

setMovies(data.Search);
} catch (err) {
console.error(err.message);
setError(err.message);
} finally {
setIsLoading(false);
}
};

// Return nothing when search keyword less than 3 characters
if (query.length < 3) {
setMovies([]);
setError("");
return;
}

fetchMovies();
}, [query]);
使用者每輸入一個字,就會發送出一次 fetch request 😮

使用者每輸入一個字,就會發送出一次 fetch request 😮


以上面的截圖來看,使用者在搜尋欄依序書輸入《人選之人—造浪者》的英文劇名,結果瀏覽器開發者工具的 Network 顯示每輸入一個字,就發送出一次 fetch request。

確實在程式碼內,useEffect 的 dependency 陣列值為 query,而 query 是我們在元件上方所定義的 state,和搜尋欄 input 綁定,所以使用者多輸入一個字,就會造成元件重新渲染,觸發 useEffect,React 接著比對兩次渲染之間的 dependency 陣列值,發現有更新,便呼叫 effect 函式,也就是 fetch

  1. query state 隨著 input 值更新
  2. state 更新觸發 component 重新渲染
  3. component 重新渲染,觸發 useEffect 運作
  4. 檢查出 useEffect 的依賴陣列值在兩次渲染間有所不同,觸發 fetch

如果對於 useEffect 的運作方式不熟悉,可以參考 React useEffect Hook 抓取 API 資料

這樣的結果明顯不是我們所期望的。

最理想的狀況,是保留住最後 Wave Makers 的搜尋關鍵字,前面的搜尋請求都可以捨棄掉。而 Web API 所提供的 AbortController 正好就能達到類似的效果!


AbortController + useEffect clean-up 函式

如何主動終止 fetch 請求

AbortController 是 Web API 所提供的 controller 物件,讓我們可以在想要的時候終止 request。abort 這個字有墮胎的意思,引申意為某個計畫胎死腹中、臨時終止。

使用 AbortController 的方式如下:

  1. 初始化一個 abortConroller 實體
  2. AbortControllersignal 屬性帶入成 fetch 的參數,負責傳送出終止 request 的「訊號」
  3. abortController.abort() 當作 useEffect 的 clean-up 函式

由於 clean-up 函式會在渲染結束後,清除當前的 effect,正適合運用在這次的情境。換句話說,我們將在兩次渲染之間 (使用者輸入兩個字之間),終止前一次渲染所發出的 fetch 請求。

// Fetch movie data
useEffect(() => {
// Initialize an instance of AbortController
👉 const abortController = new AbortController();
// Retrieve signal property from AbortController instance
👉 const signal = abortController.signal;

const fetchMovies = async () => {
try {
// Turn on the loader
setIsLoading(true);
// Reset the error
setError("");
const response = await fetch(
`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`,
👉{ signal }
);

if (!response.ok) {
throw new Error("Something went wrong with fetching movies");
}

const data = await response.json();

// Can not find query result
if (data.Response === "False") throw new Error("Movie not found");

setMovies(data.Search);
} catch (err) {
console.error(err.message);
setError(err.message);
} finally {
setIsLoading(false);
}
};

// Return nothing when search keyword less than 3 characters
if (query.length < 3) {
setMovies([]);
setError("");
return;
}

fetchMovies();
// Clean-up function for aborting previous fetch request
👉return () => {
abortController.abort();
};
}, [query]);


為了更方便觀察,我們把開發者工具的 Network throttling 改成 fast 3G,可以看到上一次渲染所發出的 fetch 請求真的被取消掉了!

raw-image


處理 AbortError

正當我們準備開香檳慶祝時,卻赫然發現畫面出現了 The user aborted a request. 這句錯誤訊息,顯然是 async 的 catch 抓到了錯誤。

JavaScript 把 fetch request 被終止視為某種錯誤,所以一旦終止發生,就算是我們刻意為之,還是會跑到 catch 裡面處理。

查看 MDN 關於 AbortController: abort 的介紹,的確有這麼一段話:

 When abort() is called, the fetch() promise rejects with an Error of type DOMException, with name AbortError.

嗯......既然終止 fetch 請求的錯誤有個固定的名稱,我們不妨在 catch 中加入判斷機制,唯有錯誤的名稱非 AbortError,才會當作錯誤來處理:

catch (err) {
console.error(err.message);

👉if (err.name !== "AbortError") {
setError(err.message);
}



參考資料:

16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!