跳转至

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 → O(1) 查找和更新

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, 前端状态管理