在上一篇文章中,我們認識到該如何透過 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]);
以上面的截圖來看,使用者在搜尋欄依序書輸入《人選之人—造浪者》的英文劇名,結果瀏覽器開發者工具的 Network 顯示每輸入一個字,就發送出一次 fetch request。
確實在程式碼內,useEffect 的 dependency 陣列值為 query
,而 query
是我們在元件上方所定義的 state,和搜尋欄 input 綁定,所以使用者多輸入一個字,就會造成元件重新渲染,觸發 useEffect
,React 接著比對兩次渲染之間的 dependency 陣列值,發現有更新,便呼叫 effect 函式,也就是 fetch
。
query
state 隨著 input 值更新useEffect
運作useEffect
的依賴陣列值在兩次渲染間有所不同,觸發 fetch
如果對於 useEffect
的運作方式不熟悉,可以參考 React useEffect Hook 抓取 API 資料。
這樣的結果明顯不是我們所期望的。
最理想的狀況,是保留住最後 Wave Makers 的搜尋關鍵字,前面的搜尋請求都可以捨棄掉。而 Web API 所提供的 AbortController
正好就能達到類似的效果!
AbortController
是 Web API 所提供的 controller 物件,讓我們可以在想要的時候終止 request。abort 這個字有墮胎的意思,引申意為某個計畫胎死腹中、臨時終止。
使用 AbortController
的方式如下:
abortConroller
實體AbortController
的 signal
屬性帶入成 fetch
的參數,負責傳送出終止 request 的「訊號」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 請求真的被取消掉了!
正當我們準備開香檳慶祝時,卻赫然發現畫面出現了 The user aborted a request. 這句錯誤訊息,顯然是 async 的 catch 抓到了錯誤。
JavaScript 把 fetch request 被終止視為某種錯誤,所以一旦終止發生,就算是我們刻意為之,還是會跑到 catch 裡面處理。
查看 MDN 關於 AbortController: abort 的介紹,的確有這麼一段話:
Whenabort()
is called, thefetch()
promise rejects with anError
of typeDOMException
, with nameAbortError
.
嗯......既然終止 fetch 請求的錯誤有個固定的名稱,我們不妨在 catch 中加入判斷機制,唯有錯誤的名稱非 AbortError,才會當作錯誤來處理:
catch (err) {
console.error(err.message);
👉if (err.name !== "AbortError") {
setError(err.message);
}