页面对接 wan2.6-image 图文混排之后,SSE 是正常回来的——DevTools 里能看到文字一个字一个字推,最后也有 { type: "image", image: "https://..." }。
但肉眼看到的体验很差:文字在跳,图片闪一下,像重新加载,聊天框跟着上下晃,像页面在刷新
数据没问题,是渲染策略有问题。
一开始怎么写的
{messages.map((message) => (
<MessageBubble key={index}>
<ReactMarkdown>{message.text}</ReactMarkdown>
</MessageBubble>
))}
每来一个 SSE chunk,就把新内容拼到 message.text 上,整段交给 ReactMarkdown 重渲染。
本地只有纯文字时,这样勉强能用。一旦涉及图片,问题就暴露了。
根因:不是 React 慢,是 DOM 被反复拆掉
流式更新的本质是:同一帧里,旧 DOM 被整棵换掉,再挂一棵新的。
ReactMarkdown 每次都会重新解析整段字符串。如果内容里有:xxx.png
解析流程大概是:
- 第一次 chunk:
我选了 - 第二次:
我选了柯基 - ……
- 某次 chunk 里出现完整
 - 下一次又来文字,Markdown 再解析一遍
每一次解析,<img> 都是新创建的节点。浏览器会:
- 销毁旧
<img> - 插入新
<img> - 重新请求或重绘图片
人眼看到的就是闪一下。
实际场景更麻烦
wan2.6-image 混排模式下的顺序不是「先说完话,再给图」,而是:
文 → 文 → 文 → 图 → 文 → 文
文字和图片交错过来。整段 Markdown 方案在这种协议下几乎注定翻车——你没法假设「图片只在末尾出现一次」。
第二个问题:滚动
早期还写了:
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
messages 每变一次(流式时可能每秒十几次),就触发一次 smooth 滚动。内容高度还在变,滚动动画叠加上去,整个聊天框像在抖。
闪烁 + 滚动抖动,体感就是「页面一直在刷新」。
改法:别渲染「一整段」,渲染「一块一块」
核心思路:把不可变的东西和可变的东西分开。
1. 消息拆成 block 数组
blocks: [
{ type: 'text', id: 'block-1', content: '我选了柯基,因为……' },
{ type: 'image', id: 'block-2', url: 'https\://...' },
{ type: 'text', id: 'block-3', content: '腿短,看着就开心。' }
]
渲染时按类型分开:
{blocks.map((block) =>
block.type === 'image' ? <ImageBlock key={block.id} url={block.url} /> : <TextBlock key={block.id} content={block.content} />
)}
图片不再从 Markdown 里「解析出来」,而是 API 直接给 URL,单独成块。
2. 流式合并:只动该动的那一块
SSE 每来一个 chunk,不重建整数组,做增量合并:
文字 chunk → 追加到最后一个 text 块:
if (last?.type === 'text') {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + chunk.text },
];
}
// 图片 chunk → 新建 image 块,URL 去重:
if (type === 'image' && !alreadyExists) {
return [...prev, { type: 'image', id: nextBlockId(), url: chunk.image }];
}
这样:
- 前面的 text 块、已经出现的 image 块,引用和 id 都不变
- React 只更新最后一个 text 块的内容
- 图片块挂载一次之后,后面再怎么流文字,碰都不碰它
3. 图片组件加 memo
export default memo(ImageBlock, (prev, next) => prev.url === next.url);
父组件因为文字更新 re-render 时,只要 URL 没变,ImageBlock 直接跳过。<img> 留在 DOM 里,不会重载。
4. 流式阶段别用 ReactMarkdown
逐字输出时,ReactMarkdown 每加一个字符就重解析一遍,纯浪费,文字区域也会抖。
流式文字直接当纯文本渲染:
<div style={{ whiteSpace: 'pre-wrap' }}>{content}</div>
闪烁的本质是:本不该卸载的 DOM 被卸载了。
图片 URL 在 SSE 里一旦确定,就不该再变。把它从「字符串的一部分」抽成「独立块 + 稳定 key + memo」,React 的 diff 就能认出「这块没动」,浏览器也不会反复加载同一张图。
文字可以继续流式变长,因为它本来就该变——只更新最后一个 text 块,代价可控。