Skip to content

ADR-0002 · 编辑器内核选 CodeMirror 6(源码优先)而非 ProseMirror(模型优先)

  • 状态:已接受
  • 日期:设计阶段
  • 影响:这是本项目最重要的一次技术选型,几乎决定了后续所有设计

背景

要实现「无缝实时预览」,业界有两条成熟路线:

A · 模型优先(ProseMirror / Slate / Lexical) 文档在内存里是一棵富文本树,Markdown 只是它的一种导入/导出格式。 编辑操作作用于树,保存时把树序列化成 Markdown。

B · 源码优先(CodeMirror 6 + 装饰) 文档在内存里就是 Markdown 文本本身。编辑操作作用于文本, 渲染通过装饰层「投影」出来。保存 = 把文本写回磁盘。

决策

采用 B,基于 CodeMirror 6。

理由

1. 格式保真是本项目的核心承诺,而 A 在这一点上有结构性缺陷

目标 G2 要求「打开-保存后逐字节一致」。在方案 A 下,这需要富文本树能记住:

  • 用的是 *强调* 还是 _强调_
  • 列表标记是 -* 还是 +
  • 缩进宽度、表格是否对齐填充、代码围栏用几个反引号;
  • 无序列表项之间有几个空行;
  • 不认识的语法(自定义指令、原始 HTML、未来的新语法)的原始文本。

这些都可以塞进节点属性里,但每加一种语法就要多一份「源码提示」的维护成本, 而且一旦漏了一处,用户的文件就被悄悄改写了。这类 bug 的代价极高: 用户在 Git 里看到一个巨大的无关 diff,信任瞬间归零。

在方案 B 下,这个问题不存在 —— 不是解决了,是不存在。缓冲区就是文件内容。

2. 未知语法的处理

方案 A 遇到不认识的语法,要么解析失败,要么塞进一个「原始文本」节点里; 往返一次经常就变形了。方案 B 天然「不认识就不装饰」,文本原样保留。

对一个要支持插件扩展语法的编辑器来说,这个属性非常重要: 用户装了插件写的文档,卸载插件后内容不能坏

3. 大文档

CodeMirror 6 的视口渲染 + Lezer 增量解析,处理几万行文档是设计目标之一。 ProseMirror 会为整篇文档构建 DOM,长文档下压力明显。

4. 协同编辑(未来)

源码优先模型下,协同就是「在一段文本上做 CRDT」—— Yjs 的 Y.Text 直接可用, 几乎零适配成本。ProseMirror 需要 y-prosemirror 做模型层映射,复杂度高一个量级。 虽然协同不在 v1 范围,但保留这条低成本路径很有价值。

5. diff 友好

作家和工程师都会把 Markdown 放进 Git。方案 A 保存时整篇重新序列化, 容易产生大片格式噪声 diff。方案 B 只有真正改动的字符会变。

代价(明确承认)

**这不是免费的午餐。**方案 B 把复杂度从「序列化正确性」转移到了「呈现精细度」:

代价应对
嵌套结构(列表里的引用里的代码块)的排版观感不如富文本模型靠 CSS 与装饰精修;接受它「像精排的源码」而不是「像 Word」
表格、公式块需要自建 widget 编辑层02 §6 的双态模型;这是 M2 的主要工作量
隐藏文本导致的光标 / 选区细节问题多02 §4 逐条列了对策,且都有 Playwright 用例守着
装饰重建的性能需要自己管视口内重建 + 激活集缓存 + 性能预算进 CI

判断依据:这些代价是「多花工时能做好」的工程问题; 而方案 A 的保真问题是「结构性的,只能逼近不能消除」。 对一个把格式保真写进核心承诺的项目,前者可接受,后者不可接受。

验证

这条路线不是纸上推演 —— Obsidian 的 Live Preview 就是 CodeMirror 6 + 装饰实现的, 在数百万用户规模上验证了可行性与手感上限。我们与它的差别在于产品定位(编辑器 vs 知识库), 不在技术路线的可行性。

反悔成本

高。整个 02、03 号文档都是这个决策的推论。若要改到方案 A, @typo/editor@typo/markdown 基本要重写,@typo/ui@typo/export 可保留。

因此建议在 M1 结束时做一次明确的验证复盘:如果那时实时预览的手感被判定为 「达不到可日常使用」,就是重新评估这个决策的最后窗口期。

MIT 许可发布。与 Typora 无关联,是一个独立实现。