它解决了什么问题
最直接的问题:流还没结束,屏幕上得能看到东西。
但流式输出有几个隐藏的难题:
- 输入不匀速:token 可能一瞬间蹦很多,也可能停顿半秒什么都不来。直接把收到的字符原样吐到屏幕上,用户会看到一会儿冲出一堆、一会儿卡住等输入。
- 结构未闭合:markdown 里的代码块、表格是有结构的,但这些结构要等闭合才完整。闭合之前是显示原始字符,还是显示成型的代码块 / 表格,差别很大。
streamdown 解决的是这两件事:字符匀速展示,结构渐进渲染。
三个核心机制
1. 块级切分
最核心的是一个 tokenize 函数。它把流进来的文本扫一遍,识别出三种块:段落、表格、代码。
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 状态,再遇到闭合 fence 才结束;没闭合也照样把已到的 lines 返回。
- 表格靠分隔行识别:
| --- |这种纯-和|的行就是分隔行。 - 段落兜底:上面都不匹配就是段落,收集连续非空行。
调用方拿到 segments 后按 kind 渲染。代码块就算还没完整闭合,已到的行也照样渲染;表格行就是简单地往 tbody 里追加。
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) => {
for (let i = lastEnqueuedRef.current; i < fullText.length; i++) {
queueRef.current.push(fullText[i])
}
lastEnqueuedRef.current = fullText.length
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)
}
}核心就三步:新内容入队,timer 按固定间隔出队,队列空了就停 timer。这样不管输入多快还是多慢,输出节奏都是稳定的。
3. 表格按行识别
表格的特殊性在于结构依赖分隔行。只要 header 和 delimiter 到了,就可以先渲染成表格;后续每来一行,就追加一行。
- header + delimiter 出现 → 立刻渲染成 table,里面 0 行。
- 第 1 行出现 → tbody 加 1 个 tr。
- 第 2 行出现 → 再加 1 个 tr。
所以用户看到的是表格一行一行长出来,而不是等全部内容齐了再显示。
演示
点开始,看字符匀速出现、表格逐行长出
已接收 0 字符队列 0 字符已显示 0 字符
输出
还没开始
输入每 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 提示。
一句话总结
字符匀速进队列,块级切分让稳定块直接渲染,表格按分隔行识别按行追加。