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 的"平滑修正"

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

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

E = m c 2 E = mc^2 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 频率 极差 (代码块反复闪烁) O ( N 2 ) O(N^2) O(N2),字越多越卡
增量预解析 随屏幕刷新率 极高 (平滑增长) O ( 1 ) O(1) O(1) 或 O ( N ) O(N) O(N),恒定稳定

相关推荐
ZC跨境爬虫3 小时前
跟着 MDN 学JavaScript day_7:数学运算与逻辑判断实战测试
开发语言·前端·javascript·学习·ecmascript
fangdengfu1233 小时前
ES分析系统各个服务日志占用量
java·前端·elasticsearch
凌云拓界3 小时前
文件管理:让AI安全操作你的电脑 ——CogitoAgent开发实战(三)
javascript·人工智能·架构·开源·node.js
凌云拓界4 小时前
联网能力:让AI看见更广阔的世界 ——CogitoAgent开发实战(四)
javascript·人工智能·架构·node.js·创业创新
JustHappy5 小时前
古法编程秘籍(六):程序到底是怎么跑起来的?从 IO 到中断,一次讲明白
前端·后端·全栈
HYCS5 小时前
用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互
前端·javascript·canvas
卷帘依旧5 小时前
useImperativeHandle的作用
前端
卷帘依旧5 小时前
Hooks在Fiber上的存储原理
前端
you45805 小时前
学成在线--day02 CMS前端开发(含Vue基础知识得回顾)
前端·javascript·vue.js