解決前端常見(jiàn)問(wèn)題:競(jìng)態(tài)條件
| 導(dǎo)語(yǔ)?競(jìng)態(tài)條件一詞翻譯自英語(yǔ) "race conditions"。當(dāng)我們?cè)陂_(kāi)發(fā)前端 web 時(shí),最常見(jiàn)的邏輯就是從后臺(tái)服務(wù)器獲取并處理數(shù)據(jù)然后渲染到瀏覽器頁(yè)面上,過(guò)程中有不少的細(xì)節(jié)需要注意,其中一個(gè)就是數(shù)據(jù)競(jìng)態(tài)條件問(wèn)題,本文會(huì)基于 React 并結(jié)合一個(gè)小 demo 來(lái)解釋何為競(jìng)態(tài)條件,以及循序漸進(jìn)地介紹解決競(jìng)態(tài)條件方法。框架不同解決的方式會(huì)不一樣,但不影響理解競(jìng)態(tài)條件。
獲取數(shù)據(jù)
下面是一個(gè)小 demo:前端獲取文章數(shù)據(jù),并渲染到頁(yè)面上
App.tsx
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Article from './Article';
function App() {
return (
<Routes>
<Route path="/articles/:articleId" element={<Article />} />
Routes>
);
}
export default App;
Article.tsx
import React from 'react';
import useArticleLoading from './useArticleLoading';
const Article = () => {
const { article, isLoading } = useArticleLoading();
if (!article || isLoading) {
return<div>Loading...div>;
}
return (
<div>
<p>{article.id}p>
<p>{article.title}p>
<p>{article.body}p>
div>
);
};
export default Article;
在上述的 Article 組件中,我們把相關(guān)的數(shù)據(jù)請(qǐng)求封裝到了自定義 hook "useArticleLoading" 中,為了頁(yè)面的使用體驗(yàn),我們要么顯示獲取的數(shù)據(jù),要么顯示加載中。這里加上了加載態(tài)的判斷。
useArticleLoading.tsx
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
interface Article {
?id: number;
?title: string;
?body: string;
}
function useArticleLoading() {
?const { articleId } = useParams<{ articleId: string }>();
?const [isLoading, setIsLoading] = useState(false);
?const [article, setArticle] = useStatenull>(null);
?useEffect(() => {
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`)
? ? ?.then((response) => {
? ? ? ?if (response.ok) {
? ? ? ? ?return response.json();
? ? ? ?}
? ? ? ?return Promise.reject();
? ? ?})
? ? ?.then((fetchedArticle: Article) => {
? ? ? ?setArticle(fetchedArticle);
? ? ?})
? ? ?.finally(() => {
? ? ? ?setIsLoading(false);
? ? ?});
?}, [articleId]);
?return {
? ?article,
? ?isLoading,
?};
}
export default useArticleLoading;
在這個(gè)自定義 hook 中,我們管理了加載態(tài)以及數(shù)據(jù)請(qǐng)求
當(dāng)我們 url 訪問(wèn) /articles/1 時(shí),會(huì)發(fā)出 get 請(qǐng)求獲取對(duì)應(yīng) articleId 為 1 的文章內(nèi)容
競(jìng)態(tài)條件出現(xiàn)場(chǎng)景
上面是我們非常常見(jiàn)的獲取數(shù)據(jù)的方法,但是讓我們考慮以下情況(時(shí)間順序):
訪問(wèn) articles/1 查看第一個(gè)文章內(nèi)容
瀏覽器開(kāi)始請(qǐng)求后臺(tái)服務(wù)器,獲取文章 1 的內(nèi)容
網(wǎng)絡(luò)連接出現(xiàn)問(wèn)題
articles/1 請(qǐng)求未響應(yīng),數(shù)據(jù)未渲染到頁(yè)面中
不等待 articles/1 了,訪問(wèn) articles/2
瀏覽器開(kāi)始請(qǐng)求后臺(tái)服務(wù)器,獲取文章 2 的內(nèi)容
網(wǎng)絡(luò)連接沒(méi)有問(wèn)題
articles/2 請(qǐng)求立即響應(yīng)了,數(shù)據(jù)渲染到頁(yè)面中
articles/1 的請(qǐng)求響應(yīng)了
通過(guò) setArticles (fetchedArticles) 覆蓋了當(dāng)前的文章內(nèi)容
當(dāng)前 url 應(yīng)該顯示 articles/2,卻顯示了 articles/1
需要理解的一點(diǎn)就是,網(wǎng)絡(luò)請(qǐng)求的過(guò)程是復(fù)雜的,且響應(yīng)時(shí)間是不確定的,訪問(wèn)同一個(gè)目的地址,請(qǐng)求經(jīng)過(guò)的網(wǎng)絡(luò)鏈路不一定是一樣的路徑。所以先發(fā)出的請(qǐng)求不一定先響應(yīng),如果前端以先發(fā)請(qǐng)求先響應(yīng)的規(guī)則來(lái)開(kāi)發(fā)的話,那么就可能會(huì)導(dǎo)致錯(cuò)誤的數(shù)據(jù)使用,這就是競(jìng)態(tài)條件問(wèn)題。
解決
解決方法也很簡(jiǎn)單,當(dāng)收到響應(yīng)后,只要判斷當(dāng)前數(shù)據(jù)是否需要,如果不是則忽略即可。
在 React 中可以很巧妙的通過(guò) useEffect 的執(zhí)行機(jī)制來(lái)簡(jiǎn)潔、方便地做到這點(diǎn):
useArticlesLoading.tsx
useEffect(() => {
?let didCancel = false;
?setIsLoading(true);
?fetch(`https://get.a.article.com/articles/${articleId}`)
? ?.then((response) => {
? ? ?if (response.ok) {
? ? ? ?return response.json();
? ? ?}
? ? ?return Promise.reject();
? ?})
? ?.then((fetchedArticle: Article) => {
? ? ?if (!didCancel) {
? ? ? ?setArticle(fetchedArticle);
? ? ?}
? ?})
? ?.finally(() => {
? ? ?setIsLoading(false);
? ?});
?return () => {
? ?didCancel = true;
?}
}, [articleId]);
根據(jù) hook 的執(zhí)行機(jī)制:每次切換獲取新文章時(shí),執(zhí)行 useEffect 返回的函數(shù),然后再重新執(zhí)行 hook,重新渲染。
現(xiàn)在 bug 不會(huì)再出現(xiàn)了:
訪問(wèn) articles/1 查看第一個(gè)文章內(nèi)容
瀏覽器開(kāi)始請(qǐng)求后臺(tái)服務(wù)器,獲取文章 1 的內(nèi)容
網(wǎng)絡(luò)連接出現(xiàn)問(wèn)題
articles/1 請(qǐng)求未響應(yīng),數(shù)據(jù)未渲染到頁(yè)面中
不等待 articles/1 了,訪問(wèn) articles/2
useArticleLoading 重新渲染執(zhí)行,重新渲染前執(zhí)行了上一次的 useEffect 返回函數(shù),把 didCancel 設(shè)置為 true
網(wǎng)絡(luò)連接沒(méi)有問(wèn)題
articles/2 請(qǐng)求立即響應(yīng)了,數(shù)據(jù)渲染到頁(yè)面中
articles/1 的請(qǐng)求響應(yīng)了
由于 didCancel 變量,setArticles (fetchedArticles) 沒(méi)有執(zhí)行。
處理完后,當(dāng)我們?cè)俅吻袚Q文章時(shí),didCancel 為 true,就不會(huì)再處理上一個(gè)文章的數(shù)據(jù),以及 setArticles。
AbortController 解決
雖然上述通過(guò)變量的解決方案解決了問(wèn)題,但它并不是最優(yōu)的。瀏覽器仍然等待請(qǐng)求完成,但忽略其結(jié)果。這樣仍然浪費(fèi)占用著資源。為了改進(jìn)這一點(diǎn),我們可以使用 AbortController。
通過(guò) AbortController,我們可以中止一個(gè)或多個(gè)請(qǐng)求。使用方法很簡(jiǎn)單,創(chuàng)建 AbortController 實(shí)例,并在發(fā)出請(qǐng)求時(shí)使用它:
useEffect(() => {
const abortController = new AbortController();
setIsLoading(true);
fetch(`https://get.a.rticle.com/articles/${articleId}`, {
signal: abortController.signal,
})
.then((response) => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.finally(() => {
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, [articleId]);
通過(guò)傳遞 abortController.signal,我們可以很容易的使用 abortController.abort() 來(lái)終止請(qǐng)求(也可以使用相同的 signal 傳遞給多個(gè)請(qǐng)求,這樣可以終止多個(gè)請(qǐng)求)
使用 abortController 后,再來(lái)看看效果:
訪問(wèn) articles/1
請(qǐng)求服務(wù)器獲取 articles/1 數(shù)據(jù)
不等待響應(yīng),再訪問(wèn) articles/2
重新渲染 hook,useEffect 執(zhí)行返回函數(shù),執(zhí)行 abortController.abort ()
請(qǐng)求服務(wù)器獲取 articles/2 數(shù)據(jù)
獲取到 articles/2 數(shù)據(jù)并渲染到頁(yè)面上
第一個(gè)文章從未完成加載,因?yàn)槲覀兪謩?dòng)終止了請(qǐng)求
可以在開(kāi)發(fā)工具中查看手動(dòng)中斷的請(qǐng)求:

調(diào)用 abortController.abort () 有一個(gè)問(wèn)題,就是其會(huì)導(dǎo)致 promise 被拒絕,可能會(huì)導(dǎo)致未捕獲的錯(cuò)誤:

為了避免,我們可以加個(gè)捕獲錯(cuò)誤處理:
useEffect(() => {
?const abortController = new AbortController();
?setIsLoading(true);
?fetch(`https://get.a.article.com/articles/${articleId}`, {
? ?signal: abortController.signal,
?})
? ?.then((response) => {
? ? ?if (response.ok) {
? ? ? ?return response.json();
? ? ?}
? ? ?return Promise.reject();
? ?})
? ?.then((fetchedArticle: Article) => {
? ? ?setArticle(fetchedArticle);
? ?})
? ?.catch(() => {
? ? ?if (abortController.signal.aborted) {
? ? ? ?console.log('The user aborted the request');
? ? ?} else {
? ? ? ?console.error('The request failed');
? ? ?}
? ?})
? ?.finally(() => {
? ? ?setIsLoading(false);
? ?});
?return () => {
? ?abortController.abort();
?};
}, [articleId]);
停止其他 promises
AbortController 不止可以停止異步請(qǐng)求,在函數(shù)中也是可以使用的:
function wait(time: number) {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
wait(5000).then(() => {
console.log('5 seconds passed');
});
function wait(time: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve();
}, time);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject();
});
});
}
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, 1000);
wait(5000, abortController.signal)
.then(() => {
console.log('5 seconds passed');
})
.catch(() => {
console.log('Waiting was interrupted');
});
傳遞 signal 給 wait 來(lái)終止 promise。
其他
關(guān)于 AbortController 兼容性:

除了 IE,其他可以放心使用。
總結(jié)
本文討論了 React 中的競(jìng)態(tài)條件,解釋了競(jìng)態(tài)條件問(wèn)題。為了解決這個(gè)問(wèn)題,我們學(xué)習(xí)了 AbortController 背后的思想,并擴(kuò)展了解決方案。除此之外,我們還學(xué)習(xí)了如何將 AbortController 用于其他目的。它需要我們更深入地挖掘并更好地理解 AbortController 是如何工作的。對(duì)于前端,可以選擇自己最合適的解決方案。

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專(zhuān)業(yè)的技術(shù)人...


