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 结束时做一次明确的验证复盘:如果那时实时预览的手感被判定为 「达不到可日常使用」,就是重新评估这个决策的最后窗口期。