跳到正文
renderingStreamdown

streamdown 怎么把 markdown 流式渲染出来

AI 回复不是一次性给你的。它是几十毫秒蹦一段。streamdown 是 Vercel 出的 markdown 渲染库,专门解决边流边显示。

它解决了什么问题

最直接的问题:流还没结束,屏幕上得能看到东西。

但流式输出有几个隐藏的难题:

  • 输入不匀速:token 可能一瞬间蹦很多,也可能停顿半秒什么都不来。直接把收到的字符原样吐到屏幕上,用户会看到一会儿冲出一堆、一会儿卡住等输入。
  • 结构未闭合:markdown 里的代码块、表格是有结构的,但这些结构要等闭合才完整。闭合之前是显示原始字符,还是显示成型的代码块 / 表格,差别很大。

streamdown 解决的是这两件事:字符匀速展示,结构渐进渲染。

流式输入burst, 不匀速字符队列 + 定时器按固定间隔出队把 burst 摊匀块级切分段落 / 表格 / 代码表格按行识别分隔行检测, row 追加直接渲染没闭合也是成型样式

三个核心机制

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 提示。

一句话总结

字符匀速进队列,块级切分让稳定块直接渲染,表格按分隔行识别按行追加。