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