做了个 AI 对话页面才发现,流式渲染没想象中那么简单

做了个 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]);

问题一堆:

  1. 时序错位useEffect 触发时 DOM 可能还没更新完,scrollHeight 是旧的
  2. 覆盖用户操作:用户向上翻看历史消息时,新 token 进来就把他弹回底部
  3. 性能浪费 :每个 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>
);

它的工作原理

  1. 内部用 ResizeObserver 监听内容容器的尺寸变化
  2. 维护一个内部状态 isAtBottom
  3. 如果 isAtBottom === true 且内容高度增加了 → 自动 scrollTo(bottom)
  4. 用户向上滚动 → isAtBottom 变为 false → 停止自动贴底
  5. 用户手动滚回底部 → isAtBottom 恢复 true → 重新贴底

为什么 ResizeObserveruseEffect 好?

  • useEffect(() => ..., [messages]) 在 React 状态更新时触发,但 DOM 不一定渲染完了
  • ResizeObserverDOM 实际发生尺寸变化后 触发,时序天然正确
  • 而且它不关心"为什么变高了"(可能是流式文本、可能是图片加载、可能是折叠展开),统一处理

五、把它们串起来

最后,这三层是怎么协作的?一张图说清楚:

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 的核心痛点。如果你也在做类似的产品,希望少帮你踩几个坑。


如果这篇文章对你有帮助,欢迎点赞收藏 👍

相关推荐
环信7 小时前
环信Flutter UIKit适配鸿蒙实战指南
前端
环信7 小时前
HarmonyOS Flutter 键盘高度监听插件开发完全指南
前端
真夜7 小时前
开发正常但生产异常的 Bug:Vite manualChunks 循环依赖导致 ReferenceError
前端·前端框架·vite
用户11481867894847 小时前
Vue 开发者快速上手 Flutter(四)
前端
dreamsever7 小时前
OpenTelemetry可观测系统之Metrics学习
java·前端·学习
Bacon7 小时前
装上就回不去了:CodeGraph 让 AI 编程效率飙升 92%,它到底做了什么?
前端·人工智能·后端
hadeas7 小时前
Spring 技术栈学习文档(面向前端开发者)
前端
狗头大军之江苏分军7 小时前
Python 协程进化史:从 yield 到 async/await 的底层实现
前端·后端
摄影图7 小时前
科技企业研发宣传图片素材 适配多场景宣传使用需求
大数据·人工智能·科技·aigc·贴图·插画