Fluxio
← 返回博客

流式 Markdown 无闪烁渲染:从增量接收到稳定显示

SSE 与接口ZH

§1 第一步:用固定文本复现 Markdown 闪烁

先准备一段包含标题、加粗、列表、链接和代码块的回复,再把它拆成不规则片段逐个送入界面。不要只按完整句子切分,还要让片段停在星号、反引号和链接地址中间。你的目标不是立即优化,而是区分三种现象:格式反复变化、整个消息重新挂载,以及内容高度变化造成的滚动跳动。

未完成的 Markdown 并不总能保持最终语义。例如,后续到达的下划线分隔行可能把前一行普通文字解释成标题,链接引用定义也可能影响前文链接。因此,不能简单地把每次解析得到的段落都当作永久完成的内容。([spec.commonmark.org](https://spec.commonmark.org/0.31.2/))

§2 第二步:把接收缓冲与界面更新分开

建议把数据流拆成三个层次:原始回复、待处理增量,以及供渲染器读取的显示快照。新片段到达时,先追加到缓冲区,再安排一次界面提交。不要让网络回调直接承担解析、语法高亮和滚动控制,也不要为了制造打字效果,把已经收到的内容重新拆成单字播放。

可以使用 requestAnimationFrame 合并同一绘制周期内的更新;它会在浏览器下一次重绘前调用回调,而不是保证固定的更新间隔。具体做法是保留一个待执行标记,已有回调时只追加缓冲,回调执行后提交最新快照并清除标记。([developer.mozilla.org](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame))

为结束、取消和报错分别增加显式收尾路径。正常结束时立即提交尚未显示的增量,避免最后几个字留在缓冲区;取消时停止后续提交,保留已经收到的正文,并明确显示中断状态。

§3 第三步:区分稳定内容与可变尾部

接下来,把渲染范围划分成稳定内容和仍可能变化的尾部。对于自己实现的解析器,只缓存已经确认边界的块,并保留足够的上下文;不要把遇到空行作为唯一判断条件。涉及列表容器、引用定义或尚未闭合的代码围栏时,宁可扩大重新解析范围,也不要过早冻结错误结构。

如果采用 React 技术栈,可以评估专门面向流式内容的 Streamdown。它提供对不完整 Markdown 的处理,适合替代仅面向完整文档的渲染路径。先用第一步的固定文本验证行为,再决定是否替换现有组件,不必一开始就自建完整解析器。([github.com](https://github.com/vercel/streamdown))

所有临时补全都只能存在于显示层。原始回复应保持不变,复制、保存和后续处理都读取原文,避免把临时补上的符号当成模型实际输出。

§4 第四步:用稳定标识保留消息组件

为每条消息在创建时分配固定标识,并让后续增量持续更新同一个消息对象。不要把正文、正文长度或当前时间当作组件标识。对于内部块,也应使用解析器能够持续追踪的标识,而不是每次解析都重新生成随机值。

React 使用 key 判断列表项身份;在渲染期间生成新的 key 会导致组件和对应节点重新创建。因此,先排查标识是否变化,再考虑缓存组件,不能用缓存掩盖不断重新挂载的问题。([react.dev](https://react.dev/learn/rendering-lists))

§5 第五步:让代码高亮与滚动保持稳定

代码块先显示可读的纯文本,再按需要加载高亮能力。建议统一高亮前后的字体、字号、行高和内边距,避免高亮完成后整块内容突然增高。尚未完整到达的语言名称可以暂按纯文本展示,不必反复切换高亮器。

可以参考 Streamdown 的实现方向:代码先显示纯文本,已经完成的块避免随新增内容重复渲染,未知或截断的语言标识回退到纯文本高亮。这些处理针对的是实际渲染成本,而不是用整段淡入动画遮住闪烁。([vercel.com](https://vercel.com/changelog/streamdown-2-5?utm_source=openai))

滚动则采用独立状态:只有用户仍在底部附近时才跟随新增内容;用户向上阅读后暂停跟随,显示返回最新位置的入口。把滚动安排在内容提交之后,并避免每收到一个片段就触发平滑滚动。

§6 第六步:接入 Fluxio 时保持渲染层独立

如果项目还需要统一流式接口,可以考虑 Fluxio。它采用流式优先、按词元计费的 API 中转方式,回复逐词元到达,适合作为这套增量接收流程的上游。接入时,把服务端响应先转换成统一的正文增量事件,再交给缓冲与渲染模块,不要让页面组件依赖某个上游的原始事件结构。

建议把密钥和上游请求保留在后端,由后端向浏览器转发所需事件。也要明确边界:流式接口负责持续传输,前端仍需负责语法容错、更新调度和组件复用;更换接口本身不等于解决 Markdown 闪烁。

§7 第七步:用断流与长代码块完成验收

最后,用同一份原始文本重复回放不同分片方式,比较流式结束后的结果与完整文本一次性渲染的结果。对于临时容错显示,结束时应重新核对原文,而不是把显示层补全结果保存下来。建议把以下场景加入回归测试:

  • – 在加粗、链接和代码围栏中途暂停,确认尾部仍可阅读且不会整条消失。
  • – 连续输出长代码块和嵌套列表,检查历史内容是否重复挂载或明显跳动。
  • – 在用户向上阅读时继续接收增量,确认视口不会被强行拉回底部。
  • – 模拟取消、断线和正常结束,核对尾部文字、状态提示与复制内容是否一致。

验收时同时观察首次可读内容出现时间、更新耗时和组件挂载次数。把优化目标定为内容持续可读、既有结构尽量稳定,而不是要求新增文字完全不引起布局变化。

欢迎注册 Fluxio。

创建 Fluxio 账号 →

顺流再读几篇