前言
如果你开发过 AI 聊天应用,一定遇到过这个问题:随着对话越来越长,页面越来越卡。
原因很简单:每次 AI 输出新的 token,你都要把整个 markdown 字符串重新解析一遍。
假设 AI 输出了 10,000 个字符,分 500 次推送,传统方案的解析工作量是:
1 + 2 + 3 + ... + 10000 ≈ 50,000,000 字符
这就是 O(n²) 的灾难。
而 Incremark 的方案是:
10000 字符(每个字符最多解析一次)
这就是 O(n) 的优雅。
今天,我将从架构设计到实现细节,完整解析 Incremark 是如何做到这一点的。
目录
- 问题分析:传统方案为什么慢?
- 核心思路:增量解析
- 边界检测:识别稳定边界
- 双引擎架构:速度与兼容的平衡
- 打字机效果:AST 层的动画方案
- 实战对比:真实场景基准测试
一、问题分析:传统方案为什么慢?
1.1 流式场景的特殊性
AI 流式输出的特点是:内容是增量的,但大部分解析器只支持全量解析。
ts
// 传统方案:每次都全量解析
async function handleStream(stream) {
let content = ''
for await (const chunk of stream) {
content += chunk
const html = marked.parse(content) // 每次都从头解析
render(html)
}
}
这段代码的问题在于:即使只新增了 10 个字符,marked.parse 也要扫描整个 content。
1.2 解析复杂度分析
假设最终文档长度为 N,分 K 次推送(每次推送 N/K 个字符):
| 方案 | 单次解析量 | 总解析量 |
|---|---|---|
| 全量解析 | 递增:N/K, 2N/K, ..., N | O(NK) ≈ O(N²) |
| 增量解析 | 恒定:约 N/K | O(N) |
当 N = 10,000,K = 500 时:
- 全量解析:约 5000 万字符
- 增量解析:约 1 万字符
500 倍的差距,这就是为什么长文档会卡的原因。
二、核心思路:增量解析
2.1 关键洞察
Markdown 文档可以分为独立的"块"(Block):标题、段落、代码块、列表等。
核心洞察:一旦一个块"完成"了,后续的输入不会再影响它。
例如:
markdown
# 标题
这是一个段落。
```新的内容...
当我们看到第二个空行时,就知道"这是一个段落"已经完成了。后续无论输入什么,这个段落都不会变化。
2.2 Block 生命周期
Incremark 将每个块标记为两种状态:
| 状态 | 说明 | 处理策略 |
|---|---|---|
| Pending | 正在接收中,可能变化 | 每次只重新解析这一块 |
| Completed | 已确认完成 | 永不重新解析 |
ts
interface Block {
id: string
node: RootContent
status: 'pending' | 'completed'
}
这个不变量保证了:每个字符最多只被解析一次。
三、边界检测:识别稳定边界
3.1 什么是稳定边界?
稳定边界是指:当我们看到这个位置时,可以确定之前的内容已经"完成"了。
常见的稳定边界:
| 类型 | 示例 | 说明 |
|---|---|---|
| 空行 | \n\n |
段落结束 |
| 新标题 | \n# |
前一个块结束 |
| 围栏结束 | ``````````` | 代码块结束 |
| 缩进回退 | 列表项缩进减少 | 列表结束 |
3.2 上下文状态机
为了准确识别边界,解析器维护了一个状态机:
ts
interface BlockContext {
inFencedCode: boolean // 是否在代码块中
fenceChar: string // 围栏字符(` 或 ~)
fenceLength: number // 围栏长度
inContainer: boolean // 是否在容器中
containerDepth: number // 容器嵌套深度
listStack: ListInfo[] // 列表嵌套栈
blockquoteDepth: number // 引用嵌套深度
}
3.3 边界检测算法
ts
findStableBoundary(): { line: number, contextAtLine: BlockContext } {
// 从后往前扫描
for (let i = this.lines.length - 1; i >= this.pendingStartLine; i--) {
const line = this.lines[i]
const context = this.contextAtLine[i]
// 在围栏块内部,继续等待
if (context.inFencedCode) continue
// 在容器内部,继续等待
if (context.inContainer) continue
// 空行可能是边界
if (line.trim() === '') {
// 检查下一行是否是新块的开始
if (this.isNewBlockStart(i + 1)) {
return { line: i, contextAtLine: context }
}
}
}
return { line: -1, contextAtLine: this.currentContext }
}
四、双引擎架构:速度与兼容的平衡
4.1 为什么需要两个引擎?
在实际开发中,我们面临两个矛盾的需求:
- 极致性能:AI 流式场景需要毫秒级响应
- 完整功能:需要支持脚注、数学公式、自定义容器等
单一引擎很难同时满足这两点。
4.2 Marked 引擎:极速派
marked 是一个轻量级的 Markdown 解析器,以速度著称。但原生 marked 不支持很多高级语法。
我们为 marked 开发了一套自研扩展:
ts
// packages/core/src/parser/ast/MarkedAstBuildter.ts
class MarkedAstBuilder implements IAstBuilder {
private extensions = [
createFootnoteDefinitionExtension(), // 脚注
createBlockMathExtension(), // 块级数学
createInlineMathExtension(), // 内联数学
createContainerExtension(), // 自定义容器
createInlineHtmlExtension() // 内联 HTML
]
}
这些扩展让 marked 具备了完整的功能,同时保持了极速的性能。
4.3 Micromark 引擎:稳定派
micromark 是 CommonMark 的官方参考实现,以严格和稳定著称。
它的优势在于:
- 100% CommonMark 兼容
- 丰富的插件生态(micromark-extension-*)
- 与 mdast 深度集成
4.4 统一的 AST 输出
无论使用哪个引擎,Incremark 都产出标准的 mdast 格式:
ts
interface Root {
type: 'root'
children: RootContent[]
}
这意味着:
- 渲染层完全复用
- 相同的输入产生相同的输出
- 切换引擎只需更改初始化代码
4.5 Tree-shaking 优化
为了保证打包体积,引擎选择是在初始化时而非运行时进行:
ts
// 默认只打包 marked(极速模式)
import { createIncremarkParser } from '@incremark/core'
const parser = createIncremarkParser({ gfm: true })
// 如需 micromark,单独导入(不影响默认 bundle)
import { MicromarkAstBuilder } from '@incremark/core/engines/micromark'
const parser = createIncremarkParser({
astBuilder: MicromarkAstBuilder,
gfm: true
})
这样打包工具可以 tree-shake 未使用的引擎,保持最小 bundle 体积。
五、打字机效果:AST 层的动画方案
5.1 传统方案的问题
很多打字机效果是在字符串层面实现的:
ts
// 字符串切片方案
const displayText = fullText.slice(0, currentIndex)
这种方案的问题:
- 不知道当前字符属于什么结构(标题?代码?)
- 高频更新时容易导致结构闪烁
- 无法精确控制 Markdown 语法标记的动画
5.2 Incremark 的 AST 层方案
我们在 AST 层面实现打字机效果:
ts
// BlockTransformer 追踪每个文本节点的播放进度
interface TextChunk {
text: string // 新增的文本
isNew: boolean // 是否是新增的
effect: 'none' | 'fade-in' | 'typing'
}
这样做的优势:
- 知道每个字符的语义上下文
- 可以精确控制动画粒度
- 永远不会在语法结构中间"断开"
5.3 配置示例
vue
<IncremarkContent
:content="content"
:is-finished="isFinished"
:incremark-options="{
typewriter: {
enabled: true,
charsPerTick: [1, 3], // 随机 1-3 字符
tickInterval: 30, // 30ms 一次
effect: 'fade-in', // 淡入效果
cursor: '|' // 光标样式
}
}"
/>
六、实战对比:真实场景基准测试
6.1 测试方法
我们使用 38 个真实的 Markdown 文档进行测试:
- 来自项目文档、博客、AI 对话记录
- 总计 6,484 行,128.55 KB
- 模拟 AI 流式输出(每次 1-20 字符随机)
6.2 测试结果
| 对比方案 | 平均优势 |
|---|---|
| vs Streamdown | 约 6.1 倍 |
| vs ant-design-x | 约 7.2 倍 |
| vs markstream-vue | 约 28.3 倍 |
6.3 随文档大小扩展
| 文件 | 行数 | 大小 | Incremark | ant-design-x | 优势 |
|---|---|---|---|---|---|
| introduction.md | 34 | 1.57 KB | 5.6 ms | 12.8 ms | 2.3x |
| comparison.md | 109 | 5.39 KB | 20.5 ms | 85.2 ms | 4.1x |
| BLOCK_TRANSFORMER.md | 489 | 9.24 KB | 75.7 ms | 619.9 ms | 8.2x |
| test-md-01.md | 916 | 17.67 KB | 87.7 ms | 1656.9 ms | 18.9x 🚀 |
规律明显:文档越长,优势越大。
七、总结
Incremark 的核心优化思路:
- 增量解析:只解析新增内容,已完成的块永不重新解析
- 边界检测:用状态机精确识别稳定边界
- 双引擎:marked 追求极速,micromark 追求稳定
- AST 动画:在语法树层面实现打字机效果
这些优化将复杂度从 O(n²) 降到 O(n),理论上文档越长,优势越大。
资源链接
- 📚 文档 :www.incremark.com/
- 💻 GitHub :github.com/kingshuaish...
- 🎮 在线演示 :incremark-vue.vercel.app/