如何解決前端常見的競態問題?
大家好,我是 CUGGZ。
本文將深入研究 Promise 是如何導致競態條件的,以及防止競態條件發生的幾種方法!
1. Promise 和競態條件
(1)Promise
我們知道,JavaScript 是單線程的,代碼會同步執行,即按順序從上到下執行。Promise 是可供我們異步執行的方法之一。使用 Promise,可以觸發一個任務並立即進入下一步,而無需等待任務完成,該任務承諾它會在完成時通知我們。
最重要和最廣泛使用 Promise 的情況之一就是數據獲取。不管是 fetch 還是 axios,Promise 的行爲都是一樣的。
從代碼的角度來看,就是這樣的:
console.log('first step');
fetch('/some-url') // 創建 Promise
.then(() => { // 等待 Promise 完成
console.log('second step'); // 成功
}
)
.catch(() => {
console.log('something bad happened'); // 發生錯誤
})
console.log('third step');
這裏會創建 Promise fetch('/some-url')
,並在 .then
中獲得結果時執行某些操作,或者在 .catch
中處理錯誤。
(2)實際應用
Promise 中最有趣的部分之一是它可能會導致競態條件。下面是一個非常簡單的應用:
import "./styles.scss";
import { useState, useEffect } from "react";
type Issue = {
id: string;
title: string;
description: string;
author: string;
};
const url1 =
"https://run.mocky.io/v3/ebf1b8f3-0368-4e3b-a965-1c5fdcc5d490?mocky-delay=2000ms";
const url2 =
"https://run.mocky.io/v3/27398801-05e2-4a62-8719-2a2d40974e52?mocky-delay=2000ms";
const Page = ({ id }: { id: string }) => {
const [data, setData] = useState<Issue>({} as Issue);
const [loading, setLoading] = useState(false);
const url = id === "1" ? url1 : url2;
useEffect(() => {
setLoading(true);
fetch(url)
.then((r) => r.json())
.then((r) => {
setData(r);
console.log(r);
setLoading(false);
});
}, [url]);
if (!data.id || loading) return <>loading issue {id}</>;
return (
<div>
<h1>My issue number {data.id}</h1>
<h2>{data.title}</h2>
<p>{data.description}</p>
</div>
);
};
const App = () => {
const [page, setPage] = useState("1");
return (
<div class>
<div class>
<ul class>
<li>
<button onClick={() => setPage("1")}>Issue 1</button>
</li>
<li>
<button onClick={() => setPage("2")}>Issue 2</button>
</li>
</ul>
<Page id={page} />
</div>
</div>
);
};
export default App;
在線實例: https://codesandbox.io/s/app-with-race-condition-fzyrj5?from-embed
頁面效果如下:
爲什麼會這樣呢?我們來看一下這個應用是怎麼實現的。這裏有兩個組件,一個是根組件 APP
,它會管理 active
的 page
狀態,並渲染導航按鈕和實際的 Page
組件。
const App = () => {
const [page, setPage] = useState("1");
return (
<>
<!-- 左側按鈕 -->
<button onClick={() => setPage("1")}>Issue 1</button>
<button onClick={() => setPage("2")}>Issue 2</button>
<!-- 實際內容 -->
<Page id={page} />
</div>
);
};
另一個就是 Page
組件,它接受活動頁面 的 id
作爲 props
,發送一個 fetch 請求來獲取數據,然後渲染它。簡化的實現(沒有加載狀態)如下所示:
const Page = ({ id }: { id: string }) => {
const [data, setData] = useState({});
// 通過 id 獲取相關數據
const url = `/some-url/${id}`;
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then((r) => {
setData(r);
});
}, [url]);
return (
<>
<h2>{data.title}</h2>
<p>{data.description}</p>
</>
);
};
這裏通過 id
來確定獲取數據的 url
。然在 useEffect
中發送 fetch 請求,並將獲取到的數據存儲在 state
中。那麼競態條件和奇怪的行爲是從哪裏來的呢?
(3)競態條件
這可以歸結於兩個方面:Promises 的本質和 React 生命週期。
從生命週期的角度來看,執行如下:
-
App 組件掛載;
-
Page 組件使用默認的 prop 值 1 掛載;
-
Page 組件中的 useEffect 首次執行
那麼 Promises 的本質就生效了:useEffect
中的 fetch 是一個 Promise,它是異步操作。它發送實際的請求,然後 React 繼續它的生命週期而不會等待結果。大約 2 秒後,請求完成,.then
開始執行,在其中我們調用 setData
來將獲取到的數據保存狀態中,Page
組件使用新數據更新,我們在屏幕上看到它。
如果在所有內容渲染完成後再點擊導航按鈕,事件流如下:
-
App
組件將其狀態更改爲另一個頁面; -
狀態改變觸發
App
組件的重新渲染; -
Page
組件也會重新渲染; -
Page
組件中的useEffect
依賴於id
,id
變了就會再次觸發useEffect
; -
useEffect
中的 fetch 將使用新id
觸發,大約 2 秒後setData
將再次調用,Page
組件更新,我們將在屏幕上看到新數據。
但是,如果在第一次 fetch 正在進行但尚未完成時單擊導航按鈕,這時 id
發生了變化,會發生什麼呢?
-
App
組件將再次觸發Page
的重新渲染; -
useEffect
將再次被觸發(因爲依賴的id
更改); -
fetch 將再次被觸發;
-
第一次 fetch 完成,
setData
被觸發,Page
組件使用第一次 fecth 的數據進行更新; -
第二次 fetch 完成,
setData
被觸發,Page
組件使用第二次 fetch 的數據進行更新。
這樣,競態條件就產生了。在導航到新頁面後,我們會看到內容的閃爍:第一次 fetch 的內容先被渲染,然後被第二次 fetch 的內容替換。
如果第二次 fetch 在第一次 fetch 之前完成,這種效果會更加有趣。我們會先看到下一頁的正確內容,然後將其替換爲上一頁的錯誤內容。
來看下面的例子,等到第一次加載完所有內容,然後導航到第二頁,然後快速導航回第一頁。頁面效果如下:
在線實例: https://codesandbox.io/s/app-without-race-condition-reversed-yuoqkh?from-embed
可以看到,我們先點擊 Issues 2,再點擊的 Issue 1。而最終先顯示了 Issue 1 的結果,後顯示了 Issue 2 的結果。那該如何解決這個問題呢?
2. 修復競態條件
(1)強制重新掛載
其實這一個並不是解決方案,它更多地解釋了爲什麼這些競態條件實際上並不會經常發生,以及爲什麼我們通常在常規頁面導航期間看不到它們。
想象一下如下組件:
const App = () => {
const [page, setPage] = useState('issue');
return (
<>
{page === 'issue' && <Issue />}
{page === 'about' && <About />}
</>
)
}
這裏我們並沒有傳遞 props,Issue
和 About
組件都有各自的 url
,它們可以從中獲取數據。並且數據獲取發生在 useEffect
Hook 中:
const About = () => {
const [about, setAbout] = useState();
useEffect(() => {
fetch("/some-url-for-about-page")
.then((r) => r.json())
.then((r) => setAbout(r));
}, []);
...
}
這次導航時沒有發生競態條件。儘可能多地和儘可能快地進行導航:應用運行正常。
在線實例: https://codesandbox.io/s/issue-and-about-no-bug-5udo04?from-embed
這是爲什麼呢?答案就在這裏:{page === ‘issue’ && <Issue />}
。當 page
值發生更改時,Issue
和 About
頁面都不會重新渲染,而是會重新掛載。當值從 issue
更改爲 about
時,Issue
組件會自行卸載,而 About
組件會進行掛載。
從 fetch 的角度來看:
-
App
組件首先渲染,掛載Issue
組件,並獲取相關數據; -
當 fetch 仍在進行時導航到下一頁時,
App
組件會卸載Issue
頁面並掛載About
組件,它會執行自己的數據獲取。
當 React 卸載一個組件時,就意味着它已經完全消失了,從屏幕上消失,其中發生的一切,包括它的狀態都丟失了。將其與前面的代碼進行比較,我們在其中編寫了 <Page id={page} />
,這個 Page
組件從未被卸載,我們只是在導航時重新使用它和它的狀態。
回到卸載的情況,當我們跳轉到在 About 頁面時,Issue 的 fetch 請求完成時,Issue
組件的 .then
回調將嘗試調用 setIssue
,但是組件已經消失了,從 React 的角度來看,它已經不存在了。所以 Promise 會消失,它獲取的數據也會消失。
順便說一句,React 中經常會提示:Can't perform a React state update on an unmounted component
,當組件已經消失後完成數據獲取等異步操作時就會出現這個警告。
理論上,這種行爲可以用來解決應用中的競態條件:只需要強制頁面組件重新掛載。可以使用 key 屬性:
<Page id={page} key={page} />
在線實例: https://codesandbox.io/s/app-without-race-condition-twv1sm?file=/src/App.tsx
⚠️ 這並不是推薦使用的競態條件問題的解決方案,其影響較大:性能可能會受到影響,狀態的意外錯誤,渲染樹下的 useEffect 意外觸發。有更好的方法來處理競爭條件(見下文)。
(2)丟棄錯誤的結果
解決競爭條件的另外一種方法就是確保傳入 .then
回調的結果與當前 “active” 的 id 匹配。
如果結果可以返回用於生成 url 的id
,就可以比較它們,如果不匹配就忽略它。這裏的技巧就是在函數中避免 React 生命週期和本地數據,並在 useEffect
中訪問最新的 id
。React ref
就非常適合:
const Page = ({ id }) => {
// 創建 ref
const ref = useRef(id);
useEffect(() => {
// 用最新的 id 更新 ref 值
ref.current = id;
fetch(`/some-data-url/${id}`)
.then((r) => r.json())
.then((r) => {
// 將最新的 id 與結果進行比較,只有兩個 id 相等時才更新狀態
if (ref.current === r.id) {
setData(r);
}
});
}, [id]);
}
在線示例: https://codesandbox.io/s/app-with-race-condition-fixed-with-id-and-ref-jug1jk?file=/src/App.tsx
我們也可以直接比較 url
:
const Page = ({ id }) => {
// 創建 ref
const ref = useRef(id);
useEffect(() => {
// 用最新的 url 更新 ref 值
ref.current = url;
fetch(`/some-data-url/${id}`)
.then((result) => {
// 將最新的 url 與結果進行比較,僅當結果實際上屬於該 url 時才更新狀態
if (result.url === ref.current) {
result.json().then((r) => {
setData(r);
});
}
});
}, [url]);
}
在線示例: https://codesandbox.io/s/app-with-race-condition-fixed-with-url-and-ref-whczob?file=/src/App.tsx
(3)丟棄以前的結果
useEffect
有一個清理函數,可以在其中清理訂閱等內容。它的語法如下所示:
useEffect(() => {
return () => {
// 清理的內容
}
}, [url]);
清理函數會在組件卸載後執行,或者在每次更改依賴項導致的重新渲染之前執行。因此重新渲染期間的操作順序將如下所示:
-
url
更改; -
清理函數被觸發;
-
useEffect
的實際內容被觸發。
JavaScript 中函數和閉包的性質允許我們這樣做:
useEffect(() => {
// useEffect中的局部變量
let isActive = true;
// 執行 fetch 請求
return () => {
// 上面的局部變量
isActive = false;
}
}, [url]);
我們引入了一個局部布爾變量 isActive
,並在 useEffect
運行時將其設置爲 true
,在清理時將其設置爲 false
。每次重新渲染時都會重新創建 useEffect
中的變量,因此最新的 useEffect
會將 isActive
始終重置爲 true
。但是,在它之前運行的清理函數仍然可以訪問前一個變量的作用域,並將其重置爲 false
。這就是 JavaScript 閉包的工作方式。
雖然 fetch 是異步的,但仍然只存在於該閉包中,並且只能訪問啓動它的 useEffect
中的局部變量。因此,當檢查 .then
回調中的 isActive
時,只有最近的運行(即尚未清理的運行)纔會將變量設置爲 true
。所以,現在只需要檢查是否處於活動閉包中,如果是,則將獲取的數據設置狀態。如果不是,什麼都不做,數據將再次消失。
useEffect(() => {
// 將 isActive 設置爲 true
let isActive = true;
fetch(`/some-data-url/${id}`)
.then((r) => r.json())
.then((r) => {
// 如果閉包處於活動狀態,更新狀態
if (isActive) {
setData(r);
}
});
return () => {
// 在下一次重新渲染之前將 isActive 設置爲 false
isActive = false;
}
}, [id]);
在線示例: https://codesandbox.io/s/app-with-race-condition-fixed-with-cleanup-4du0wf?file=/src/App.tsx
(4)取消之前的請求
對於競態條件問題,我們可以取消之前的請求,而不是清理或比較結果。如果之前的請求不能完成(取消),那麼使用過時數據的狀態更新將永遠不會發生,問題也就不會存在。可以爲此使用 AbortController
來取消請求。
我們可以在 useEffect
中創建 AbortController
並在清理函數中調用 .abort()
:
useEffect(() => {
// 創建 controller
const controller = new AbortController();
// 將 controller 作爲signal傳遞給 fetch
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((r) => {
setData(r);
});
return () => {
// 中止請求
controller.abort();
};
}, [url]);
這樣,在每次重新渲染時,正在進行的請求將被取消,新的請求將是唯一允許解析和設置狀態的請求。
中止一個正在進行的請求會導致 Promise 被拒絕,所以需要在 Promise 中捕捉錯誤。因爲 AbortController
而拒絕會給出特定類型的錯誤:
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((r) => {
setData(r);
})
.catch((error) => {
// 由於 AbortController 導致的錯誤
if (error.name === 'AbortError') {
// ...
} else {
// ...
}
});
在線示例: https://codesandbox.io/s/app-with-race-condition-fixed-with-abort-controller-6u0ckk?file=/src/App.tsx
3. Async/await
上面我們說了 Promise 的競態條件的解決方案,那 Async/await 會有所不同嗎?其實,Async/await 只是編寫 Promise 的一種更好的方式。它只是將 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://codesandbox.io/s/app-with-race-condition-async-away-q39lgi?file=/src/App.tsx
參考文章:https://www.developerway.com/posts/fetching-in-react-lost-promises
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/KHxkXW7vUKZKeG9EBe5KcQ