React 高频渲染场景:数据更新像“机关枪”,页面怎么不掉帧?#
我的实战背景很接地气:在做一个 AI 聊天室时,前端用 React,后端通过 text/event-stream 源源不断地推消息。数据少的时候一切安好;一旦消息多了、列表长了、更新还频繁,页面就开始“喘不上气”——丢帧、卡顿、主线程忙不过来。于是我动手做了几轮优化和压测,下面把过程、思路、代码和结论给各位交代清楚。
场景背景#
- 技术方案:React + fetch(SSE)长连接,
text/event-stream
持续接收消息。 - 特点:消息列表较长、后端高频推送、消息还需要累计拼接(每次在已有字符串尾部追加约 15 个字符)。
- 朴素实现:每接到一次消息就
setState
更新消息列表。 - 结果:当成为长列表并高频更新时,UI“招架不住”。
问题复现与瓶颈定位#
- 问题1:大量
setState
触发频繁渲染,视觉丢帧卡顿。 - 问题2:每
1ms
来一条消息(+15 字符
),就setState
一次。React 更新是异步批处理,更新频率过高时,更新任务堆积,虚拟 DOM diff + 真实 DOM 更新时间越来越长,主线程被 DOM 操作阻塞。 - 问题3:大约堆到 400 条消息左右,明显卡顿甚至“卡死”,无法正常操作。
优化路线与压测结果#
以下方案朴实无华,主打我上我也行。(关键是思路,文末有压测演示)
方案 V1:纯节流 throttle(100ms 一次)#
- 思路:简单粗暴,降低
setState
频率。 - 实现:每 100ms 合并一次更新。
- 压测结果:
- 20条/ms * 400ms = 8000 条消息,渲染总耗时约 204s(3 分 20 秒)。
- 渲染到 3000 条附近,已有明显性能下降,主线程偶尔阻塞但还能渲染完。
- 总结:能用,但时间太长、仍有卡顿。
示例(lodash.throttle):
const flush = throttle( () => { setMessageList((draft) => { const last = draft[draft.length - 1] last.message += bufferRef.current }) bufferRef.current = '' }, 100, { leading: false, trailing: true })
方案 V2:消息队列 + 节流批量刷新#
- 思路:把每次收到的 msg 先进队列,达到一定条数(或时间窗口)后,再触发节流合并刷新一次,减少渲染次数。
- 压测结果:
- 渲染过程明显更顺滑,主线程无卡死。
- 20条/ms * 400ms = 8000 条,耗时降到约 22s(较 V1 降低约 90%)。
- 总结:效果显著,但随着数据总量增长,队列长度和合并成本也在变大,性能仍会下降,只是下降得更“优雅”。
核心片段:
const queueRef = useRef<string[]>([])const push = (msg: string) => { queueRef.current.push(msg) flush() // 触发节流的合并刷新}
const flush = throttle( () => { const batch = queueRef.current.join('') queueRef.current.length = 0 setMessageList((draft) => { const last = draft[draft.length - 1] last.message += batch }) }, 100, { leading: false, trailing: true })
方案 V3:节流函数里加空闲帧渲染(requestAnimationFrame)#
- 思路:进一步把合并后的真正渲染放进 rAF,让浏览器挑轻松的一帧来做 UI 更新,减少和布局/绘制抢主线程。
- 压测结果:
- 渲染过程流畅,主线程无阻塞。
- 20条/ms * 400ms = 8000 条,耗时约 16~18s(较 V2 再降 20~25%)。
- 总结:rAF 是把 UI 更新“安排在恰当时机”的关键手段。
核心片段:
const flush = throttle( () => { requestAnimationFrame(() => { const batch = queueRef.current.join('') queueRef.current.length = 0
setMessageList((draft) => { const last = draft[draft.length - 1] last.message += batch })
chatMessageContainerRef.current?.scrollToBottom() }) }, 100, { leading: false, trailing: true })
进一步压测:每 1ms × 20 条消息,触发 600 次(共 12000 条)#
- 执行情况:渲染流畅,主线程不阻塞
- 结果:约 50s
方案 4:把节流间隔从 100ms 提到 300ms(实测最优点)#
- 为什么 300ms?
- <100ms:用户感知为“即时响应”(适合点击反馈)
- 100~300ms:轻微延迟,但流程连贯
- 300ms~1s:明显延迟,注意力易分散
- 1s:不可接受
- 本场景是“消息列表渲染”,不要求即时,只要连贯流畅即可,所以设为 300ms。
- 压测结果(rAF + 队列 + 300ms 节流):
- 8000 条(最终字符串长度约 13 万):2~3s
- 12000 条:5~6s
- 16000 条(最终字符串长度约 26 万):18~19s
- 20000 条:60~62s
- 总结:满足需求。真实聊天室很少到这种极端并发,完全可用作“最终落地方案”。
对比 Tip:仅“队列 + rAF”(不加节流)#
- 8000 条:约 28~30s
- 结论:节流是“降频”的关键;队列和 rAF 负责“合并 + 时机”。三件套一起用,收益最大。
可抄用的示例代码(含压测入口)#
下面是一段整理过的测试/生产两用代码。它覆盖“队列 + 节流 + rAF”,并且对“把内容拼接到最后一条消息”的场景做了适配。
import React, { useState, useCallback, useRef, useMemo, useEffect,} from 'react@18'import { createRoot } from 'react-dom@18/client'import throttle from 'lodash.throttle'
type Message = { id: string; message: string }
export default function ChatPerfDemo() { const [visible, setVisible] = useState<boolean>(true) const [count, setCount] = useState<number>(0)
const [messageList, setMessageList] = useState<Message[]>([ { id: 'init', message: '' }, ])
const chatMessageContainerRef = useRef<{ scrollToBottom: () => void }>(null)
// 批量缓冲区(用 ref 避免频繁触发渲染) const bufferRef = useRef<string>('')
// 节流 + rAF 的合并刷新 const flush = useMemo( () => throttle( () => { requestAnimationFrame(() => { if (!bufferRef.current) return const batch = bufferRef.current bufferRef.current = ''
setMessageList((draft) => { const newDraft = [...draft] const last = { ...newDraft[newDraft.length - 1] } last.message += batch newDraft[newDraft.length - 1] = last return newDraft })
chatMessageContainerRef.current?.scrollToBottom?.() }) }, 300, // 关键:根据场景选择 100ms / 300ms { leading: false, trailing: true } ), [] )
const play = useCallback(() => { setVisible(false)
let round = 0 const timer = setInterval(() => { round++ if (round > 6000) { clearInterval(timer) return } // 每次“进 20 条” for (let j = 0; j < 20; j++) { bufferRef.current += `~~~~~${round}:${j + 1}~~~~~~` } flush() }, 1) return () => clearInterval(timer) }, [flush])
return ( <div> <div>数据渲染时,进行输入、计算、动画测试,检测fps是否流畅</div> <input type="text" placehoder="可输入" />
<div> <button onClick={() => { setCount((v) => v + 1) }} > 点击+1 </button> <span>{count}</span> </div>
<div> <div className="move"></div> </div>
{visible && <button onClick={play}>开始渲染</button>} <MessageList ref={chatMessageContainerRef} items={messageList} /> </div> )}
// 极简的消息列表const MessageList = React.forwardRef< { scrollToBottom: () => void }, { items: Message[] }>(({ items }, ref) => { const ulRef = useRef<HTMLUListElement>(null)
React.useImperativeHandle(ref, () => ({ scrollToBottom() { const el = ulRef.current if (!el) return el.scrollTop = el.scrollHeight }, }))
return ( <> <div>当前渲染字符长度:{items[0].message.length || 0}</div> <ul ref={ulRef} style={{ height: 400, overflow: 'auto', fontFamily: 'monospace', padding: 8, }} > {items?.map((m) => ( <li key={m.id} style={{ wordWrap: 'break-word' }}> <MessageRow text={m.message} /> </li> ))} </ul> </> )})
// 列表项尽量 pure,避免重复渲染const MessageRow = React.memo(({ text }: { text: string }) => { return <span>{text}</span>})
const app = document.getElementById('app')const root = createRoot(app!)root.render(<ChatPerfDemo />)
要点说明:
- 把“高频原子更新”变成“低频批量更新”:用
bufferRef
暂存、throttle + rAF
合并刷新。 React.memo
保住列表项纯净,减少不必要渲染。scrollToBottom
放到刷新后执行,避免和布局冲突。- 生产环境用 SSE 时,直接把
e.data
追加到bufferRef
,然后调用flush()
。
进一步可选优化(按需选)#
- 列表虚拟化:
react-window
/react-virtual
,越长的列表越明显。聊天这种“只显示视窗区域”的场景非常适合虚拟化。 - 仅更新尾部:你的场景是“拼接最后一项”,尽量保证只改动那一项,避免整个数组引用变更(或做好
key
稳定 +React.memo
)。 - 派生数据缓存:对长文本的统计/解析,放
useMemo
;避免每次渲染都重算。 - React 18 并发特性:对“非紧急更新”使用
startTransition
,把“重任务”降优先级,让交互保持顺滑。 - CSS 性能:文本区域用
will-change: contents
不靠谱;但滚动容器开启合成层(比如transform: translateZ(0)
)有时能减轻卡顿。 - 避免日志“拖后腿”:高频场景下
console.log
也是性能杀手,压测请关掉。 - Web Worker(重度场景):文本拼接/解析非常重时,考虑挪到 Worker 再回主线程更新。
最终结论#
- 高频更新≠高频渲染。核心是“把更新合并、挑好时机、降低频率”。
- 队列 + rAF + 节流(300ms)这三件套,在我的场景里是性价比最高的组合,压测已达标。
- 真正的“终极解法”是组合拳:虚拟化列表 + 批量合并 + 降优先级。别死磕某一个点。
这套思路落进业务后,页面终于从“猛男硬上”变成“润物无声”。消息再密,UI 依旧稳。