Incremark 是我们开发的第一代流式 Markdown 工具。 QuireKit Markdown 延续了同一个目标:让 AI 一边生成文字,页面一边显示排版好的内容。
这次重新设计,主要解决一个容易被低估的问题:后面收到的内容,仍然可能改变前面那句话应该怎样显示。 新引擎在减少重复解析和渲染的同时,让这些后到的定义能够正确更新前文。
看一棵树怎样更新
“中间插入”会连续修改同一份原文三次:先在第二段加入加粗,再在第一段加入行内代码, 最后在中间插入两项列表。每次上一轮的结果都成为下一轮的起点。
点击播放,跟随移动片段进入原文落点,再找到橙色的受影响分支、换入蓝色的新节点。 绿色节点保留原树对象。可以暂停、单步、回退,或选择某次插入与某个阶段。 视图固定,不支持拖动或缩放。
第三次插在段落边界:第三段原文虽然没改,解析器仍重建了它的语法分支。 这也说明更新范围要看实际语法与上下文,不能只按改了几个字判断。 “末尾追加”和“后置引用”保留对照;后置定义到达后,前面变成链接的是 bar。 树和阅读结果来自真实引擎;移动轨迹与慢放是教学编排,不是底层执行日志。
这段树变化也由我们的流程图库绘制与播放:节点、连线、片段移动及树的替换,都使用同一个公共播放器。
对照旧版的流程
切换“旧 Incremark”和“当前算法”,沿着箭头看一份新片段到达后会经历哪些步骤。图使用我们现有的流程图编辑器渲染,支持缩放和查看大图。
为什么要重新设计
旧 Incremark 把正在生成的回答分成两部分:已经结束的段落,和还在继续写的部分。新文字到来时,尽量保留前面的解析结果,只重新解析末尾。这个思路能减少重复计算。
但 Markdown 不总是写完一段就能确定这一段的意思。例如,AI 可以先写正文,最后才给出链接地址:
更多内容见[文档][api]。
[api]: https://example.com/docs
在最后一行到达之前,[文档][api] 还没有可用的地址;地址到达以后,前面的“文档”应当变成链接。脚注也是如此:正文先出现,解释可能在整篇回答的末尾才到达。
旧版已经为这些情况做了处理:先记录引用,等定义到达后再补上链接。困难在于,有些后置定义还会改变哪几个方括号组成一个链接。这时,只给已有节点补地址就不够了。我们需要重新检查前面的语法,同时让页面更新正确的内容。
旧实现的两个具体问题
同一个链接地址定义了两次
假设下面的正文和两个地址依次到达:
[文档][api]
[api]: /first
[api]: /second
按照 Markdown 的规则,同名定义应使用第一次出现的地址,所以“文档”应当指向 /first。旧版在合并定义时覆盖了原来的值,导致前面的链接改为 /second。当前引擎保留首次定义。
链接落在了错误的文字上
再看一个短例子。先收到第一行,随后收到 baz 的定义:
[foo][bar][baz]
[baz]: /target
这段文字应该被理解为:保留普通文字 [foo],将 [bar][baz] 解释为链接,也就是让 bar 可以点击。
旧版提前把 [foo][bar] 识别成了一个引用,把 [baz] 留作另一个引用。等地址到达后,可点击的变成了 baz。前面的分组已经确定,补上地址也不能纠正它。当前引擎会依据实际到达的定义重新判断分组,因此链接落在 bar 上。
我们用旧版的两种引擎重新跑了这两个例子,结果如下:
| 情况 | 旧 Marked | 旧 Micromark | 当前 QuireKit Markdown |
|---|---|---|---|
api 定义了两次 | 链接到 /second | 链接到 /second | 链接到 /first |
| 相邻的三个方括号 | baz 可以点击 | baz 可以点击 | bar 可以点击 |
新算法怎样处理流式更新
1. 保留实际收到的原文
每次追加都写入同一份 Markdown。停止生成时保留已经收到的文字,不为了显示效果补写围栏、括号或 HTML 结束标签。切换到另一条消息时,再建立新的解析会话。
2. 让 Lezer 找出可以继续使用的解析结果
Lezer 把标题、段落、列表和链接等结构记录在语法树里。新文字到来后,它结合语法上下文判断哪些旧结果还能使用,哪些需要重新解析。我们复用这套机制来处理流式追加。
例如,在末尾开始新段落时,前面未受影响的段落可以继续使用。列表、围栏等结构发生变化时,解析器也会按相应的语法处理,而不是仅凭一个空行就断定前面的内容已经结束。
3. 后到的定义可以回头修正引用
新引擎先收集当前文档中的链接定义,再检查哪些段落的引用判断受到影响。像上面的 [foo][bar][baz],需要重新判断哪部分是链接;同名定义则按首次出现的值处理。
它也会记录段落查询过哪些引用,包括当时还不存在的引用。有足够依据确认一个新定义没有影响该段落时,就继续使用旧结果。
下面这张白板把依赖画出来:api 的定义到达后,需要重新检查用过它的段落 B。普通介绍和只查询 faq 的段落,满足复用条件时可以保留。
4. 显示内容也要检查,才能复用
语法结构没变,链接地址或脚注编号仍可能改变。因此,渲染缓存还会检查这些依赖,以及组件和 HTML 配置。条件满足时保留已有内容;受影响或无法确认的块重新生成。
这样,AI 继续往下写时,前面可以复用的内容继续保留;后面的定义改变了前文含义时,前面的链接和脚注仍会更新。
这些步骤由谁负责
解析、引用判断和缓存证明在公共内核里。Vue 和 React 负责各自的组件与提交生命周期;网络流和可选图表渲染由应用接入。
这三张图都保留了原始图稿。自己的文档也可以接入同样的流程图、思维导图与白板渲染。
看一次流式更新
点击“开始流式演示”,观察正文先出现、链接和脚注定义随后到达的过程。同一个脚注被引用两次,脚注正文里也有链接。点击“停止”可以保留当前已经收到的内容。
也可以把上面的两个短例子粘到 Playground,查看新引擎的输出。
速度有什么变化
旧版的 Marked 本身很快。换用 Lezer,并不意味着所有 Markdown 都会解析得更快。当前优化主要减少两种重复工作:重新解析没有受影响的文字,以及重新生成可以复用的显示内容。
此前原 demo 的解析测试中,Lezer/MSFE 快于旧 Micromark、慢于旧 Marked。这批数据早于当前渲染缓存,不能当作当前版本的速度排名。
大型列表、表格或很长的单段落,仍可能作为一个整体重新处理。判断流式体验时,需要同时看解析、渲染和浏览器更新,不能只看解析器耗时。测量方法见性能说明。
使用与迁移
QuireKit Markdown 支持 Vue 和 React,两者共用同一套解析规则。基本用法仍然是传入 Markdown文字;链接、代码块等可以换成自己的组件,也支持自定义语法和显式开启 HTML。
新包没有沿用旧 Incremark 的 API,旧插件需要适配。可以从 Vue 快速开始或 React 接入开始,按自定义语法与节点迁移扩展。当前仍为未发布的预览版;旧 Incremark 与当前的核心、Vue、React 包均使用 MIT 许可。
继续了解实现
如果你准备扩展解析器或研究缓存,下面这些细节会更有用:
- 语法复用: CodeMirror 用文本事务和
TreeFragment映射变化。我们在 Lezer 层提供 顶层块复用信息,保持原生语法和复用判断;引用处理放在 MSFE 的 parser 包装层。 - 引用判断: 完整段落记录语法实际查询过的标签及其是否存在。只有这些查询结果未变, 才复用相应判断;没有完整记录的范围保守检查。地址和 title 的变化另由渲染层检查。
- 渲染缓存: 检查原生 Tree 身份、源码范围、引用目标、脚注导航和渲染配置。
自定义节点可以用
cacheKey(document)声明额外依赖;未声明时重新生成。 具体条件与缓存限额见性能说明。 - 框架与资源: Vue 和 React 使用各自的组件。React 在提交后接受候选缓存,资源也在 客户端提交后加载;切换、卸载和迟到结果按取消与释放规则处理,见 React 接入。
文中两例于 2026-10-11 验证,旧版为 @incremark/core 1.0.2、commit 98f6b95。旧结果来自两引擎的 AST 与定义表,当前结果来自 React 服务端渲染,并以独立完整 GFM 解析和CommonMark 引用规则核对。这两个例子说明了具体问题,版本范围不延伸到后续 Incremark。