React 19 出手解決了異步請(qǐng)求的競態(tài)問題,是好事還是壞事?
是的,又是競態(tài)問題。
在客戶端開發(fā)中,這是一個(gè)老生常態(tài)的問題。一個(gè)有經(jīng)驗(yàn)的前端工程師必定是對(duì)這個(gè)問題的情況與解決方案如數(shù)家珍。因此競態(tài)問題也經(jīng)常在面試的過程中被討論。
競態(tài)問題指的是,當(dāng)我們?cè)诮换ミ^程中,由于各種原因?qū)е峦粋€(gè)接口短時(shí)間之內(nèi)連續(xù)發(fā)送請(qǐng)求,后發(fā)送的請(qǐng)求有可能先得到請(qǐng)求結(jié)果,從而導(dǎo)致數(shù)據(jù)渲染出現(xiàn)預(yù)期之外的錯(cuò)誤。
有的地方也稱為競態(tài)條件
因?yàn)榉乐怪貜?fù)執(zhí)行可以有效的解決競態(tài)問題,因此許多時(shí)候面試官也會(huì)直接在面試中問我們?nèi)绾螌?shí)現(xiàn)防重。常用的方式就是取消上一次請(qǐng)求,或者設(shè)置狀態(tài)讓按鈕不能連續(xù)點(diǎn)擊,想必各位大佬對(duì)這些方案都已經(jīng)非常熟悉,我這里就不展開細(xì)說。當(dāng)然,這個(gè)問題雖然被經(jīng)常討論,但是要解決好確實(shí)需要一點(diǎn)技術(shù)功底。
React 19 結(jié)合 Suspense 也在競態(tài)問題上,提出了一個(gè)自己的解決方案。我們結(jié)合新的案例來探討一下這個(gè)問題,看完之后大家感受一下這種方式是好是壞。
一、案例
我們先來看一下本次案例要實(shí)現(xiàn)的交互效果。如下圖所示。每次點(diǎn)擊會(huì)新增一條數(shù)據(jù)到下方的列表中。
我們來實(shí)現(xiàn)一下這個(gè)效果,首先定義一個(gè)用于請(qǐng)求接口的 promise。
const getApi = async () => {
const res = await fetch('https://api.chucknorris.io/jokes/random')
return res.json()
}
然后和前面的案例一樣,我們將每次點(diǎn)擊的 api 作為狀態(tài)存儲(chǔ)起來,通過 api 的改變來觸發(fā)更新的執(zhí)行。
const [api, setApi] = useState(null)
與此同時(shí),我們還需要一個(gè)數(shù)組作為狀態(tài)來管理列表。
const [list, setList] = useState([])
有了這個(gè)數(shù)組之后,我們需要遍歷這個(gè)數(shù)組渲染成 UI。
<div className="list">
{list.map((item, index) => {
return <div className='item' key={item}>{item}</div>
})}
</div>
最后需要 loading 顯示的部分,我們使用 Suspense 來完成。
<Suspense fallback={<div>loading...</div>}>
<Item api={api} setList={setList} />
</Suspense>
需要注意的是,我們這里把 setList 傳遞進(jìn)入了子組件。這個(gè)細(xì)節(jié)需要仔細(xì)思考我的動(dòng)因。
我們要考慮的問題是,當(dāng)我們?cè)?Suspense 之外,需要知道請(qǐng)求成功的狀態(tài)和數(shù)據(jù)時(shí),只有在 Suspense 的子組件內(nèi)部才可以獲取到。Suspense 子組件和外面的 Loading 是一個(gè)互斥的顯示關(guān)系。
因此,我們要在子組件內(nèi)部去獲取請(qǐng)求成功的數(shù)據(jù)結(jié)果。
const Item = ({api, setList}) => {
const [show, setShow] = useState(true)
const joke = api ? use(api) : {value: 'nothing'}
useEffect(() => {
if (!api) return
setList((list) => {
if (!list.includes(joke.value)) {
return list.concat(joke.value)
}
return list
})
setShow(false)
}, [])
const __cls = show ? '_03_a_value show' : '_03_a_value'
return (
<div className={__cls}>{joke.value}</div>
)
}
狀態(tài) show 是為了讓最后一條數(shù)據(jù)在列表中顯示,而不在這里顯示。
這里我們使用了 useEffect 來表示子組件渲染完成時(shí)需要執(zhí)行的邏輯。注意 React 19 雖然通過很多方式大幅度弱化了 useEffect 的存在感,但是偶爾在合適的時(shí)候使用也是必要的。
我在合并 list 的過程中,添加了一個(gè)判斷。
setList((list) => {
if (!list.includes(joke.value)) {
return list.concat(joke.value)
}
return list
})
這個(gè)細(xì)節(jié)在真實(shí)項(xiàng)目開發(fā)中尤其重要。因?yàn)?React 19 嚴(yán)格模式之下,組件會(huì)讓 useEffect 執(zhí)行兩次,以模擬生產(chǎn)環(huán)境的重復(fù)請(qǐng)求問題,因此,我這里做了一個(gè)判斷方式同樣的數(shù)據(jù)連續(xù)推送到數(shù)組里,從而導(dǎo)致線上 bug 的發(fā)生。
一個(gè)程序員是否經(jīng)驗(yàn)豐富,是否成熟,都是體現(xiàn)在這些生產(chǎn)環(huán)境的細(xì)節(jié)中。
完整代碼如下:
const getApi = async () => {
const res = await fetch('https://api.chucknorris.io/jokes/random')
return res.json()
}
export default function Index() {
const [api, setApi] = useState(null)
const [list, setList] = useState([])
function __clickToGetMessage() {
setApi(getApi())
}
return (
<div>
<div id='tips'>點(diǎn)擊按鈕新增一條數(shù)據(jù),該數(shù)據(jù)從接口中獲取</div>
<button onClick={__clickToGetMessage}>新增數(shù)據(jù)</button>
<div className="content">
<div className="list">
{list.map((item, index) => {
return <div className='item' key={item}>{item}</div>
})}
</div>
<Suspense fallback={<div>loading...</div>}>
<Item api={api} setList={setList} />
</Suspense>
</div>
</div>
)
}
const Item = ({api, setList}) => {
const [show, setShow] = useState(true)
const joke = api ? use(api) : {value: 'nothing'}
useEffect(() => {
if (!api) return
setList((list) => {
if (!list.includes(joke.value)) {
return list.concat(joke.value)
}
return list
})
setShow(false)
}, [])
const __cls = show ? '_03_a_value show' : '_03_a_value'
return (
<div className={__cls}>{joke.value}</div>
)
}
這樣之后,我們的目標(biāo)基本就完成了。接下來,我們需要觀察,當(dāng)我惡意重復(fù)點(diǎn)擊按鈕,會(huì)發(fā)生什么事情。
二、連續(xù)點(diǎn)擊
惡意連續(xù)點(diǎn)擊之前,我根據(jù)我以往的經(jīng)驗(yàn)預(yù)測一下可能會(huì)發(fā)生什么事情。
首先,多次點(diǎn)擊會(huì)導(dǎo)致多次請(qǐng)求,因此數(shù)組中會(huì)新增大量的數(shù)據(jù)。
其次,由于請(qǐng)求太密集,那么點(diǎn)擊的先后順序,與請(qǐng)求成功的先后順序不一致,因此列表中的順序也會(huì)與點(diǎn)擊順序不同?!父倯B(tài)問題」
那么我們來試著操作一下,看看該案例會(huì)有什么反應(yīng)。演示結(jié)果如下,新增一條數(shù)據(jù)時(shí),我連續(xù)點(diǎn)擊了 10 次。
圖片
結(jié)果我們發(fā)現(xiàn),點(diǎn)擊期間,并沒有新的數(shù)據(jù)渲染到頁面上,一直是 loading 的狀態(tài)。
再來看一下此時(shí)的請(qǐng)求情況。
請(qǐng)求的順序被嚴(yán)格控制了:上一個(gè)請(qǐng)求請(qǐng)求成功之后,下一個(gè)請(qǐng)求才開始發(fā)生。此時(shí)是一個(gè)串行的請(qǐng)求過程。
react 19 使用這種思路解決了競態(tài)問題。與此同時(shí),反饋到數(shù)據(jù)上,雖然前面多次的請(qǐng)求已經(jīng)成功,但是對(duì)于組件狀態(tài)來說,這個(gè)中間過程中一直有請(qǐng)求在發(fā)生,此時(shí) React 認(rèn)為中間的請(qǐng)求產(chǎn)生的數(shù)據(jù)為無效數(shù)據(jù)。只會(huì)把最后一個(gè)請(qǐng)求成功的數(shù)據(jù)作為最終的返回結(jié)果。
三、是好是壞
很顯然,僅從 UI 結(jié)果上來說,這樣的處理方式確實(shí)是非常合理的,我們不需要過多的干涉數(shù)據(jù)的處理,非常的輕松。但問題是,每次請(qǐng)求都成功發(fā)生。
當(dāng)我點(diǎn)擊 10 次,就會(huì)有 10 次請(qǐng)求,由于使用串行的策略來解決競態(tài)問題,導(dǎo)致最后一次的請(qǐng)求結(jié)果需要等待很長實(shí)踐才會(huì)返回。這無疑極大的降低了開發(fā)體驗(yàn)。
和取消上一次的請(qǐng)求相比,無論是從體驗(yàn)上,還是從效率上來說,無疑都是更差的一種方案。
因此,我們可以簡單基于目前的代碼,使用禁用按鈕的方式,來防止重復(fù)請(qǐng)求。
在父組件中定義一個(gè)狀態(tài)用于控制按鈕的禁用狀態(tài)。
const [disabled, setDisabled] = useState(false)
并將其傳遞給按鈕 button 組件的 disabled 屬性。
<button
disabled={disabled}
onClick={__clickToGetMessage}
>新增數(shù)據(jù)</button>
點(diǎn)擊時(shí),我們將其設(shè)置為 true,此時(shí)一個(gè)新的請(qǐng)求會(huì)發(fā)生。
function __clickToGetMessage() {
setDisabled(true);
setApi(getApi())
}
請(qǐng)求成功之后,我們?cè)谧咏M件的 useEffect 中,將其設(shè)置為 false。子組件代碼調(diào)整如下:
const Item = ({api, setList, setDisabled}) => {
const [show, setShow] = useState(true)
const joke = api ? use(api) : {value: 'nothing'}
useEffect(() => {
if (!api) return
+ setDisabled(false)
setList((list) => {
if (!list.includes(joke.value)) {
return list.concat(joke.value)
}
return list
})
setShow(false)
}, [])
const __cls = show ? '_03_a_value show' : '_03_a_value'
return (
<div className={__cls}>{joke.value}</div>
)
}
演示效果如下:
這種方式也可以比較合理的解決競態(tài)問題。
后續(xù)我們通過別的案例,再來演示通過取消上一次的接口請(qǐng)求方式是如何實(shí)現(xiàn)的。