块级切分让稳定块先渲染,末尾 hold 住等成型,useTransition 把更新降级。
AI 回复不是一个 token 一起给你的。它是几十毫秒蹦一段。streamdown 是 Vercel 出的一个 markdown 渲染库,专门解决「边流边显示」。
最直接的问题:流还没结束,屏幕上得能看到东西。
但流式输出有几个隐藏的难题:
| 和 ``` 字符,还是显示成型的代码块 / 表格,差别很大streamdown 解决的是这两件事:
下面把这两个核心问题的解决思路拆开讲,配上极简版的实现代码。
最核心的是一个 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())
几个关键点:
while 循环从头到尾扫一遍,指针只前进进入 hold 状态,再遇到 才闭合;没闭合也照样把已到的 lines 返回| --- | 这种纯 - 和 | 的行就是分隔行调用方拿到 segments 后按 kind 渲染。代码块就算 isComplete: false,已到的 lines 也照样渲染——每行一个 <div>,最后加个闪烁光标表示还在流;表格行就是简单的 <tbody><tr> 追加。
输入是 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))queueRef.current.shift())这样不管输入多快(队列堆积),还是多慢(队列空着),输出都是匀速的。
代码块和表格的特殊性在于它们的结构依赖于完整闭合。表格尤其——| 这种字符在段落里就是普通文本,要等到下一行是 | --- | 这种分隔行,parser 才能识别为表格。
tokenize 里识别表格的关键是看分隔行:
const isDelimiterRow = (line: string) =>
/^[\s\-:|]+$/.test(line.trim()) && line.includes('|') && line.includes('-')
只要当前行含 | 且下一行是分隔行,就认定是表格的 header + delimiter。后续每行含 | 且非空的就是一行数据。
不依赖完整表格——header + delimiter 一到就识别,row 一来就追加。所以流到一半的表格:
<table>,里面 0 行<tbody> 加 1 个 <tr><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>
)
}
isPending 是给视觉反馈用的——可以加个 loading 提示字符匀速进队列,块级切分让稳定块直接渲染,表格按分隔行识别按行追加。