手头的项目需要一个富文本编辑器,需求包括表格、自定义节点和撤销功能。我先在 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 结构由代码生成,回车产生什么结构也由代码定义,浏览器负责派发按键事件。文档具有唯一的结构表示,读取语义时查询树而不是推断 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,外部代码不应直接修改;状态由调用方持有,view 负责渲染和上报。这一职责划分使核心无需处理菜单样式或撤销栈的存储方式,扩展也无需处理 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 的共同依赖,因此这些阶段应按顺序阅读。扩展部分相对独立,可以按需选择。
下一篇将查看仓库全景,说明二十来个包的职责、核心四层的依赖方向,以及拆分为多个包的原因。
