網站首頁 編程語言 正文
引言
本文源于翻譯?Fetching data in React: the case of lost Promises?由公眾號 ikoofe 完成翻譯。
在某些場景里,前端開發同學會無意識的編寫出一些看似正確的代碼,這些代碼能夠通過所有測試以及代碼審查,但是在實際執行時會導致應用程序出現一些意想不到的問題:頁面上的數據毫無規律地隨機展示出來、搜索出的結果與查詢條件不匹配、切換選項卡的導航時出現錯誤的內容。
當然,我們并不希望發生這些問題,那么如何避免這些問題呢?本文將繼續討論 React 數據獲取的一些方法和原理,并深入了解 Promise 在數據獲取時如何導致了條件競爭(race conditions)以及如何避免它們。
Promise 簡介
在本文正式開始之前,我們先了解一下 Promise 以及我們為什么需要 Promise。
當 JavaScript 執行代碼時,通常是一步一步地同步執行。Promise 是為數不多的異步執行方法之一。有了 Promise,我們就可以直接觸發一個任務并立即進入下一步,而無需等待任務完成。當任務完成后,Promise 會通知我們。
數據獲取是 Promise 最重要和最廣泛使用的場景之一。不管是直接使用 fetch
還是使用像 axios
這樣的第三方類庫,Promise 的行為都是一樣的。
從代碼的角度來看,如下所示:
console.log('first step'); // will log FIRST fetch('/some-url') // create promise here .then(() => { // wait for Promise to be done // log stuff after the promise is done console.log('second step') // will log THIRD (if successful) } ) .catch(() => { console.log('something bad happened') // will log THIRD (if error happens) }) console.log('third step') // will log SECOND
整個過程是:在 fetch('/some-url')
中創建一個 Promise,然后在 .then
和 .catch
中處理后續的事情。當然,這個簡單的示例只是為了方便大家理解本文的后續部分。如果要完全掌握 Promise,需要閱讀資料來了解更多的細節,本文不做暫不展開介紹。
Promise 和條件競爭
Promise 會帶來條件競爭這個問題,下面以一個簡單的應用頁面為例展開介紹:
頁面的左側是選項卡 tabs 的列,切換 tabs 就會導航到新頁面并發送一個數據請求,請求返回的數據渲染在右側。如果快速的來回切換 tabs 就會發現這里存在奇怪問題:右側的內容會閃爍,數據看起來是隨機出現的。
這個問題是怎么發生的?讓我們看看頁面的實現。頁面有兩個部分。一個是根 App 組件,它管理 “page” 的狀態,并渲染了導航按鈕和實際的 Page 組件。
const App = () => { const [page, setPage] = useState("1"); return ( <> <!-- left column buttons --> <button onClick={() => setPage("1")}>Issue 1</button> <button onClick={() => setPage("2")}>Issue 2</button> <!-- the actual content --> <Page id={page} /> </div> ); };
Page 組件接受 page 的 id 作為屬性,發送請求以獲取數據,然后渲染數據。Page 組件的實現(沒有加載狀態)如下所示:
const Page = ({ id }: { id: string }) => { const [data, setData] = useState({}); // pass id to fetch relevant data const url = `/some-url/${id}`; useEffect(() => { fetch(url) .then((r) => r.json()) .then((r) => { // save data from fetch request to state setData(r); }); }, [url]); // render data return ( <> <h2>{data.title}</h2> <p>{data.description}</p> </> ); };
通過 id,我們確定從哪個 url 獲取數據。然后我們在useEffect 中發送數據請求,并將結果數據存儲在 state 中 —— 這一切都很標準。那么,條件競爭和奇怪問題是什么原因導致的呢?
條件競爭的起因
這一切歸結為兩件事:Promise 和 React 生命周期。從生命周期的角度來看,會發生這些事情:
- App 組件被裝載(mounted)
- Page 組件被裝載,并默認的 prop 為 “1”
- Page 組件中的 useEffect 首次被執行
然后 Promise 開始執行:useEffect 中的 fetch 是一個promise 異步操作。它負責發送實際的數據請求,而 React 繼續它自己的工作,而不等待結果。大約 2s 后數據請求完成,在 .then 中調用 setData 并將數據保存在 state 中,Page 組件渲染出最新的數據,最終我們在屏幕上看到這些數據。
如果在渲染和完成所有內容后,單擊導航按鈕,我們將看到以下事件:
- App 組件將其狀態更改為另一個 page
- state 觸發 App 組件的重新渲染
- Page 組件也將重新渲染
- Page 組件中的 useEffect 依賴于 id;由于 id 已變化,將再次觸發 useEffect
- useEffect 將用新 id 觸發 fecth 請求,大約 2s 后將再次調用 setData,Page 組件更新,我們在屏幕上看到新數據
但是,如果 id 第一次變化觸發的 fetch 尚未返回,這時單擊導航按鈕切換 tabs 會發生什么?
- App 組件將再次觸發頁面的重新渲染
- useEffect 也將再次被觸發(id 已發生變化?。?/li>
- fetch 將再次被執行,React 將一如既往地繼續工作
- 這時第一次數據請求完成。它仍然可以訪問到 Page 組件的 setData(組件只是被更新,組件仍然是之前的組件)
- 第一次請求成功后的 setData 將被觸發,Page 組件將使用該次請求的數據更新自身
- 第二次請求完成。它仍然在那里,掛在后臺,就像任何 Promise 一樣。該組件依然使用了同一個 Page 組件的 setData,它將被觸發,Page 再次更新自身,這一次使用的是第二次獲取的數據。
條件競爭出現了!點擊導航按鈕到新 page 后,我們看到了頁面內容發生閃爍:首先渲染第一次請求的數據,然后替換成第二次請求的數據。
如果第二次數據請求在第一次數據請求之前完成,這種效果就更有趣了。我們將首先看到下一頁的正確內容,然后它將被上一頁的不正確內容替換。
解決條件競爭:強制重新掛載
嚴格來說,這并不是一個解決方案,它更多的是用來解釋為什么這些競爭條件不會經常發生,以及為什么在常規頁面導航中我們碰不到競爭條件。我們用下面的代碼來實現上面的功能:
const App = () => { const [page, setPage] = useState('issue'); return ( <> {page === 'issue' && <Issue />} {page === 'about' && <About />} </> ) }
沒有向下傳遞 prop,Issue 和 About 組件使用各自的 url 來完成數據請求。在 useEffect 中進行數據獲取,這與之前的實現方式完全相同:
const About = () => { const [about, setAbout] = useState(); useEffect(() => { fetch("/some-url-for-about-page") .then((r) => r.json()) .then((r) => setAbout(r)); }, []); ... }
這樣在 tabs 導航時不會出現競爭條件。無論操作的次數和速度有多快,應用程序都能夠正常運行。這是為什么呢?
答案在這里:{page==='issue'&&<issue/>}
。當 page 的值更改時,Issue 和 About 不會重新渲染,而是重新裝載。當值從 issue 更改為 about 時,Issue 組件將自行卸載,About 組件將裝載在它的位置上。
從數據獲取的角度來看:
- App 組件首先被渲染,然后裝載 Issue 組件并開始獲取數據
- 在數據請求過程中導航到下一個頁面,App 組件會卸載 Issue 組件并裝載 About 組件,然后開始它自己的數據獲取
當 React 卸載一個組件時,意味著它已經不存在了。徹底消失了,從屏幕上消失了,沒有人可以訪問它,里面發生的一切,包括它的狀態都丟失了。與前面的代碼相比,我們在前面的代碼中編寫了 <Page id={Page} />
,此 Page 組件從未被卸載,我們只是在導航時重新使用它。
所以回到卸載的場景。當 Issue 的數據請求在 About 組件上完成時,Issue 組件的 .then
將嘗試調用其 setIssue 來設置 state。但組件已經不存在了,從 React 的角度來看,它已經不存在。因此,這個 Promise 也將消失,它得到的數據也隨之消失。
順便提一下,你還記得曾經有一個警告 “無法對未安裝的組件執行React 狀態更新” 嗎?在組件消失之后完成異步操作(如數據獲?。?,會出現這個警告。當然,現在這個警告已經被 React 刪除掉了。
無論如何,理論上,這種方法可以用于解決的條件競爭問題:我們所需要做的是在導航變化時強制重新裝載 Page 組件。為此,我們可以使用 “key” 屬性:
<Page id={page} key={page} />
但是,并不推薦使用這個方案來解決條件競爭問題,它會引起一些潛在問題:可能會使性能受到影響,焦點和狀態會出現意外錯誤,以及觸發不必要的 useEffect。它并沒有從根本上解決問題,而是將問題掩蓋起來。但在某些場景下,如果小心使用,也不失為一種解決問題的方法。
解決條件競爭:丟棄錯誤數據
一種解決條件競爭更友好的方法,是確保傳入到 .then callback 的數據與當前被 “選中” 的 id 匹配;而不是清除整個已存在的 Page 組件。
如果返回的數據中包括了用于生成 url 的 “id”,我們可以比較它們是否一致。如果它們不一致,則忽略它們。這里需要做的是,要跳出 React 生命周期和函數的本地作用域,在 useEffect 訪問到最新的 id。React ref 非常適合這個場景:
const Page = ({ id }) => { // create ref const ref = useRef(id); useEffect(() => { // update ref value with the latest id ref.current = id; fetch(`/some-data-url/${id}`) .then((r) => r.json()) .then((r) => { // compare the latest id with the result // only update state if the result actually belongs to that id if (ref.current === r.id) { setData(r); } }); }, [id]); }
如果返回的數據結果中沒有包含 id,我們可以比較 url:
const Page = ({ id }) => { // create ref const ref = useRef(id); useEffect(() => { // update ref value with the latest url ref.current = url; fetch(`/some-data-url/${id}`) .then((result) => { // compare the latest url with the result's url // only update state if the result actually belongs to that url if (result.url === ref.current) { result.json().then((r) => { setData(r); }); } }); }, [url]); }
解決條件競爭:丟棄之前的數據
如果不喜歡上面的解決方案,或者認為使用 ref 來做這樣的事情。還有另一種方法:useEffect 有一個叫做 “cleanup” 的清除函數,在這個函數中我們可以清理訂閱之類的東西。在我們的場景中可以用來控制數據獲取。它的語法如下:
// normal useEffect useEffect(() => { // "cleanup" function - function that is returned in useEffect return () => { // clean something up here } // dependency - useEffect will be triggered every time url has changed }, [url]);
“cleanup” 的函數在組件卸載后執行,或者在每次依賴項變化引起重新渲染之前運行。因此,重新渲染期間的執行順序如下:
- url 發生變化
- 觸發 “cleanup” 函數
- useEffect 的實際內容被觸發
利用 JavaScript 函數和閉包,我們可寫出這樣的代碼:
useEffect(() => { // local variable for useEffect's run let isActive = true; // do fetch here return () => { // local variable from above isActive = false; } }, [url]);
我們引入了一個局部布爾變量 isActive,并在 useEffect 運行時將其設置為 true,在 “cleanup” 清理時將其設為 false。每次重新渲染時都會重新創建 useEffect 中的函數,因此最近一次 useEffect 運行的 isActive 將始終重置為 true。但是在它之前運行的 “cleanup” 函數仍然可以訪問前一個函數的作用域,它會將其重置為 false。這也是 JavaScript 閉包的工作原理。
fetch Promise 雖然是異步的,但仍然只存在于該閉包中,并且只能訪問 useEffect 運行的局部變量。因此,當我們在 .then 回調中檢查 isActive 布爾值時,只有在最近的一次運行中,尚未執行清理函數,才會將變量設置為 true。所以現在需要做的只是檢查是否處于活動的閉包中,如果是,則設置狀態。如果不是,什么都不做,數據將再次憑空消失。
useEffect(() => { // set this closure to "active" let isActive = true; fetch(`/some-data-url/${id}`) .then((r) => r.json()) .then((r) => { // if the closure is active - update state if (isActive) { setData(r); } }); return () => { // set this closure to not active before next re-render isActive = false; } }, [id]);
解決條件競爭:取消之前的請求
如果覺得在 React 生命周期的上下文中處理 JavaScript 閉包太復雜,那么還有另一種解決問題的方法。
我們可以取消之前的所有請求,而不是清理或比較數據結果。如果它們永遠都不會完成數據請求,那么也就永遠不會使用這些過時數據,條件競爭的問題也就不復存在。
在這里,我們可以使用 AbortController,實現的代碼也相對比較簡單:在 useEffect 中創建 AbortController 并在清理函數中調用 .abort()。
useEffect(() => { // create controller here const controller = new AbortController(); // pass controller as signal to fetch fetch(url, { signal: controller.signal }) .then((r) => r.json()) .then((r) => { setData(r); }); return () => { // abort the request here controller.abort(); }; }, [url]);
在每次重新渲染時,正在進行的請求將被取消,只允許最新的數據請求被解析并更新到 state。
中止正在進行的請求,會導致 Promise 被 reject,因此我們需要捕獲錯誤以消除控制臺中的可怕警告。正確處理 Promise reject 是一個很好的開發習慣,這是任何場景都下都應該做的事情,與是否使用 AbortController 無關。
由 AbortController 引起的 reject 會拋出的特定類型的錯誤,因此很容易將它與其它常規錯誤區分開。
fetch(url, { signal: controller.signal }) .then((r) => r.json()) .then((r) => { setData(r); }) .catch((error) => { // error because of AbortController if (error.name === 'AbortError') { // do nothing } else { // do something, it's a real error! } });
Async/await 會改變什么嗎?
Async/await 只是編寫 Promise 的語法糖。從執行的角度來看,它們只是轉換為 “同步” 函數,但不會改變它們的異步特性。下面的 Promise 代碼:
fetch('/some-url') .then(r => r.json()) .then(r => setData(r));
與下面 async/await 代碼等價:
const response = await fetch('/some-url'); const result = await response.json(); setData(result);
使用 async/await 代替 “傳統”的 Promise,同樣會存在條件競爭問題,前面介紹的解決方案也依然適用,只是語法略有差異。
原文鏈接:https://juejin.cn/post/7193709457254613050
相關推薦
- 2022-07-18 SQL?Server中實現錯誤處理_MsSql
- 2022-04-03 Python?八個數據清洗實例代碼詳解_python
- 2023-10-14 c/c++--__attribute__ 機制
- 2022-12-05 Android實現自動變換大小的ViewPager_Android
- 2022-08-10 詳細講解Swift中的類型占位符_Swift
- 2022-08-03 GoFrame?glist?基礎使用和自定義遍歷_Golang
- 2022-09-23 C#實現目錄跳轉(TreeView和SplitContainer)的示例代碼_C#教程
- 2022-07-15 C++面向對象之類和對象那些你不知道的細節原理詳解_C 語言
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支