Skip to main content

半截 Markdown 怎么渲染

渲染 Markdown 是个已解决的问题——直到 Markdown 还没写完。

AI 对话界面永远不会给你一份完整的文档,它给的是一个逐 token 增长的字符串。在任意一帧上,这个字符串都可能是:一个没有闭合的代码围栏、半个加粗标记、有表头但没有分隔行的表格、少一个右括号的链接。

喂给常规渲染器,你会得到闪烁的标记、一闪而过的星号、以及每次 token 到达时的布局跳动。

token 流缓冲按帧合并补齐半截语法围栏/强调/链接渲染高亮延后两个必须已完成区块要 memo,否则每个 token 都让整段重排重解析必须消毒模型输出等于不可信输入:javascript: 链接、onerror 属性都要挡交互延后复制/折叠按钮在流结束前禁用,避免半截状态下的失效操作不完整的语法会自愈:闭合符号到达后下一次解析就正确了,不需要手写状态机
图:从字符流到组件树之间要有一个显式层

1. 半截状态会出什么问题​

1.1 未闭合的代码围栏​

最严重的一种。渲染器把 ```ts 之后的所有内容都当成代码块的正文——包括后面真正的内容。视觉上是"后半段全变成了等宽字体",而且随着后续 token 到达会反复跳变。

1.2 半截的行内标记​

**bol 在闭合标记到达之前就是字面意义上的两个星号。用户会看到星号闪一下,然后变成加粗文本。单次不明显,高频下就变成了"廉价感"。

1.3 半截的块级结构​

表格有表头但没有分隔行时,渲染器不认识它是表格,会当成普通段落;分隔行到达的那一帧,整块突然变成表格。这是布局跳动最主要的来源。

1.4 半截的 HTML 与链接​

[标题](https:// 会被当成字面文本,一般无害。但如果你的渲染器允许 HTML 直通,一个没写完的标签可能短暂地影响后续结构。

2. 一个好消息:大部分情况会自愈​

CommonMark 解析器(remark、react-markdown 底下的 micromark)设计上就是宽容的:遇到未闭合的标记不会抛错,会尽力渲染它能渲染的部分。

不完整的语法会自己变好——**bol 现在按纯文本渲染,等闭合的 ** 到达,下一次解析就变成加粗了。你不需要写任何"检测半截语法并修复"的状态机。

真正需要显式处理的只有一类:未闭合的代码围栏。因为它不是渲染错误,而是范围错误——它会吞掉后续本不属于它的内容。

3. 管线:字符流到组件树之间要有一个显式层​

关键认识是:token 流和渲染结果不是同一个东西,中间那一层要显式写出来。

3.1 五段​

token 流 → 缓冲(按帧合并)→ 补齐半截语法 → 渲染 → 高亮(延后)

缓冲:把到达的 token 先攒着,按动画帧(或 50ms)合并后再触发一次解析。典型回答能从 500 次解析降到几十次。

补齐:只对围栏做。检测围栏标记数量是奇数时,临时补一个闭合(只用于渲染,原始数据不动)。

高亮延后:语法高亮是最贵的一步,等代码块闭合了再做。

这套缓冲合并的思路不只服务于 Markdown——任何按 token 驱动的界面都有同一个问题,节流尺度怎么定、已稳定区块怎么 memo,见《打字机的节流尺度》。

3.2 一个最小实现​

// 缓冲:用 rAF 把 token 合并成帧级更新
function useStreamBuffer() {
const rawRef = useRef('')
const [renderText, setRenderText] = useState('')
const frame = useRef(0)

const push = useCallback((delta: string) => {
rawRef.current += delta // 原始数据永远完整
if (frame.current) return // 已有帧在排队,直接返回
frame.current = requestAnimationFrame(() => {
frame.current = 0
setRenderText(normalize(rawRef.current))
})
}, [])

return { renderText, push }
}

// 补齐:只补真正未闭合的围栏,且不改原始数据
function closeOpenFence(raw: string): string {
let open: { char: string; len: number } | null = null
for (const line of raw.split('\n')) {
// 围栏必须出现在行首(前导空格 ≤ 3),行内出现的 ``` 不算
const m = line.match(/^ {0,3}(`{3,}|~{3,})/)
if (!m) continue
const fence = m[1]
if (!open) open = { char: fence[0], len: fence.length }
else if (fence[0] === open.char && fence.length >= open.len) open = null
}
if (!open) return raw
return raw + '\n' + open.char.repeat(open.len) + '\n'
}

三个细节都是踩过的:只数三个反引号的个数既认不出 ~~~ 围栏(CommonMark 标准语法,GFM 同样支持),也会把四连反引号围栏算错(它是合法围栏,必须用四个才能闭合);不判断行首则会把正文里"用三个反引号包起来"这种行内代码当成围栏,凭空多补一个。

注意 rawRef 和 renderText 的分离:rawRef 是权威数据,renderText 只是"这一帧拿去渲染的字符串"。流结束后用 rawRef 再渲染一次,就不会留下补齐时的临时围栏。

frame 这个标志位的作用是:token 永远追加到缓冲里(不会丢),但只有没有帧在排队时才调度下一次更新。这样同一帧内到达的一批 token 会合并成一次 setState。

3.3 围栏之外:表格与列表也需要块级策略​

代码围栏只是"半截结构"里最好修的一种。真正让页面跳得最厉害的是表格——第一行表头先出来、分隔行还没到,渲染器会先按段落排一遍;分隔行一到,整块重新变成表格,高度瞬间变化。

通用的解法是块级缓冲:按空行把内容切成块,最后一个"未完成块"先不上屏(或者上屏但冻结其解析结果),等它收到结束标志再提交。

// 以空行为块边界,最后一块视为未完成
const blocks = raw.split(/\n{2,}/)
const stable = blocks.slice(0, -1) // 前面完整提交的块
const pending = blocks.at(-1) ?? '' // 最后一块先按纯文本占位

代价是"最后一个块会晚一点出现",换来的是前面所有内容不再重排。对长回答来说这笔交易很划算:用户盯着看的是稳定增长的正文,而不是不断跳动的版面。

同样的思路适用于列表(列表项在没结束时会被当成段落)和引用块。判断"这一块完成了吗"的规则不用很聪明——按空行切已经能覆盖绝大多数情况。

4. 性能:三件必须做的事​

4.1 已完成的区块要 memo​

react-markdown 内部不做缓存——每次渲染都会重新解析整个字符串。父组件因为无关原因重渲染时,一条已经完成的 2000 字回答也会被重新解析一遍,纯属浪费。

const Rendered = React.memo(({ text }: { text: string }) => (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
))

流式期间 text 每帧都变,所以照常更新;消息完成后就彻底停止重解析。

4.2 语法高亮延后​

模型输出里围栏代码块非常多,而高亮是整个渲染里最贵的一步。在流式过程中逐帧高亮一个还在增长的代码块,是纯粹的 CPU 浪费。

做法是判断代码块是否闭合(围栏计数为偶数),未闭合时渲染成普通 pre,闭合后再交给高亮器。

顺带一个细节:语言标识符也可能是半截的(js、type),高亮器遇到未知语言要降级为纯文本而不是抛错。

5. 安全:模型输出是不可信输入​

这一条和《SSE 流式解析与切帧处理》那些格式问题同等重要,但更常被漏掉。

模型生成的内容来自它读过的所有东西——包括用户输入、检索到的文档、网页。把它直接交给一个会产生 HTML 的渲染器,就是一个 XSS 面:

  • markdown 链接里写 javascript: 协议
  • 图片标签带 onerror 属性
  • 直接输出 <script>

常规 Markdown 渲染器不做消毒,那是应用层的责任。两条硬规则:

  1. 渲染前过一遍消毒(DOMPurify,或 rehype-sanitize),尤其是允许 HTML 直通时
  2. 代码高亮器要转义而非渲染原始 HTML

另外,链接的 href 可能是 undefined(半截链接),自定义渲染器里要防护:既避免渲染出无效链接,也避免 href 为空时的意外行为。

6. 交互在流结束前禁用​

一个很具体的体验问题:复制按钮、折叠展开、"重新生成"这些交互元素,在内容还在增长时是失效或半失效的。用户点了一下,复制到的是半截内容。

做法是流期间禁用这些元素,流结束后再启用。状态机怎么组织见《对话界面的状态机》——streaming 和 ready 必须是两个明确的状态,不能靠"有没有内容"来判断。

7. 现成方案​

如果不想自己搭这层,2026 年有几个可选:

Streamdown(Vercel)——AI Elements 的底层渲染器,也可以独立装。它内置一个叫 remend 的预处理器,在交给渲染器之前修复未闭合的围栏、补全半截的强调和链接、剥掉不完整的 HTML 标签;块级 memo 让已完成的段落不再重排;自带 Shiki 高亮、KaTeX 数学、Mermaid 图,以及 rehype-sanitize + rehype-harden 两层安全默认。

有个几乎所有人都会踩的坑:它的样式写在编译产物里的 Tailwind 工具类,需要在全局 CSS 加一条 @source 指向包路径,否则 Tailwind 会把这些类摇掉,页面看起来就是"没样式"。另外它依赖 shadcn/ui 的设计令牌变量,没用的话要自己定义一组。

react-markdown + remark-gfm——标准栈,但它不是为流式设计的,需要自己包一层缓冲与补齐。控制力最强,工作量也最大。

llm-ui / streaming-markdown——专注流式的轻量解析器,比 react-markdown 更贴合这个场景,比 Streamdown 更可定制。

选择依据很简单:要开箱即用选 Streamdown;要完全控制渲染结构(渲染到 canvas、原生视图、富文本编辑器自己的文档模型)就自己解析成 mdast 树再渲染——mdast-util-from-markdown 就是只产出树的那一层。

8. 清单​

  • token 先入缓冲,按帧合并再解析,不逐 token 渲染
  • 未闭合围栏在渲染层补齐,原始数据不动
  • 已完成区块 memo,避免流结束后持续重解析
  • 高亮延后到代码块闭合,未知语言降级为纯文本
  • 输出过消毒,高亮器转义而非渲染 HTML
  • 流期间禁用复制/折叠等交互
  • TextDecoder 开流式解码({ stream: true }),否则中文会乱码
  • 流结束后用完整原始数据再渲染一次

最后一条单独说:补齐是为了流式过程中的观感,最终渲染必须回到原始数据。否则临时加的那个围栏会留在消息里——用户复制时会发现结尾多了一行反引号。

本文只处理"半截内容怎么好看",没有处理"这些内容能不能信"。模型输出里的链接、图片和内嵌标签都可能是攻击载荷,渲染侧的净化管道见《模型输出不能直接塞进 innerHTML》——这两件事是串行的,不是二选一。