React useEffect 異步的實現(xiàn)示例
問題場景
你在做一個搜索下拉組件,用戶輸入關(guān)鍵詞后自動請求后端接口:
// ? 常見寫法,埋了競態(tài)地雷
const SearchSuggest = ({ query }: { query: string }) => {
const [suggestions, setSuggestions] = useState<string[]>([])
useEffect(() => {
fetch(`/api/suggest?q=${query}`)
.then(res => res.json())
.then(data => setSuggestions(data))
}, [query])
return <ul>{suggestions.map(s => <li key={s}>{s}</li>)}</ul>
}
看起來很對?測試一下:
用戶輸入 "rea" → 請求 A 發(fā)出(耗時 300ms) 用戶繼續(xù)輸入 "react" → 請求 B 發(fā)出(耗時 100ms) 用戶又輸入 "react h" → 請求 C 發(fā)出(耗時 200ms)
結(jié)果:
請求 B 先回來(100ms) → setSuggestions(["react"])
請求 A 才回來(300ms) → setSuggestions(["rea"]) ← ? 把正確的數(shù)據(jù)覆蓋了!
請求 C 回來(200ms) → setSuggestions(["react hook"])
用戶明明搜的是 "react hook",結(jié)果 2 秒內(nèi)頁面閃現(xiàn)三組數(shù)據(jù)后,最終顯示了根本不完整的 ["rea"]。這對用戶體驗是災(zāi)難性的。
原因分析
問題的本質(zhì)是 異步請求的響應(yīng)順序不可控。
- 請求 B(最新)先返回 → 更新狀態(tài) ?
- 請求 A(過期)后返回 → 覆蓋狀態(tài) ?
React 不會替你判斷"哪個響應(yīng)是新哪個響應(yīng)舊"。setSuggestions 是個純函數(shù),誰來調(diào)用它,它就被誰覆蓋。
這種現(xiàn)象叫做 Race Condition(競態(tài)條件)——多個異步操作競爭同一個狀態(tài)寫入權(quán),后發(fā)先至造成數(shù)據(jù)不一致。
不止是搜索場景,分頁列表切換、Tab 切換加載、詳情頁路由跳轉(zhuǎn)……只要組件 Props/State 變化觸發(fā)新請求,而舊請求還在 pending,就會出現(xiàn)競態(tài)。
解決方案(3 種,由簡到優(yōu))
方案一:useEffect Cleanup + 布爾守衛(wèi)(最基礎(chǔ))
useEffect(() => {
let ignore = false // ?? 關(guān)鍵守衛(wèi)變量
fetch(`/api/suggest?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) { // ?? 組件未卸載/query未變化才寫入
setSuggestions(data)
}
})
return () => {
ignore = true // ?? 組件卸載或 query 變化時標(biāo)記過期
}
}, [query])
原理: 每次 query 變化時,上一次的 cleanup 先執(zhí)行,把 ignore 置為 true。舊請求的 .then 即便執(zhí)行了也不會修改狀態(tài)。
?? 但這個方案只能阻止卸載后的回調(diào),如果舊請求超慢、新請求更快,舊請求仍然可能在新請求之前執(zhí)行 .then 并寫入。單純 ignore 不夠可靠。
方案二:AbortController 取消請求(推薦生產(chǎn)使用)
useEffect(() => {
const controller = new AbortController() // ?? 創(chuàng)建取消控制器
fetch(`/api/suggest?q=${query}`, {
signal: controller.signal // ?? 綁定 signal
})
.then(res => res.json())
.then(data => setSuggestions(data))
.catch(err => {
if (err.name !== 'AbortError') { // ?? 忽略主動取消的錯誤
console.error('請求失敗:', err)
}
})
return () => {
controller.abort() // ?? 組件卸載/query變化時取消上次請求
}
}, [query])
為什么更好?
| 方案 | 優(yōu)點 | 缺點 |
|---|---|---|
| ignore 守衛(wèi) | 簡單易懂 | 請求仍在背后運行,浪費帶寬 |
| AbortController | 實際取消 HTTP 請求,節(jié)省資源 | 需要瀏覽器支持(>Chrome 66,無需擔(dān)心) |
同時 AbortController 是 100% 可靠的——舊請求的 promise 被 reject,永遠(yuǎn)不可能再調(diào)用 setSuggestions。
用 fetch 默認(rèn)支持,不需要額外庫。如果你用 axios,等價的是 CancelToken(v0.22+ 用 AbortSignal)。
方案三:自定義 Hook 封裝(一勞永逸)
把競態(tài)處理封裝進(jìn) useFetch,全項目通用:
// useFetch.ts
import { useEffect, useState, useRef } from 'react'
function useFetch<T>(url: string): { data: T | null; loading: boolean; error: Error | null } {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let ignore = false
const controller = new AbortController()
setLoading(true)
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
})
.then(res => {
if (!ignore) {
setData(res as T)
setError(null)
}
})
.catch(err => {
if (err.name === 'AbortError') return // 靜默忽略
if (!ignore) {
setError(err)
setData(null)
}
})
.finally(() => {
if (!ignore) setLoading(false)
})
return () => {
ignore = true
controller.abort()
}
}, [url])
return { data, loading, error }
}
使用:
const SearchSuggest = ({ query }: { query: string }) => {
const { data, loading } = useFetch<string[]>(`/api/suggest?q=${query}`)
if (loading) return <p>加載中…</p>
return <ul>{data?.map(s => <li key={s}>{s}</li>)}</ul>
}
進(jìn)一步優(yōu)化: 配合 lodash.debounce 或 useDeferredValue 做防抖,減少請求頻率,效果更佳。
實操代碼:完整搜索組件(含防抖 + AbortController)
import { useState, useEffect, useRef, useMemo } from 'react'
import { debounce } from 'lodash-es'
const SearchPage = () => {
const [input, setInput] = useState('')
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<string[]>([])
const [loading, setLoading] = useState(false)
// 防抖:用戶停止輸入 300ms 后設(shè)為正式查詢詞
const debouncedSetQuery = useMemo(
() => debounce((val: string) => setQuery(val), 300),
[]
)
useEffect(() => {
debouncedSetQuery(input)
return () => debouncedSetQuery.cancel()
}, [input])
// 請求競態(tài)保護(hù)
useEffect(() => {
if (!query) {
setSuggestions([])
return
}
const controller = new AbortController()
setLoading(true)
fetch(`/api/suggest?q=${encodeURIComponent(query)}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setSuggestions(data)
setLoading(false)
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err)
setLoading(false)
}
})
return () => controller.abort()
}, [query])
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="輸入搜索關(guān)鍵詞…"
/>
{loading && <span>搜索中…</span>}
<ul>
{suggestions.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
)
}
要點總結(jié)
- 競態(tài)的本質(zhì):異步請求后發(fā)先至,舊請求覆蓋新響應(yīng)
- ignore 守衛(wèi)最低成本,適合簡單場景;AbortController 才是生產(chǎn)級方案,能真正取消網(wǎng)絡(luò)請求
- 自定義 useFetch Hook 將競態(tài)處理集中封裝,團(tuán)隊統(tǒng)一使用避免重復(fù)踩坑
- 配合 防抖(Debounce) 可以減少無效請求,請求量降低 80%+
- 搜索/分頁/Tab 切換/路由跳轉(zhuǎn)——所有"頻繁變化參數(shù) + 異步請求"的場景都需要考慮競態(tài)處理
- React 18 的 Suspense + useTransition 未來可能改變這個模式,但傳統(tǒng) useEffect 取數(shù)依然廣泛存在,務(wù)必掌握
到此這篇關(guān)于React useEffect 異步的實現(xiàn)示例的文章就介紹到這了,更多相關(guān)React useEffect 異步內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React子組件調(diào)用父組件方法獲取的數(shù)據(jù)不是最新值的解決方法
這篇文章主要介紹了React子組件調(diào)用父組件方法獲取的數(shù)據(jù)不是最新值的解決方法,文中通過代碼示例介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-09-09
react antd-mobile ActionSheet+tag實現(xiàn)多選方式
這篇文章主要介紹了react antd-mobile ActionSheet+tag實現(xiàn)多選方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-10-10
React項目中使用zustand狀態(tài)管理的實現(xiàn)
zustand是一個用于狀態(tài)管理的小巧而強大的庫,本文主要介紹了React項目中使用zustand狀態(tài)管理的實現(xiàn),具有一定的參考價值,感興趣的可以了解一下2023-10-10
React State狀態(tài)與生命周期的實現(xiàn)方法
這篇文章主要介紹了React State狀態(tài)與生命周期的實現(xiàn)方法,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-03-03
React Native全面屏狀態(tài)欄和底部導(dǎo)航欄適配教程詳細(xì)講解
最近在寫 React Native 項目,調(diào)試應(yīng)用時發(fā)現(xiàn)頂部狀態(tài)欄和底部全面屏手勢指示條區(qū)域不是透明的,看起來很難受。研究了一下這個問題,現(xiàn)在總結(jié)一下解決方案,這篇文章主要介紹了React Native全面屏狀態(tài)欄和底部導(dǎo)航欄適配教程2023-01-01
React?中使用?react-i18next?國際化的過程(react-i18next?的基本用法)
i18next?是一款強大的國際化框架,react-i18next?是基于?i18next?適用于?React?的框架,本文介紹了?react-i18next?的基本用法,如果更特殊的需求,文章開頭的官方地址可以找到答案2023-01-01

