AI对话页面流式渲染挑战:状态管理与优化策略解析
原文地址: https://88box.top 生成时间: 2026-05-19 16:32:44
做了个 AI 对话页面才发现,流式渲染没想象中那么简单 - hey99 知识搜索引擎
精选文章
做了个 AI 对话页面才发现,流式渲染没想象中那么简单
做了个 AI 对话页面才发现,流式渲染没想象中那么简单 一、问题拆解 把"流式对话 UI"这个大问题拆成三个独立子问题...
更新于 2026-05-19 08:17
AIGC
前端
做了个 AI 对话页面才发现,流式渲染没想象中那么简单
最近在做一个 对话相关的功能,后端通过 SSE 逐 token 推送回答。一开始觉得挺简单——收到数据就 setState 呗。结果消息一多,每来一个 token 整个列表都在重渲染,帧率肉眼可见地往下掉;滚动条要么不跟着走,要么用户往上翻两下就被弹回底部。折腾了一圈之后,总结出三个关键问题的解法,分享一下思路。
一、问题拆解
把"流式对话 UI"这个大问题拆成三个独立子问题:
问题
核心矛盾
1
状态管理
流式消息需要高频更新单条数据,但不能让整个列表跟着抖
2
渲染优化
React 默认行为是"父组件更新 → 子组件全部重渲染",在消息列表场景下是灾难
3
自动触底
内容在增长、DOM 在变高,滚动条要跟着走,但又不能跟得太"死"
下面逐个击破。
二、状态管理:归一化 + Proxy 精准追踪
2.1 为什么不用数组存消息?
最直觉的方案:
const
[messages, setMessages] = useState<
Message
[]>([]);
流式更新时要更新数组中的某一条消息:
setMessages
(
(
prev
) =>
prev.
map
(
(
msg
) =>
(msg.
id
=== targetId ? { ...msg,
content
: msg.
content
- token } : msg)),
);
问题在于:
每次更新都生成了一个全新的数组引用
,React 会认为整个列表都"变了",所有子组件都会走一遍 reconciliation。消息越多,每个 token 的更新成本越高。
2.2 归一化(Normalized)结构
借鉴 Redux 推荐的归一化思想,把消息拆成两个数据结构:
const
store = {
entities
: {},
// Record
order
: [],
// messageId[] → 只维护顺序
};
流式更新时:
// 只改 entities 中的一个 key,order 完全不动
store.
entities
[messageId].
content
+= token;
2.3 为什么选 Valtio
Valtio 是一个 Proxy-based 的状态管理库。它的核心优势是
自动依赖追踪
:
import
{ proxy, useSnapshot }
from
'valtio'
;
// 创建 store —— 就是一个普通对象,被 Proxy 包了一层
const
store =
proxy
({
entities
: {},
order
: [],
isLoading
:
false
,
});
// 组件里用 useSnapshot 读取
function
SomeComponent
(
) {
const
snap =
useSnapshot
(store);
// Valtio 记住了你读了 snap.entities['msg-1']
// 只有 entities['msg-1'] 变化时才触发重渲染
return
<
div
{snap.entities['msg-1']?.content}
</
div
;
}
跟其他方案对比一下:
方案
更新 1 条消息时的副作用
useState
- 数组
整个数组引用变化 → 所有消息组件 re-render
Redux +
useSelector
需要手动写 selector +
shallowEqual
,写不好就全量刷新
Valtio +
useSnapshot
Proxy 自动追踪读取路径
,只有
entities[thisId]
变了才刷新
在流式场景下,后端每秒可能推 30+ 个 token,每次只更新一条消息。Valtio 的 Proxy 机制让"只有正在接收流的那一条消息重渲染"变成了默认行为,不需要额外优化。
三、渲染优化:让更新范围最小化
状态管理层保证了"数据变更的粒度是单条消息",但还需要组件层配合,才能真正缩小重渲染范围。
3.1 列表组件只管顺序
const
MessageList
= (
) => {
const
{ order } =
useSnapshot
(store);
// order 只是一个 string[]
// 流式更新时 order 不变(长度不变、顺序不变)
// 所以 MessageList 本身不会 re-render ✅
return
(
<
div
{order.map((id) => (
<
MessageItem
key
=
{id}
id
=
{id}
/>
))}
</
div
);
};
3.2 每条消息独立读取数据
const
MessageItem
=
React
.
memo
(
(
{ id }
) =>
{
const
snap =
useSnapshot
(store);
const
message = snap.
entities
[id];
// 只有当 entities[id] 这个对象变化时,这个组件才 re-render
// 其他消息的更新、isLoading 的变化,都不会影响到这里
return
(
<
div
<
Avatar
role
=
{message.role}
/>
<
ContentBlocks
blocks
=
{message.blocks}
/>
</
div
);
});
3.3 内容块级别继续拆分
一条助手消息内部可能有多个内容块(文本、代码、工具调用等),再拆一层:
const
ContentBlocks
= (
{ blocks }
) => {
return
blocks.
map
(
(
block
) =>
<
ContentBlock
key
=
{block.id}
block
=
{block}
/>
);
};
const
ContentBlock
=
React
.
memo
(
(
{ block }
) =>
{
switch
(block.
type
) {
case
'text'
:
return
<
Markdown
content
=
{block.content}
/>
;
case
'code'
:
return
<
CodeBlock
code
=
{block.content}
/>
;
case
'tool'
:
return
<
ToolCard
tool
=
{block}
/>
;
}
});
当流式文本在追加时,只有对应的
组件重渲染。旁边的代码块、工具调用卡片完全不受影响。
3.4 渲染示意图
收到一个 token → store
.entities
[
'msg-3'
]
.blocks
[0]
.content
+= token
│
Valtio Proxy 检测变化
│
┌──────────────────────────┼────────────────────┐
│ │ │
MessageItem
(msg-
1
)
MessageItem
(msg-
3
)
MessageItem
(msg-
5
)
读了 entities
[
'msg-1'
]
读了 entities
[
'msg-3'
]
读了 entities
[
'msg-5'
]
没变 → 跳过 ✅ 变了 → re-render ⚡ 没变 → 跳过 ✅
│
┌──────────┴──────────┐
ContentBlock
[0]
ContentBlock
[1]
(text, 变了 ⚡) (tool, 没变 ✅)
四、自动触底:看起来简单,全是边界
4.1 自己实现的困境
最容易想到的方案:
useEffect
(
() =>
{
containerRef.
current
.
scrollTop
= containerRef.
current
.
scrollHeight
;
}, [messages]);
问题一堆:
时序错位
:
useEffect
触发时 DOM 可能还没更新完,
scrollHeight
是旧的
覆盖用户操作
:用户向上翻看历史消息时,新 token 进来就把他弹回底部
性能浪费
:每个 token 都触发一次
scrollTo
,在流式场景下每秒 30+ 次
4.2 use-stick-to-bottom:声明式的贴底容器
use-stick-to-bottom
用了一个完全不同的思路 ——
不监听 state,监听 DOM
:
import
{
StickToBottom
, useStickToBottomContext }
from
'use-stick-to-bottom'
;
const
ChatPage
= (
) => (
<
StickToBottom
<
StickToBottom.Content
<
MessageList
/>
</
StickToBottom.Content
</
StickToBottom
);
它的
工作原理
:
内部用
ResizeObserver
监听
内容容器的尺寸变化
维护一个内部状态
isAtBottom
如果
isAtBottom === true
且内容高度增加了 → 自动
scrollTo(bottom)
用户向上滚动 →
isAtBottom
变为
false
→ 停止自动贴底
用户手动滚回底部 →
isAtBottom
恢复
true
→ 重新贴底
为什么
ResizeObserver
比
useEffect
好?
useEffect(() => ..., [messages])
在 React 状态更新时触发,但 DOM 不一定渲染完了
ResizeObserver
在
DOM 实际发生尺寸变化后
触发,时序天然正确
而且它不关心"为什么变高了"(可能是流式文本、可能是图片加载、可能是折叠展开),统一处理
五、把它们串起来
最后,这三层是怎么协作的?一张图说清楚:
用户发送消息
│
├──▶ store
.order
.push
(userMsgId, botMsgId)
// 占位
└──▶ 建立 SSE 连接
│
│ 收到 token
▼
store
.entities
[botMsgId]
// 只改一条消息
.blocks
[0]
.content
+= token
│
│ Valtio Proxy 通知
▼
┌────────┴────────┐
│ │
MessageItem ResizeObserver
(只重渲染这一条) (检测到内容变高)
│ │
▼ ▼
DOM 局部更新 自动触底
三层各管各的事,互不耦合:
Store 层
:只管数据怎么存、怎么更新 —— 归一化 + 单条修改
组件层
:只管渲染最小范围 ——
React.memo
- 按 ID 读取
滚动层
:只管 DOM 高度变了怎么办 ——
ResizeObserver
+
isAtBottom
状态机
六、总结
问题
核心方案
一句话原理
状态管理
Valtio + 归一化
entities[id]
直接改,O(1) 更新且不影响其他消息
渲染优化
React.memo
- 按 ID 读取
Proxy 追踪读取路径,只有读了的数据变了才 re-render
自动触底
use-stick-to-bottom
ResizeObserver
监听 DOM 而非 state,时序天然正确
这三层方案各自独立,放在一起刚好覆盖了流式对话 UI 的核心痛点。如果你也在做类似的产品,希望少帮你踩几个坑。
如果这篇文章对你有帮助,欢迎点赞收藏 👍
查看原文
🏷 标签: 流式渲染, React 性能优化, SSE, Valtio, 前端状态管理