手头的项目需要一个富文本编辑器,需求里带表格、带自定义节点,还要能撤销。先在 contenteditable 上直接写了一版,越写越不对劲:同样的操作在不同浏览器里产出的 DOM 不一样,从 DOM 读回内容时遇到一堆边界情况,修一个 bug 牵出两个。停下来想了一下,问题出在整个思路的起点上:我把 DOM 当成了文档本身。
ProseMirror 走了另一条路。它把文档做成纯数据,DOM 只是数据的渲染结果,浏览器只管显示和报事件,文档长什么样由代码说了算。这个思路听起来简单,落到代码里牵扯出一整套设计:文档怎么表示、修改怎么表达、状态和视图怎么分工。这套东西值得逐文件读一遍,所以开这个系列。读法和我之前读源码的习惯一样:不泛泛介绍 API,直接打开文件看数据结构和函数,每一篇只解决一个具体问题。
系列目录
| 日期 | 标题 |
|---|---|
| 05-10 | ProseMirror 源码分析开篇:富文本编辑器到底难在哪(本篇) |
contenteditable 的坑
contenteditable 的思路是浏览器包办一切:给一个 div 挂上这个属性,输入、删除、回车、粘贴、选区管理都由浏览器实现,页面只需要读 innerHTML。问题是各浏览器对这些操作的实现不一样。
回车键是个典型的例子。在某个浏览器里回车会新起一个 div 包一行,换一个浏览器可能在原地插两个 br,再换一个会用 p 包起来,还会顺手给空行塞一个 br 占位。删除也一样:跨元素删选区,有的浏览器会把两个块合成一个,有的会留下一个空的块,空块里有没有占位 br 又不统一。也就是说,用户做了同一个动作,编辑器里的 DOM 结构取决于用户用的是哪个浏览器。程序想在「文档」层面做点事情,先得回答「现在的 DOM 到底表示什么」,而这个答案因浏览器而异。
更深一层的问题是 HTML 本身就是一份含糊的文档表示。同一段「加粗又斜体的文字」,可以写成 b 套 i,也可以 i 套 b,也可以拆成两个相邻的 b 标签各自套一段。视觉上等价,结构上不同。浏览器不会替你做规范化,它只管渲染。把 HTML 当数据模型用,等于接受一份「同义句有无数种写法」的格式当唯一事实来源,任何基于它的判断(这段文字加粗了吗、光标后面是什么节点)都要先把这些等价写法穷举一遍。
选区是另一个不稳定来源。DOM 选区用「节点加偏移」表示,同一个视觉上的光标位置经常有多个合法的表示方式:一个段落开头,可以表示成段落节点的 0 偏移,也可以表示成段落里第一个文本节点的 0 偏移。程序想判断「光标在哪」,先要在这堆等价表示里消歧。还有些位置上根本没有光标能落脚的文本节点,比如两个图片节点中间,浏览器处理这类位置的方式又各有出入。
粘贴的问题更直接。从外面复制的内容带着来源方的全部标记:内联样式、浏览器私有标签,从办公软件里复制出来时还有一堆包裹用的 div 和 span。放任它进 DOM,文档立刻被弄脏;想过滤,就得在粘贴事件里拦截、解析、清洗,而粘贴事件在各浏览器里能拿到什么数据、什么时候触发,又不完全一致。
还有中间状态的问题。中文输入法在 composition 期间,拼音串是直接写进 DOM 的,用户还没选词,DOM 里的内容处于「既不是最终文本、又不能丢」的状态。这时候如果程序也在改 DOM,两边的修改会打架。execCommand 那套命令接口也是浏览器各自实现的,bold、insertHTML 这些命令在不同浏览器里产生的结构差异不小,想靠它做统一的行为没有指望。
归纳一下,直接把编辑器架在 contenteditable 上有三个绕不开的麻烦:浏览器行为不一致,同样的操作产出不同的 DOM;HTML 是同义写法泛滥的格式,从 DOM 读回语义答案不唯一;输入法等中间态会让 DOM 暂时脱离程序的控制。这三个麻烦有一个共同的根:文档的唯一事实来源是 DOM,而 DOM 的行为由浏览器决定,程序说了不算。
文档即数据
ProseMirror 把事实来源从 DOM 搬到了一份纯数据上。文档是一棵由代码定义结构的树,存在 JavaScript 对象里。DOM 退化成这棵树的渲染结果:树变,DOM 跟着变;浏览器擅自改了 DOM(输入法、粘贴),编辑器把 DOM 的变化读回来翻译成对树的修改,再重新渲染。
这么一搬,前面三个麻烦各自有了着落。浏览器行为不一致没关系,因为 DOM 的结构由代码生成,回车该产生什么结构由代码决定,浏览器只负责把按键事件报上来。同义 HTML 的问题消失了,因为文档有唯一的结构表示,读回语义时查的是树,不猜 DOM。中间态问题被隔离在一个明确的环节里:DOM 被浏览器动了之后,有一个专门的「读回」步骤负责把 DOM 状态对齐回文档,对齐之前文档数据不受影响。
文档变成纯数据之后,几件原本难做的事情顺带着变得直接。文档可以序列化成 JSON 存起来、走网络传输,加载时再解析回树,存取不再依赖 HTML 的解析规则。撤销可以对数据做,不需要浏览器那套说不清的 undo 栈。多人协作可以表达成「把对方对数据做的修改合并过来」,修改和位置都是数据层面有明确定义的东西。这些能力后面都会讲到,它们全都是「文档即数据」这一个决定的下游。
这个思路的代价是,编辑器要自己实现几乎所有编辑行为:回车拆分段落、退格合并节点、粘贴解析 HTML,全是代码写的。这正是一个富文本编辑器库的主体工作量所在,也是这套源码值得读的原因。
四条设计原则
「文档即数据」只是方向,落到代码里是四个具体的决定。它们分别对应四个包的核心文件,后面整个系列会逐个展开,这里先把骨架立起来。
不可变文档
文档树是不可变的。参考代码是 prosemirror-model 的 6264de0,src/node.ts 的 Node 类,构造时收 type、attrs、content、marks 几个字段,全部 readonly,创建之后没有任何方法能改它。要「修改」文档,做法是算出一棵新树,沿途没变的子树直接复用旧引用。
不可变带来的直接好处是新旧文档可以并排存在。撤销要引用旧文档,直接存着就行;视图更新时想比较「哪里变了」,拿新旧两棵树做比较就行,prosemirror-model 里 src/diff.ts 的 findDiffStart、findDiffEnd 干的就是这件事。比较时没变的子树是同一个引用,相等判断一步就过,不用递归下去逐字段比对,这是共享引用顺带给的性能收益。如果文档是可变的,旧状态只能靠快照或者操作日志重建,这两条路都更绕。
文档内容存放在 src/fragment.ts 的 Fragment 里,它同样不可变,并且缓存了 size,后面讲 model 的篇章会展开。
显式 Schema
文档能装什么内容,由一份显式的 schema 规定。prosemirror-model src/schema.ts 的 Schema 类,由一组 NodeSpec 和 MarkSpec 编译出来。每种节点声明自己的内容表达式,比如段落只能装 inline 内容,列表项只能装特定结构;还可以声明 attrs、分组、能不能被某些 mark 修饰。加粗斜体这类内联格式由 MarkSpec 定义,挂在文本节点上,不进树。NodeType.createChecked 会在创建节点时校验,不合法的结构根本构造不出来。
这补上了 HTML 模型缺的一半。DOM 对结构没有约束,div 里套 table 再套 div 浏览器照渲染不误;schema 约束下,文档在任何时刻都保证合法,编辑命令产生的每个中间结果也合法。校验集中在创建节点这一个入口,写编辑逻辑的人不用每一步都自查。内容表达式怎么编译、怎么匹配,是 model 阶段一整篇的内容。
Transaction 驱动
所有对文档的修改,统一表达成 transaction,走同一条管道。这条管道分两层。
底层在 prosemirror-transform,参考代码是 662b7a9。src/step.ts 的 Step 是个抽象类,代表一步最小修改,接口有四个:apply 把这步应用到文档上返回新文档,invert 算出它的逆操作,getMap 给出这步对位置的映射,toJSON 支持序列化。一步应用失败会返回带失败信息的结果而不是抛异常,调用方检查后决定怎么处理。src/map.ts 的 StepMap 和 Mapping 负责位置映射:文档变了一个区间,旧文档里的位置对应到新文档哪里,都由映射回答。
上层在 prosemirror-state,参考代码是 ffad5d9。src/transaction.ts 的 Transaction 直接继承 transform 的 Transform,在步骤之上加了选区、时间戳和 meta 数据。src/state.ts 的 EditorState.apply(tr) 接收一个 transaction,返回一个全新的 EditorState。整个编辑器的状态迁移只有这一个入口。
统一管道的好处体现在扩展上。撤销重做用 invert 和映射回放,协作编辑用映射把远端步骤 rebase 到本地,插件可以在 applyTransaction 里拦截和追加 transaction。这些能力各是一个独立的包,但它们都建立在 step 和映射这两个抽象上,不需要碰核心代码。
核心与扩展分离
顺着上面说,ProseMirror 的核心只管文档、修改、状态、视图四件事,快捷键、撤销历史、输入规则、菜单这些全是扩展包,通过插件系统挂上去。插件系统的定义在 prosemirror-state src/plugin.ts:Plugin 类包一份规格,StateField 接口让插件持有自己的状态字段,字段的值由它的 apply 函数维护:接收 transaction 和旧值,返回新值,跟着状态迁移走。
视图这一侧同样克制。参考代码是 prosemirror-view 的 ca4c78e,src/index.ts 的 EditorView 把 DOM 完全收归自己管理:构造时在传入的挂载点上创建可编辑节点,attrs.contenteditable 由 view 自己设置;每次状态更新走 updateState,内部用一棵和文档对应的视图描述树(src/viewdesc.ts 的 ViewDesc)做增量更新,只动变化的局部。浏览器擅自改了 DOM 时,src/domobserver.ts 的 DOMObserver 监听 mutation,src/domchange.ts 的 readDOMChange 负责把变化读回来翻译成文档修改。反过来,编辑器产生的每个 transaction 都通过 dispatchTransaction 交还给调用方,由调用方决定怎么应用,view 不擅自推进状态。
所以 view 的定位很清楚:DOM 由它独占管理,外面不该碰;状态是外面的,它只负责渲染和上报。这条边界划出来,核心就不需要知道菜单长什么样、撤销栈怎么存,扩展也不需要知道 DOM 怎么增量更新。
系列规划
这个系列按「从整体到局部、从核心到扩展」推进,共 60 篇,大致分十个阶段:
| 阶段 | 篇数 | 内容 |
|---|---|---|
| 整体 | 3 | 本篇、仓库全景、跑通最小 demo 看文档结构 |
| model | 9 | Node/Fragment、Mark、Schema、ResolvedPos、Slice、DOM 序列化与解析、diff |
| transform | 6 | Step 抽象、ReplaceStep、StepMap、Mapping、结构判断、Transform 类 |
| state | 6 | EditorState、Selection、Transaction、Plugin 系统、手写插件 |
| view | 13 | EditorView、ViewDesc、DOM 读回、输入管线、选区同步、IME、NodeView、Decoration、剪贴板等 |
| 基础扩展 | 9 | keymap、commands、history、inputrules、schema 系列、gapcursor、dropcursor、menu |
| 高级扩展 | 5 | collab、changeset、markdown、search |
| tables 专题 | 4 | 表格 schema 与 TableMap、CellSelection、编辑命令、列宽拖拽 |
| 工程与测试 | 3 | example-setup 装配、test-builder、多包构建 |
| 对比与总结 | 2 | 与其他编辑器架构对比、全系列总结 |
阅读顺序上有个依赖关系要说明:model 是基础,文档结构和位置约定不理解,后面 transform 的步骤和映射没法看;transform 的映射机制又是 state 的插件、history 的回放、collab 的 rebase 共同依赖的东西。所以中间几段顺序不能跳,扩展部分相对独立,可以挑感兴趣的读。
下一篇先看仓库全景:二十来个包怎么分工,核心四层的依赖方向是什么样,为什么拆成多包而不是一个大包。

