Markdown 预解析:别等全文完了再渲染,如何流式增量渲染代码块和公式?

1 阅读2分钟

传统的 Markdown 渲染逻辑(如 marked(fullText))在 AI 流式输出面前就是个“性能黑洞”。

如果每来一个 Token 就把几千字的全文重绘一遍,不仅 CPU 会因为重复的正则匹配而爆表,更糟糕的是 UI 体验:由于解析器还没看到结尾的 ```$$,代码块和公式会频繁地在“纯文本”和“渲染态”之间反复横跳(Flicker)

要做到“流式且无损”,我们需要一套状态机驱动的增量预解析方案


1. 核心原理:解析器的“状态化”

传统的解析器是“无状态”的。而流式解析器需要记住: “我现在处于什么环境?”

我们可以将解析过程拆解为三个核心状态:

  1. TEXT:常规文本模式。
  2. CODE:检测到 ```,进入代码块模式。
  3. MATH:检测到 $$[,进入公式模式。

2. 代码实现:StreamMarkdownRenderer 逻辑

核心思路是:在闭合标签到达之前,先“假装”它已经闭合了,并创建一个占位节点进行增量更新。

JavaScript

class StreamMarkdownRenderer {
  constructor(container) {
    this.container = container;
    this.currentState = 'TEXT';
    this.activeNode = null; // 当前正在被填充的 DOM 节点
    this.buffer = '';       // 未处理的碎片缓存
  }

  push(token) {
    this.buffer += token;
    this.parse();
  }

  parse() {
    // 逻辑简化:检测 buffer 中的特殊标识符
    if (this.buffer.includes('```') && this.currentState === 'TEXT') {
      this.currentState = 'CODE';
      this.activeNode = this.createCodeBlock();
      this.buffer = this.buffer.split('```')[1]; // 移除标识符
    } 
    else if (this.buffer.includes('$$') && this.currentState === 'TEXT') {
      this.currentState = 'MATH';
      this.activeNode = this.createMathBlock();
      this.buffer = this.buffer.split('$$')[1];
    }

    this.updateActiveNode();
  }

  updateActiveNode() {
    if (!this.activeNode) {
      // 普通文本追加
      this.renderText(this.buffer);
      this.buffer = '';
      return;
    }

    // 增量填充代码或公式
    if (this.currentState === 'CODE') {
      this.activeNode.querySelector('code').textContent += this.buffer;
      // 触发高亮(如 Prism.highlightElement)
      this.highlight(this.activeNode);
    } 
    else if (this.currentState === 'MATH') {
      // 增量渲染 KaTeX
      this.renderKaTeX(this.activeNode, this.buffer);
    }
    this.buffer = '';
  }
}

3. 针对不同类型的进阶处理

① 代码块:语法高亮的“预热”

在流式状态下,Shiki 虽然美观但太重,Prism.js 是更好的选择。

  • 技巧:不要在每个字符进来时都调用 highlight。利用我们之前聊过的 requestAnimationFrame,每隔 50ms 左右进行一次重排高亮,这样既能保证“语法变色”,又不会卡死主线程。

② 数学公式:KaTeX 的“平滑修正”

公式渲染最怕的是 a+b=ca + b = c 还没打完,公式就显示报错红叉。

  • 技巧:使用 try-catch 包裹 katex.render。如果当前内容不完整导致报错,则先以纯文本样式显示内容,直到检测到完整的公式语法。

E=mc2E = mc^2

(这是公式渲染态)


4. 3 个“防坑”策略

  1. “假闭合”处理

    AI 偶尔会断网或中断,导致 ``` 永远不出现。你的 Renderer 必须有一个 Auto-Close 机制:如果解析器进入 CODE 状态后 5 秒没有新内容,自动强行闭合它。

  2. 避免 HTML 标签截断

    如果你在流式渲染中允许 HTML(如 <br>),一定要小心 Token 刚好把 <br> 劈成 <br> 的情况。策略:如果 buffer 的结尾包含 <& 等起始符,先憋住不发,等下一个 Token 凑整。

  3. 计算属性的“懒加载”

    对于公式和代码块,不要频繁查询 scrollHeight。在大规模 AI 响应中,频繁的布局查询会触发浏览器的同步布局震荡(Layout Thrashing)。


5. 效果对比

方案刷新率稳定性CPU 消耗
全量重绘随 Token 频率极差 (代码块反复闪烁)O(N2)O(N^2),字越多越卡
增量预解析随屏幕刷新率极高 (平滑增长)O(1)O(1)O(N)O(N),恒定稳定