renderingJune 2, 2026

streamdown 渲染原理:块级切分 + 降优先级 + 视口外懒渲染

块级切分让稳定块先渲染,末尾 hold 住等成型,useTransition 把更新降级。

reactmarkdownstreamingaiperformance

streamdown 怎么把 markdown 流式渲染出来

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

它解决了什么问题

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

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

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

streamdown 解决的是这两件事:

  • 字符匀速展示:不管输入多快多慢,输出都是匀速的
  • 结构渐进渲染:代码块、表格等结构未闭合时,也是格式化后的样子,而不是一堆原始字符

下面把这两个核心问题的解决思路拆开讲,配上极简版的实现代码。

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

三个核心机制

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>
  • ……

这样用户看到的是表格一行一行长出来,而不是等齐了再显示。

演示

点开始,看字符匀速出现、表格逐行长出

已接收 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 提示

一句话总结

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