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, @mosu/editor 与 @mosu/markdown 基本要重写,@mosu/ui 和 @mosu/export 可保留。
因此建议在 M1 结束时做一次明确的验证复盘:如果那时实时预览的手感被判定为 「达不到可日常使用」,就是重新评估这个决策的最后窗口期。
复盘(M4.5 补记)
那个窗口期已经过了,决策成立,不重新评估。四条依据:
- 手感达标。 按键处理 p95 0.1ms,打开 10k 行 36ms(07 §2)。装饰驱动的 实时预览没有出现「输入卡顿」这一类结构性问题。
- 格式保真兑现了。 CommonMark 官方 652 例语料全部「打开→保存字节不变」。 这正是选 CodeMirror 而不是 ProseMirror 的核心理由 —— 富文本模型的 序列化偏差在这里根本不存在,因为没有序列化这一步。
- 装饰模型扛住了后续需求。 表格、公式、图表、行内 HTML、代码块语言选择器、 专注模式,全部是「再加一层装饰」,没有一次需要动内核形态。 尤其是行内 HTML:因为渲染是投影而不是转换,它可以做到一个字节的 HTML 都 不进 DOM(02 §5.1)—— 富文本模型下这个解法不存在。
- 代价也如实兑现了。 「装饰规则是这个项目最容易写错的地方」这句判词准确: 02 号文档里绝大多数篇幅都在讲某条装饰规则为什么长这样,而每一条都对应过一个 真实的手感问题。
唯一被推翻的相关设想是表格网格编辑器(02 §6.4):那是「用 widget 装一份第二 状态」的思路,跟本 ADR 的整个前提相冲突。放弃它之后,表格编辑反而变成了一组纯 文本变换,撤销与脏标记自动正确。