Skip to content

流式输出的 Markdown,怎么渲染才不闪 ​

上一篇讲了怎么用 fetch 把大模型的流接进浏览器,到手的是一串越来越长的 markdown(标记语言)字符串。这篇接着往后走一步:这串文本怎么渲染到屏幕上,才不闪、不掉帧。

流式本身不难,难的是边收边渲染 ​

按 data: 分帧、把增量拼进一个字符串,这些是协议层的事,一个 TextDecoder 加上 { stream: true } 就够。

麻烦从你把这段字符串交给 Markdown 渲染器那一刻开始。字符串每变一次,渲染器就从头解析一次。而此时句子往往只有半截,语法都没闭合,你已经把它当成写完的文档排了版。

react-markdown 会把整段重新解析一遍 ​

react-markdown(核对于 2026-10-05,npm 上最新 10.1.0)的用法,是把整段 markdown 当 children 传进去。

它的定位是渲染一段静态文本,从没考虑过这段文本会长大。

于是每来一个 token,它就重跑一遍 remark、rehype 解析链,重新生成一棵 React 元素树,再交给 reconciler(协调器)做 diff。

文档越长,这一步越贵,开销随字数线性上涨。

我实测过一个 3000 字上下的回答,token 间隔大概 20 到 50 毫秒,页面肉眼可见地掉帧。

挖下去才发现,最贵的还不是解析本身,是每一帧都让整棵消息列表跟着 diff 了一遍。

半截的 markdown 会渲染出怪东西 ​

比卡顿更烦的是内容错。加粗写到一半,**重点 的标记不闭合,整段就原样显示成两个星号。代码围栏开头那三个反引号还没写收尾,后面的正文会全被吞进代码块,等围栏补上又「哗」地跳回来。

表格和引用块同理。用户看到的是排版不停跳动,字在闪。这不是 CSS 的锅,是渲染器把一个没写完的语法当成了写完的。

remend 给没写完的语法打补丁 ​

社区现在的解法是「自愈」:渲染之前先补全未闭合的语法。Vercel 的 streamdown(最新 2.7.0)内置了一个叫 remend(1.4.0)的包,专门修断裂的粗体、代码围栏和链接,先修成合法 markdown,再交给解析器。

它是 react-markdown 的 drop-in(可直接替换)替代,用法几乎一样:

tsx
import { Streamdown } from 'streamdown';

<Streamdown isAnimating={isLoading}>{markdown}</Streamdown>

isAnimating 为真时进流式模式,尾部跟一个光标;流结束切回静态模式,整段走一次完整渲染。

代码块是最贵的那部分 ​

语法高亮,要么用 Shiki(4.5.0,跑 TextMate 语法),要么用 highlight.js。Shiki 给一段代码生成高亮 HTML,要跑一遍语法分析,几十毫秒起步,逐 token 重跑能直接堵死主线程。

两条路可走。一是等围栏闭合再高亮,没闭合时先按纯文本显示,代价是代码块会「亮一下」。二是做增量高亮,@shikijs/stream 把文本流按 token 切开,只重算变动的尾部,CodeToTokenTransformStream 就是干这个的。

大多数聊天产品选第一条。用户的注意力在第一行字上,半秒的高亮延迟基本感知不到,卡顿却一眼就看得出来。

我在项目里的取舍 ​

我现在的做法分三层。

已经渲染完的块用 memo(记忆化)包住,props 深比较,token 只动最后一块,前面的块一次都不重渲。这正是 streamdown 里 Block 组件做的事,自己手写也不复杂。

高频更新(流式)和低频更新(输入框)分开,用 React 的 useDeferredValue 或 startTransition,把重渲染降级成可中断任务,输入框就不会跟着卡。

最外面再套一层 requestAnimationFrame 节流,别每个 chunk 都 setState,攒到下一帧一起刷。

几个常踩的坑

  1. Tailwind 要加 @source "../node_modules/streamdown/dist/*.js",漏了样式就不生效,这是最容易忘的一步。
  2. 代码高亮(@streamdown/code)、数学(@streamdown/math,走 KaTeX 0.19.0)、图表(@streamdown/mermaid)都是独立包,装了哪个就只加哪个的 @source。
  3. 模型吐的 HTML 不能直接塞进 dangerouslySetInnerHTML,streamdown 用 rehype-harden 过滤,自己裸写就得上 DOMPurify。
  4. 输出只有几十字的场景别上这套,首屏和高亮的开销不划算。

收个尾:先把「不闪」当成渲染正确性问题来解,性能是顺带的结果。方案不必自研,streamdown 一条命令装完,剩下真正费功夫的地方在节流和缓存上。

最近更新