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

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

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

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


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

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

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

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

2. 代码实现:StreamMarkdownRenderer 逻辑

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

JavaScript

kotlin 复制代码
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 的"平滑修正"

公式渲染最怕的是 <math xmlns="http://www.w3.org/1998/Math/MathML"> a + b = c a + b = c </math>a+b=c 还没打完,公式就显示报错红叉。

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

<math xmlns="http://www.w3.org/1998/Math/MathML"> E = m c 2 E = mc^2 </math>E=mc2

(这是公式渲染态)


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 频率 极差 (代码块反复闪烁) <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 2 ) O(N^2) </math>O(N2),字越多越卡
增量预解析 随屏幕刷新率 极高 (平滑增长) <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 1 ) O(1) </math>O(1) 或 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N ) O(N) </math>O(N),恒定稳定

相关推荐
贼爱学习的小黄3 小时前
NC BIP参照开发
java·前端·nc
weixin_462901973 小时前
ESP32 LED控制代码解析
javascript
小江的记录本3 小时前
【MyBatis-Plus】MyBatis-Plus的核心特性、条件构造器、分页插件、乐观锁插件
java·前端·spring boot·后端·sql·tomcat·mybatis
小张会进步3 小时前
数组:二维数组
java·javascript·算法
光影少年3 小时前
如何进行前端性能优化?
前端·性能优化
Dxy12393102164 小时前
js如何把字符串转数字
开发语言·前端·javascript
爱写bug的野原新之助4 小时前
爬虫之补环境:加载原型链
前端·javascript·爬虫
陈广亮4 小时前
工具指南7-Unix时间戳转换工具
前端
NGBQ121384 小时前
Adobe-Premiere-Pro-2026-26.0.2.2-m0nkrus 全解析:专业视频编辑软件深度指南
前端·adobe·音视频
北城笑笑4 小时前
Chrome:Paused in debugger 的踩坑实录:问题排查全过程与终极解决方案( 在调试器中暂停 )
前端·chrome