React處理高頻的實(shí)時(shí)數(shù)據(jù)的解決方案
最近,我遇到了一個(gè)很有意思的 React 問題。
我需要開發(fā)一個(gè)實(shí)時(shí)的日志查看器,功能上需要實(shí)時(shí)展示服務(wù)運(yùn)行的日志。因?yàn)檫@個(gè)項(xiàng)目是內(nèi)部的,我這里大概抽象一下:
后端使用 SSE(Server-Sent Events) 技術(shù),源源不斷地把日志推送給前端。
當(dāng)日志一條一條、不緊不慢地過來時(shí),一切正常。
但是,當(dāng)我預(yù)覽一個(gè)已經(jīng)完成的任務(wù)日志時(shí),網(wǎng)頁卡頓了一下。瀏覽器控制臺(tái)顯示了一個(gè) React 開發(fā)者很熟悉的錯(cuò)誤:
Uncaught Error: Maximum update depth exceeded... (錯(cuò)誤:超過最大更新深度)
這個(gè)錯(cuò)誤通常意味著,存在什么組件陷入了無限循環(huán)。比如,組件的渲染函數(shù)里直接調(diào)用了 setState,導(dǎo)致“渲染 → 更新狀態(tài) → 觸發(fā)渲染 → ...”的死循環(huán)。
比如這樣:
export default function Demo() {
const [count, setCount] = useState(0)
setCount(count + 1)
return <h1>Count: {count}</h1>
}
但我的代碼并沒有這樣的邏輯,該使用 useEffect 的地方都使用了。我只是在 SSE 的事件回調(diào)里更新狀態(tài)。
// 示意代碼
const source = new EventSource("/api/logs")
source.addEventListener("log", (event) => {
// 每來一條日志,就調(diào)用 set 函數(shù)
appendLog(event.data)
})
那么,問題出在哪里呢?
問題的根源:高頻更新
起初我以為是哪里的更新邏輯不對(duì),讓 claude 排查很久都沒找到具體問題。在給現(xiàn)有函數(shù)增加了不少緩存,比如useMemo,useCallback,甚至 React.memo 都使用上了,仍舊沒有解決這個(gè)報(bào)錯(cuò)。
代碼沒有問題,那么問題就應(yīng)該出現(xiàn)在一些極端場景導(dǎo)致的高頻渲染。比如網(wǎng)絡(luò)?我才打開控制臺(tái)的網(wǎng)絡(luò)部分,看到幾乎在很短時(shí)間內(nèi),上百條的 log 被推送過來!
到這里問題就和清晰了:當(dāng)服務(wù)器在短時(shí)間內(nèi)(比如 1 秒內(nèi))推送上百條日志時(shí),每一個(gè) log 都觸發(fā)了 React 進(jìn)行重新渲染,這里觸發(fā)了 React 的某些機(jī)制,React 對(duì)這種行為發(fā)出了報(bào)錯(cuò)。
React 內(nèi)部有一個(gè)“嵌套更新計(jì)數(shù)器”,用來防止無限循環(huán)。
簡單說,如果在一次渲染(Render)的過程中,又因?yàn)槟承┰蛴|發(fā)了新的狀態(tài)更新,這就叫“嵌套更新”。當(dāng)這個(gè)次數(shù)短時(shí)間內(nèi)超過一個(gè)閾值(通常是 50 次),React 就會(huì)認(rèn)為你“可能”寫了一個(gè) Bug,于是主動(dòng)拋出錯(cuò)誤,終止程序。
我們的問題就出在這里。SSE 的事件回調(diào)來得太快了。
當(dāng)服務(wù)器在 1 秒內(nèi)推送 150 條日志時(shí),瀏覽器的事件循環(huán)會(huì)瘋狂執(zhí)行回調(diào):
- SSE 事件 1 抵達(dá) →
appendLog()→ 觸發(fā) React 更新(第 1 次) - React 還沒來得及渲染,SSE 事件 2 抵達(dá) →
appendLog()→ 觸發(fā) React 更新(第 2 次) - ...
- SSE 事件 50 抵達(dá) →
appendLog()→ 觸發(fā) React 更新(第 50 次) - SSE 事件 51 抵達(dá) →
appendLog()→ 觸發(fā) React 更新(第 51 次)
在 React 看來,這 51 次更新幾乎是“同時(shí)”發(fā)生的,它無法分辨這是“51 條獨(dú)立日志”還是“一個(gè)死循環(huán)”。為了保護(hù)自己,它選擇了報(bào)錯(cuò)。
問題的本質(zhì)是:數(shù)據(jù)接收的頻率(高頻)和 React 狀態(tài)更新的頻率(低頻)不匹配。
我們不能每收到一條數(shù)據(jù),就立刻更新一次狀態(tài)。
后續(xù)我了解到 React 18 版本對(duì)高頻渲染的問題進(jìn)行了優(yōu)化,但它目前僅適用于 React 事件處理函數(shù)內(nèi)的同步更新。對(duì)于 SSE 回調(diào)、fetch 回調(diào)、setInterval 等異步事件源觸發(fā)的更新,仍需手動(dòng)實(shí)現(xiàn)批處理。
解決方案:批處理(Batching)
既然不能一條一條地更新,那很自然就想到,能不能把日志“攢一下”,再一次性提交給 React?
這就是“批處理”(Batching)思想。
我們不再是“來一條,更新一次”,而是“來 N 條,更新一次”。
實(shí)現(xiàn)這個(gè)功能的關(guān)鍵,是需要一個(gè)“緩沖區(qū)”(Buffer)和一個(gè)“定時(shí)器”(Timer)。
- 緩沖區(qū):需要一個(gè)地方暫存日志,但這個(gè)地方本身不能是 React 的
state(否則又觸發(fā)渲染了)。useRef是最合適的人選。 - 定時(shí)器:需要一個(gè)機(jī)制,在“攢”日志的間隙,把它們統(tǒng)一提交。
setTimeout(..., 0)是這里的法寶。
代碼實(shí)現(xiàn)
我們來改造一下 log 事件的處理。
首先,在組件里定義緩沖區(qū)和定時(shí)器:
export default function LogPage() {
// 1. 從 store 獲取批量更新的方法
const appendLogs = useLogStore((state) => state.appendLogs)
// 2. 批處理緩沖區(qū)(使用 ref 不會(huì)觸發(fā)渲染)
const batchBufferRef = useRef([])
// 3. 定時(shí)器引用(保證只有一個(gè)定時(shí)器在運(yùn)行)
const batchTimerRef = useRef(null)
// ...
}
其次,實(shí)現(xiàn)一個(gè)“提交緩沖區(qū)”的函數(shù) flushBatch:
// 4. 批量提交函數(shù)
const flushBatch = useCallback(() => {
// 如果緩沖區(qū)有數(shù)據(jù)
if (batchBufferRef.current.length > 0) {
// 一次性提交給 store
appendLogs(batchBufferRef.current)
// 清空緩沖區(qū)
batchBufferRef.current = []
}
// 重置定時(shí)器引用
batchTimerRef.current = null
}, [appendLogs]) // 依賴 appendLogs
最后,修改 SSE 的事件處理函數(shù) handleLogEvent:
// 5. 新的 SSE 事件處理函數(shù)
const handleLogEvent = useCallback(
(event) => {
const entry = {
/* ...解析日志... */
}
// 重點(diǎn):不再直接調(diào)用 appendLog
// 而是將日志加入緩沖區(qū)
batchBufferRef.current.push(entry)
// 如果還沒有計(jì)劃批處理,則在下一個(gè)事件循環(huán)中執(zhí)行
if (batchTimerRef.current === null) {
batchTimerRef.current = window.setTimeout(flushBatch, 0)
}
},
[flushBatch] // 依賴 flushBatch
)
為什么是setTimeout(..., 0)?
你可能會(huì)問,為什么是 setTimeout(..., 0)?
這是一個(gè)很巧妙的技巧。它并不是真的“延遲 0 毫秒”,而是告訴瀏覽器:“請(qǐng)?jiān)诋?dāng)前這一輪事件循環(huán)(Event Loop)的同步代碼都執(zhí)行完之后,再執(zhí)行這個(gè) flushBatch 函數(shù)。”
當(dāng) 150 條日志在短時(shí)間內(nèi)涌入時(shí),會(huì)發(fā)生什么?
- 事件 1 抵達(dá) →
push到緩沖區(qū) →setTimeout注冊(cè)一個(gè)flushBatch回調(diào)。 - 事件 2 抵達(dá) →
push到緩沖區(qū) → 檢查定時(shí)器,發(fā)現(xiàn)已有,跳過。 - 事件 3 抵達(dá) →
push到緩沖區(qū) → 跳過。 - ...
- 事件 150 抵達(dá) →
push到緩沖區(qū) → 跳過。 - (當(dāng)前宏任務(wù)結(jié)束,所有同步代碼執(zhí)行完畢)
- 瀏覽器從任務(wù)隊(duì)列中取出
flushBatch回調(diào),執(zhí)行。 flushBatch函數(shù)將 150 條日志一次性提交給 React。
于是,150 次 setState 調(diào)用,被神奇地合并成了 1 次。應(yīng)用流暢如初。
到此這篇關(guān)于React處理高頻的實(shí)時(shí)數(shù)據(jù)的解決方案的文章就介紹到這了,更多相關(guān)React處理高頻實(shí)時(shí)數(shù)據(jù)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React使用useSearchParams同步URL和查詢參數(shù)的方法
在開發(fā)React應(yīng)用時(shí),我們經(jīng)常遇到一種場景:用戶在搜索框輸入關(guān)鍵詞,篩選出一個(gè)列表,然后希望把這個(gè)結(jié)果分享給同事,在React Router v6中,useSearchParams這個(gè)Hook就是專門用來處理這個(gè)問題的,本文將介紹如何使用它來實(shí)現(xiàn) URL 與應(yīng)用狀態(tài)的同步,需要的朋友可以參考下2025-12-12
React?高階組件與Render?Props優(yōu)缺點(diǎn)詳解
這篇文章主要weidajai?介紹了React?高階組件與Render?Props優(yōu)缺點(diǎn)詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-11-11
在React頁面重新加載時(shí)保留數(shù)據(jù)的實(shí)現(xiàn)方法總結(jié)
在React頁面重新加載時(shí)保留數(shù)據(jù),可以通過多種方法來實(shí)現(xiàn),常見的方法包括使用瀏覽器的本地存儲(chǔ)(Local Storage 或 Session Storage)、URL參數(shù)、以及服務(wù)器端存儲(chǔ)等,本文給大家總結(jié)了一些具體實(shí)現(xiàn)方法,需要的朋友可以參考下2024-06-06
詳解如何優(yōu)雅地在React項(xiàng)目中使用Redux
這篇文章主要介紹了詳解如何優(yōu)雅地在React項(xiàng)目中使用Redux,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-12-12
React組件三大核心屬性State?props?Refs介紹
組件實(shí)例的三大核心屬性是:State、Props、Refs。類組件中這三大屬性都存在。函數(shù)式組件中訪問不到?this,也就不存在組件實(shí)例這種說法,但由于它的特殊性(函數(shù)可以接收參數(shù)),所以存在Props這種屬性2023-02-02
React 全自動(dòng)數(shù)據(jù)表格組件——BodeGrid的實(shí)現(xiàn)思路
表格是在后臺(tái)管理系統(tǒng)中用的最頻繁的組件之一,相關(guān)的功能有數(shù)據(jù)的新增和編輯、查詢、排序、分頁、自定義顯示以及一些操作按鈕。這篇文章主要介紹了React 全自動(dòng)數(shù)據(jù)表格組件——BodeGrid ,需要的朋友可以參考下2019-06-06

