传统的 Markdown 渲染逻辑(如 marked(fullText))在 AI 流式输出面前就是个“性能黑洞”。
如果每来一个 Token 就把几千字的全文重绘一遍,不仅 CPU 会因为重复的正则匹配而爆表,更糟糕的是 UI 体验:由于解析器还没看到结尾的 ``` 或 $$,代码块和公式会频繁地在“纯文本”和“渲染态”之间反复横跳(Flicker) 。
要做到“流式且无损”,我们需要一套状态机驱动的增量预解析方案。
1. 核心原理:解析器的“状态化”
传统的解析器是“无状态”的。而流式解析器需要记住: “我现在处于什么环境?”
我们可以将解析过程拆解为三个核心状态:
- TEXT:常规文本模式。
- CODE:检测到
```,进入代码块模式。 - 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 的“平滑修正”
公式渲染最怕的是 还没打完,公式就显示报错红叉。
- 技巧:使用
try-catch包裹katex.render。如果当前内容不完整导致报错,则先以纯文本样式显示内容,直到检测到完整的公式语法。
(这是公式渲染态)
4. 3 个“防坑”策略
-
“假闭合”处理:
AI 偶尔会断网或中断,导致
```永远不出现。你的 Renderer 必须有一个 Auto-Close 机制:如果解析器进入 CODE 状态后 5 秒没有新内容,自动强行闭合它。 -
避免 HTML 标签截断:
如果你在流式渲染中允许 HTML(如
<br>),一定要小心 Token 刚好把<br>劈成<b和r>的情况。策略:如果 buffer 的结尾包含<或&等起始符,先憋住不发,等下一个 Token 凑整。 -
计算属性的“懒加载” :
对于公式和代码块,不要频繁查询
scrollHeight。在大规模 AI 响应中,频繁的布局查询会触发浏览器的同步布局震荡(Layout Thrashing)。
5. 效果对比
| 方案 | 刷新率 | 稳定性 | CPU 消耗 |
|---|---|---|---|
| 全量重绘 | 随 Token 频率 | 极差 (代码块反复闪烁) | ,字越多越卡 |
| 增量预解析 | 随屏幕刷新率 | 极高 (平滑增长) | 或 ,恒定稳定 |