---
url: /frontend-ai/streaming-markdown.md
description: >-
  聊天界面里边收边渲染 Markdown 的两个坑：整段重新解析拖垮帧率，以及半截语法（未闭合的加粗、代码围栏）被当成品排版后跳动。讲清 block
  缓存、自愈解析（remend）、增量高亮（Shiki）的取舍，附 react-markdown 与 streamdown 的版本核实。
---

# 流式输出的 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，攒到下一帧一起刷。

::: warning 几个常踩的坑

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` 一条命令装完，剩下真正费功夫的地方在节流和缓存上。
