我的实战背景很接地气:在做一个 AI 聊天室时,前端用 React,后端通过 text/event-stream 源源不断地推消息(token)。数据少的时候一切安好;一旦消息多了、列表长了、更新还频繁,页面就开始“喘不上气”——丢帧、卡顿、主线程忙不过来。于是我动手做了几轮优化和压测,下面把过程、思路、代码和结论给各位交代清楚。
场景背景
- 技术方案:React + fetch(SSE)长连接,
text/event-stream持续接收消息。 - 特点:消息列表较长、后端高频推送、消息还需要累计拼接(每次在已有字符串尾部追加约 15 个字符)。
- 朴素实现:每接到一次消息就
setState更新消息列表。 - 结果:当成为长列表并高频更新时,UI“招架不住”。
问题复现与瓶颈定位
- 问题1:大量
setState触发频繁渲染,视觉丢帧卡顿。 - 问题2:每
1ms来一条消息(token≈ 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”,并且对“把内容拼接到最后一条消息”的场景做了适配。
要点说明:
- 把“高频原子更新”变成“低频批量更新”:用
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 依旧稳。