解决流式图文闪烁问题

138 阅读3分钟

页面对接 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

解析流程大概是:

  1. 第一次 chunk:我选了
  2. 第二次:我选了柯基
  3. ……
  4. 某次 chunk 里出现完整 ![](url)
  5. 下一次又来文字,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>

image.png 闪烁的本质是:本不该卸载的 DOM 被卸载了。

图片 URL 在 SSE 里一旦确定,就不该再变。把它从「字符串的一部分」抽成「独立块 + 稳定 key + memo」,React 的 diff 就能认出「这块没动」,浏览器也不会反复加载同一张图。

文字可以继续流式变长,因为它本来就该变——只更新最后一个 text 块,代价可控。