做了个 AI 对话页面才发现,流式渲染没想象中那么简单
最近在做一个 对话相关的功能,后端通过 SSE 逐 token 推送回答。一开始觉得挺简单------收到数据就 setState 呗。结果消息一多,每来一个 token 整个列表都在重渲染,帧率肉眼可见地往下掉;滚动条要么不跟着走,要么用户往上翻两下就被弹回底部。折腾了一圈之后,总结出三个关键问题的解法,分享一下思路。
一、问题拆解
把"流式对话 UI"这个大问题拆成三个独立子问题:
| # | 问题 | 核心矛盾 |
|---|---|---|
| 1 | 状态管理 | 流式消息需要高频更新单条数据,但不能让整个列表跟着抖 |
| 2 | 渲染优化 | React 默认行为是"父组件更新 → 子组件全部重渲染",在消息列表场景下是灾难 |
| 3 | 自动触底 | 内容在增长、DOM 在变高,滚动条要跟着走,但又不能跟得太"死" |
下面逐个击破。
二、状态管理:归一化 + Proxy 精准追踪
2.1 为什么不用数组存消息?
最直觉的方案:
ts
const [messages, setMessages] = useState<Message[]>([]);
流式更新时要更新数组中的某一条消息:
ts
setMessages((prev) =>
prev.map((msg) => (msg.id === targetId ? { ...msg, content: msg.content + token } : msg)),
);
问题在于:每次更新都生成了一个全新的数组引用,React 会认为整个列表都"变了",所有子组件都会走一遍 reconciliation。消息越多,每个 token 的更新成本越高。
2.2 归一化(Normalized)结构
借鉴 Redux 推荐的归一化思想,把消息拆成两个数据结构:
ts
const store = {
entities: {}, // Record<messageId, Message> → O(1) 查找和更新
order: [], // messageId[] → 只维护顺序
};
流式更新时:
ts
// 只改 entities 中的一个 key,order 完全不动
store.entities[messageId].content += token;
2.3 为什么选 Valtio
Valtio 是一个 Proxy-based 的状态管理库。它的核心优势是自动依赖追踪:
ts
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 列表组件只管顺序
tsx
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 每条消息独立读取数据
tsx
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 内容块级别继续拆分
一条助手消息内部可能有多个内容块(文本、代码、工具调用等),再拆一层:
tsx
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} />;
}
});
当流式文本在追加时,只有对应的 <Markdown> 组件重渲染。旁边的代码块、工具调用卡片完全不受影响。
3.4 渲染示意图
scss
收到一个 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 自己实现的困境
最容易想到的方案:
tsx
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:
tsx
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 实际发生尺寸变化后 触发,时序天然正确- 而且它不关心"为什么变高了"(可能是流式文本、可能是图片加载、可能是折叠展开),统一处理
五、把它们串起来
最后,这三层是怎么协作的?一张图说清楚:
scss
用户发送消息
│
├──▶ 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 的核心痛点。如果你也在做类似的产品,希望少帮你踩几个坑。
如果这篇文章对你有帮助,欢迎点赞收藏 👍