ProseMirror 源码分析开篇:富文本编辑器到底难在哪

📅
2 分钟阅读
·

手头的项目需要一个富文本编辑器,需求包括表格、自定义节点和撤销功能。我先在 contenteditable 上直接实现了一版,随后发现同样的操作在不同浏览器中会产出不同的 DOM;从 DOM 读回内容时也要处理许多边界情况,一个 bug 的修复还会影响其他场景。问题在于,我把 DOM 当成了文档本身。

ProseMirror 使用另一种方式:文档是纯数据,DOM 是数据的渲染结果;浏览器负责显示和派发事件,代码定义文档结构。这个思路涉及文档表示、修改表达以及状态和视图的职责划分。这个系列将逐文件阅读相关实现,不泛泛介绍 API,而是查看数据结构和函数;每篇文章聚焦一个具体问题。

系列目录

日期标题
05-10ProseMirror 源码分析开篇:富文本编辑器到底难在哪(本篇)

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.tsNode 类在构造时接收 typeattrscontentmarks 等字段,全部为 readonly,创建后没有修改它的方法。修改文档时,程序会计算一棵新树,未变化的子树继续复用旧引用。

不可变性允许新旧文档并存。撤销操作可直接引用旧文档;视图更新时,可比较新旧两棵树以确定变化位置。prosemirror-model 中 src/diff.tsfindDiffStartfindDiffEnd 就负责这项比较。未变化的子树使用同一引用,相等判断可直接结束,无需递归比较字段,共享引用由此减少比较开销。若文档可变,旧状态需要通过快照或操作日志重建。

文档内容存放在 src/fragment.tsFragment 中。它同样不可变,并缓存了 size,model 阶段会展开说明。

显式 Schema

文档可包含的内容由显式 schema 规定。prosemirror-model src/schema.tsSchema 类由一组 NodeSpecMarkSpec 编译而来。每种节点声明自己的内容表达式,例如段落只能包含 inline 内容,列表项只能包含特定结构;还可以声明 attrs、分组以及是否可被某些 mark 修饰。加粗、斜体等内联格式由 MarkSpec 定义,附着在文本节点上,不进入树结构。NodeType.createChecked 会在创建节点时校验,非法结构无法构造。

HTML 的 DOM 结构没有相应约束,例如 div 中嵌套 table 后再嵌套 div,浏览器仍会渲染。schema 约束下,文档在任何时刻都保持合法,编辑命令产生的中间结果也必须合法。校验集中在创建节点的入口,编辑逻辑无需在每一步重复检查。内容表达式的编译和匹配是 model 阶段的一篇内容。

Transaction 驱动

所有文档修改都以 transaction 表达,并经过同一管道。该管道分为两层。

底层位于 prosemirror-transform,参考代码是 662b7a9。src/step.tsStep 是抽象类,表示一次最小修改,提供四个接口:apply 将修改应用到文档并返回新文档,invert 计算逆操作,getMap 给出该修改的位置映射,toJSON 支持序列化。应用失败时会返回带失败信息的结果,不会抛出异常,调用方据此决定处理方式。src/map.tsStepMapMapping 负责位置映射:文档中的区间变化后,旧文档的位置在新文档中对应哪里,由映射计算。

上层位于 prosemirror-state,参考代码是 ffad5d9。src/transaction.tsTransaction 直接继承 transform 的 Transform,在步骤之外增加选区、时间戳和 meta 数据。src/state.tsEditorState.apply(tr) 接收一个 transaction,返回新的 EditorState。编辑器的状态迁移通过这个入口进行。

这一管道支持扩展功能。撤销重做通过 invert 和映射回放;协作编辑通过映射将远端步骤 rebase 到本地;插件可在 applyTransaction 中拦截和追加 transaction。这些能力分布在独立的包中,但都以 step 和映射为基础,不必修改核心代码。

核心与扩展分离

ProseMirror 的核心处理文档、修改、状态和视图;快捷键、撤销历史、输入规则和菜单由扩展包通过插件系统提供。插件系统定义在 prosemirror-state src/plugin.tsPlugin 类包含一份规格,StateField 接口允许插件维护自己的状态字段。字段值由它的 apply 函数维护:函数接收 transaction 和旧值,返回新值,并随状态迁移更新。

视图层的职责也受到限制。参考代码是 prosemirror-view 的 ca4c78e,src/index.tsEditorView 管理 DOM:构造时在传入的挂载点创建可编辑节点,attrs.contenteditable 由 view 设置;状态更新通过 updateState 进行,内部使用与文档对应的视图描述树,即 src/viewdesc.tsViewDesc,执行增量更新,只修改变化的局部。浏览器修改 DOM 时,src/domobserver.tsDOMObserver 监听 mutation,src/domchange.tsreadDOMChange 将变化转换为文档修改。编辑器产生的每个 transaction 都通过 dispatchTransaction 返回给调用方,由调用方决定如何应用,view 不直接推进状态。

view 的边界是:它管理 DOM,外部代码不应直接修改;状态由调用方持有,view 负责渲染和上报。这一职责划分使核心无需处理菜单样式或撤销栈的存储方式,扩展也无需处理 DOM 的增量更新。

系列规划

这个系列按从整体到局部、从核心到扩展的顺序推进,共 60 篇,分为十个阶段:

阶段篇数内容
整体3本篇、仓库全景、跑通最小 demo 看文档结构
model9Node/Fragment、Mark、Schema、ResolvedPos、Slice、DOM 序列化与解析、diff
transform6Step 抽象、ReplaceStep、StepMap、Mapping、结构判断、Transform 类
state6EditorState、Selection、Transaction、Plugin 系统、手写插件
view13EditorView、ViewDesc、DOM 读回、输入管线、选区同步、IME、NodeView、Decoration、剪贴板等
基础扩展9keymap、commands、history、inputrules、schema 系列、gapcursor、dropcursor、menu
高级扩展5collab、changeset、markdown、search
tables 专题4表格 schema 与 TableMap、CellSelection、编辑命令、列宽拖拽
工程与测试3example-setup 装配、test-builder、多包构建
对比与总结2与其他编辑器架构对比、全系列总结

model 是基础:在理解文档结构和位置约定前,无法理解 transform 的步骤和映射。transform 的映射机制又是 state 插件、history 回放和 collab rebase 的共同依赖,因此这些阶段应按顺序阅读。扩展部分相对独立,可以按需选择。

下一篇将查看仓库全景,说明二十来个包的职责、核心四层的依赖方向,以及拆分为多个包的原因。


564 字 · 40 段落
ximing

Follow onGitHub

相关文章