streamdown 怎么把 markdown 流式渲染出来
AI 回复不是一个 token 一起给你的。它是几十毫秒蹦一段。streamdown 是 Vercel 出的一个 markdown 渲染库,专门解决「边流边显示」。
它解决了什么问题
最直接的问题:流还没结束,屏幕上得能看到东西。
但流式输出有几个隐藏的难题:
- 输入不匀速:token 可能一瞬间蹦很多(burst),也可能停顿半秒什么都不来。如果直接把收到的字符原样吐到屏幕上,用户会看到一会儿冲出一堆、一会儿卡住等输入
- 结构未闭合:markdown 里的代码块、表格是有结构的,但这些结构要等闭合才完整。闭合之前是显示一堆
|和 ``` 字符,还是显示成型的代码块 / 表格,差别很大
streamdown 解决的是这两件事:
- 字符匀速展示:不管输入多快多慢,输出都是匀速的
- 结构渐进渲染:代码块、表格等结构未闭合时,也是格式化后的样子,而不是一堆原始字符
下面把这两个核心问题的解决思路拆开讲,配上极简版的实现代码。
三个核心机制
1. 块级切分
最核心的是一个 tokenize 函数。它把流进来的文本扫一遍,识别出三种块:段落、表格、代码。
下面这段就是上面 demo 用的真实代码(简化版,streamdown 自己的 parser 还处理 HTML 块、数学公式、嵌套等更多情况,但思路一样):
type Segment =
| { kind: 'paragraph'; content: string }
| { kind: 'table'; header: string[]; rows: string[][] }
| { kind: 'code'; lang: string; lines: string[]; isComplete: boolean }
function tokenize(text: string): Segment[] {
const lines = text.split('\n')
const segments: Segment[] = []
let i = 0
while (i < lines.length) {
if (lines[i].trim() === '') { i++; continue }
// 代码块:``` 开头,遇到下一个 ``` 结束
if (lines[i].startsWith('```')) {
const lang = lines[i].slice(3).trim()
const start = i
i++
while (i < lines.length && !lines[i].startsWith('```')) i++
const isComplete = i < lines.length
const codeLines = lines.slice(start + 1, isComplete ? i : lines.length)
segments.push({ kind: 'code', lang, lines: codeLines, isComplete })
if (isComplete) i++
continue
}
// 表格:当前行含 |,下一行是 --- 分隔行
if (i + 1 < lines.length && lines[i].includes('|') && isDelimiterRow(lines[i + 1])) {
const header = parseRow(lines[i])
i += 2
const rows: string[][] = []
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
rows.push(parseRow(lines[i]))
i++
}
segments.push({ kind: 'table', header, rows })
continue
}
// 段落:连续的非空、非特殊行
const paraLines: string[] = []
while (i < lines.length
&& lines[i].trim() !== ''
&& !lines[i].startsWith('```')
&& !(lines[i].includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1]))) {
paraLines.push(lines[i])
i++
}
if (paraLines.length > 0) {
segments.push({ kind: 'paragraph', content: paraLines.join('\n') })
}
}
return segments
}
const isDelimiterRow = (line: string) =>
/^[\s\-:|]+$/.test(line.trim()) && line.includes('|') && line.includes('-')
const parseRow = (line: string) =>
line.trim().replace(/^\||\|$/g, '').split('|').map((s) => s.trim())
几个关键点:
- 行扫描,O(n) 没有回溯:
while循环从头到尾扫一遍,指针只前进 - 代码块用 fence 标记:开头
进入 hold 状态,再遇到才闭合;没闭合也照样把已到的lines返回 - 表格靠分隔行识别:
| --- |这种纯-和|的行就是分隔行 - 段落兜底:上面都不匹配就是段落,收集连续非空行
调用方拿到 segments 后按 kind 渲染。代码块就算 isComplete: false,已到的 lines 也照样渲染——每行一个 <div>,最后加个闪烁光标表示还在流;表格行就是简单的 <tbody><tr> 追加。
2. 字符匀速展示
输入是 burst 的:可能一秒钟蹦 50 个字符,也可能停顿半秒。
但显示是匀速的:每 ~16ms 显示一个字符。
机制是一个字符队列 + 定时器:
const CHARS_PER_SECOND = 60
const CHAR_INTERVAL_MS = 1000 / CHARS_PER_SECOND
const queueRef = useRef<string[]>([])
const timerRef = useRef<number | null>(null)
const lastEnqueuedRef = useRef(0)
const enqueueNew = (fullText: string) => {
// 把 fullText 末尾多出来的字符入队
for (let i = lastEnqueuedRef.current; i < fullText.length; i++) {
queueRef.current.push(fullText[i])
}
lastEnqueuedRef.current= fullText.length
// 队列里还有东西,timer 没在跑,就启动它
if (timerRef.current= null && queueRef.current.length > 0) {
timerRef.current = window.setInterval(() => {
if (queueRef.current.length > 0) {
const next = queueRef.current.shift()!
setDisplayedText((d) => d + next)
} else {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current)
timerRef.current = null
}
}
}, CHAR_INTERVAL_MS)
}
}
核心就三行:
- 新内容到了就入队(
queueRef.current.push(ch)) - timer 按固定间隔出队(
queueRef.current.shift()) - 队列空了就停 timer(避免空转)
这样不管输入多快(队列堆积),还是多慢(队列空着),输出都是匀速的。
3. 表格按行识别
代码块和表格的特殊性在于它们的结构依赖于完整闭合。表格尤其——| 这种字符在段落里就是普通文本,要等到下一行是 | --- | 这种分隔行,parser 才能识别为表格。
tokenize 里识别表格的关键是看分隔行:
const isDelimiterRow = (line: string) =>
/^[\s\-:|]+$/.test(line.trim()) && line.includes('|') && line.includes('-')
只要当前行含 | 且下一行是分隔行,就认定是表格的 header + delimiter。后续每行含 | 且非空的就是一行数据。
不依赖完整表格——header + delimiter 一到就识别,row 一来就追加。所以流到一半的表格:
- header + delimiter 出现 → 立刻渲染成
<table>,里面 0 行 - 第 1 行出现 →
<tbody>加 1 个<tr> - 第 2 行出现 → 加 1 个
<tr> - ……
这样用户看到的是表格一行一行长出来,而不是等齐了再显示。
演示
点开始,看字符匀速出现、表格逐行长出
输出
还没开始
输入每 350ms 来一段(burst),输出按 60 字符/秒匀速出队。表格的分隔行一出现就识别为表格,row 一来就追加。
还能再细一点
字符匀速解决了**「看得舒服」的问题。还有个相关的问题:「卡不卡」**。
如果一次 setState 涉及上千行代码 + 高亮,React 提交时会阻塞 UI,用户滚动和点击会卡。
useTransition 把这次 setState 标记为低优先级:
import { useEffect, useState, useTransition } from 'react'
function Streamdown({ stream }: { stream: string }) {
const [blocks, setBlocks] = useState<Block[]>([])
const [isPending, startTransition] = useTransition()
useEffect(() => {
startTransition(() => {
setBlocks(tokenize(stream))
})
}, [stream])
return (
<div className={isPending ? 'opacity-90' : ''}>
{blocks.map((b, i) => renderBlock(b, i))}
</div>
)
}
- 它不是异步,是降优先级。React 还是会执行它,但会让位给高优先级的用户操作
- 它没让 parse 变快,变的只是 React 调度它的时机
isPending是给视觉反馈用的——可以加个 loading 提示
一句话总结
字符匀速进队列,块级切分让稳定块直接渲染,表格按分隔行识别按行追加。