ProseMirror vs Draft.js / Slate / Quill:文档模型与更新模型对比

📅
3 分钟阅读
·

此前已分析核心四包及其扩展。本篇将 ProseMirror 与 Draft.js、Slate、Quill 对比,考察相同问题在不同架构中的实现。对比范围包括文档存储、修改表达、合法性校验和扩展机制。

ProseMirror 侧的论断对应本系列已分析的机制;其他三者仅采用公开文档和源码可确认的机制事实,不推测内部实现,也不作优劣排名。ProseMirror 侧参考代码为 prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9。

系列目录

日期标题
05-10ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17ProseMirror 仓库全景:22 个包怎么分工
05-24跑通一个最小 ProseMirror:先看文档长什么样
06-07ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05ResolvedPos:一个数字位置怎么变成路径
07-12Slice 与 replace:切一块文档出来再塞回去
07-19DOMSerializer:文档怎么变成 DOM 和 HTML
08-02DOMParser:parseDOM 规则与 HTML 解析
08-09findDiffStart / findDiffEnd:两份文档怎么求差
08-16model 收官:Node 上的辅助方法与位置约定总结
09-06ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03StepMap:一步修改怎么映射每个位置
10-11Mapping:多步映射的链式合并,rebase 的地基
10-18structure.ts:split/join/lift/wrap 的可达性判断
10-25Transform 类:构建修改的 API 层
11-08ProseMirror state(上):EditorState,不可变编辑器状态
11-15Selection 体系:四种选区与选区书签
11-22Transaction:Transform 加上状态语义
12-06Plugin 系统(上):StateField 与插件状态
12-13Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20state 收官:动手写三个插件验证理解
01-03ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10ViewDesc(上):文档到 DOM 的描述树
01-17ViewDesc(下):增量更新怎么做到只改动的部分
02-07DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21选区同步:state 选区与 DOM 选区的双向对齐
02-28Composition 与 IME:中文输入法事件的处理
03-07NodeView 与 MarkView:把渲染权交给你
03-14Decoration 体系:不修改文档的视觉标注
03-21clipboard:复制粘贴的序列化与解析
04-04domcoords:屏幕坐标与文档位置的双向换算
04-11browser.ts:浏览器差异补丁集
04-18view 收官:不用官方扩展,手写一个最小可用编辑器
05-09扩展(上):keymap,最小的插件
05-16commands:命令的签名约定与组合器
05-23history:undo/redo 栈与 rebasing
06-06inputrules:「# 空格」变成标题是怎么实现的
06-13schema-basic:官方基础文档结构
06-20schema-list:列表节点与最复杂的一批命令
07-04gapcursor:光标落不进去的地方怎么办
07-11dropcursor:拖拽时的插入位置指示
07-18menu:菜单栏组件体系
08-01collab(上):协作编辑的 rebase 原理
08-08collab(下):receiveTransaction 与整个收发循环
08-15changeset:变更集的计算与展示
09-05markdown:文档与 Markdown 的双向转换
09-19search:查找替换插件
10-03表格专题(上):表格 schema 与 TableMap
10-10表格专题(中):CellSelection,矩形的选区
10-17表格专题(下):addColumn/mergeCells 等编辑命令
10-24columnresizing:列宽拖拽的实现
11-07example-setup:官方起手式是怎么装配的
11-14test-builder:测试文档怎么写得像代码
11-21多包仓库的构建与发布工程
12-05ProseMirror vs Draft.js / Slate / Quill:文档模型与更新模型对比(本篇)

同一份文档,四种存法

拿一份样本文档对照:一个标题、一段含加粗文字的正文、一个无序列表项。

四种文档模型对比

ProseMirror 存的是一棵受 schema 约束的树。块级节点按内容表达式嵌套(第 6 篇),文本只出现在叶子上,加粗这类内联格式是挂在文本节点上的 Mark,不在树上开节点(第 5 篇)。整篇文档的位置用一个扁平整数 pos 编址,每个节点边界各占一个位置(第 7 篇),树的任何一层都能用单个数字定位。

Slate 存的也是树,但树的形状没有约束。Editor 根节点下是 Element 与 Text 两类节点,Element 的 type 是普通字符串字段,想嵌几层嵌几层,自定义数据直接挂在节点对象上。加粗这类格式是 Text 节点对象上的一个自定义字段,形如 { text: "发布", bold: true }。位置用 Path(从根到节点的下标数组)加 offset 表示。

Quill 对外暴露的文档模型是 delta:整篇文档摊平成一条线性序列,内容是 insert 的字符串与 embed 对象。字符级格式(加粗、颜色)作为 attributes 挂在对应的 insert 上,行级格式(标题、列表、引用)挂在这一行的换行符上。位置是单一整数 index。Quill 底层用 Parchment 的 blot 树对应 DOM,但 blot 是渲染与格式注册的内部结构,读写文档、表达修改用的都是扁平 delta。

Draft.js 的 ContentState 是 ContentBlock 的有序表。块与块之间没有父子关系,列表的嵌套层级靠块上的 depth 数值表达。块内格式不进树:每个字符在 characterList 里对应一份 CharacterMetadata,记录这个字符的 inline style 集合和 entityKey。链接、提及这类带数据的格式存进独立的 entityMap,由 entityKey 引用。位置用 block key 加块内 offset 表示。

顺带对比两个细节。一个是带数据的内联格式怎么存。ProseMirror 用带 attrs 的 Mark,schema-basic 里的 link 就是 href 存在 Mark 上(第 42 篇)。Slate 可以把链接做成 inline Element,把 href 挂在节点上,也可以做成 Text 上的字段。Quill 的 link 是一个值为 URL 的 format。Draft 用 entity,字符只记 key,数据在 entityMap 里。四种存法都能工作,区别在数据和文本的耦合程度。

另一个是选区的表示。ProseMirror 的选区是 anchor/head 两个 pos,外加 NodeSelection、GapCursor 这类面向结构的选区类型(第 20、44 篇)。Slate 的 Range 是 anchor 和 focus 两个 Point。Quill 的选区退化成 index 加 length,扁平序列上一个区间就够了。Draft 的 SelectionState 用 anchorKey/anchorOffset、focusKey/focusOffset 四个字段。选区表示基本跟着文档编址方式走,结构型选区(矩形单元格选区,第 53 篇)只有树模型里才有存在的必要。

文档模型的差异主要在嵌套能力和约束强度。ProseMirror 与 Slate 可表达任意深度的结构,因此表格和嵌套列表属于模型本体;Quill 与 Draft 的扁平结构将表格表示为 embed 或 atomic block,内部结构不在模型中展开。ProseMirror 在构造时经 schema 校验(第 6 篇的 createChecked 路径);Slate 在每次变更后通过 normalizeNode 修复一致性;Quill 通过 Parchment 格式注册表限定合法属性;Draft 的 Modifier 层较少限制内容一致性,由调用方维持。

序列化和外部格式的接入方式也跟着模型走。ProseMirror 的文档可以无损转成 JSON(第 3 篇看过它的形状),和 HTML 互转走 DOMSerializer/DOMParser 这对显式的转换层(第 9、10 篇),Markdown 有独立的序列化器(第 50 篇)。Quill 的 delta 本身就是 JSON,读写一体。Slate 的节点就是普通 JSON 对象,序列化约等于 JSON.stringify。Draft 用 convertToRaw/convertFromRaw 在 ContentState 和 RawDraftContentState 之间转换。模型越贴近普通 JSON,存库和传输越省事;模型离 HTML 越远,越需要专门的解析层,这个解析层同时就是粘贴和外部内容的入口(第 34 篇)。

修改怎么表达

ProseMirror 把一次修改拆成 step 序列。Step 是最小单位,带 apply、invert、getMap 三个接口(第 13 篇):apply 带失败语义,invert 给出逆操作,getMap 给出这一步对文档里每个位置的映射。Transaction 把一串 step 攒成一次提交(第 21 篇),Mapping 把多步映射链式合并(第 16 篇)。可逆性直接喂给 history 插件(第 40 篇),映射能力直接喂给 collab 的 rebase(第 47 篇)。

Slate 的修改分两层。上层是 Transforms,setNodes、insertNodes、unwrapNodes 这批 API;落地时全部换算成低阶 operation 对象,insert_text、remove_text、insert_node、set_node、split_node 等,editor.apply 一次应用一个。位置换算有对应物:Path、Point、Range 各自带 transform 方法,按一个 operation 把自己映射到新位置。文档不可变靠 Immer 实现,onChange 拿到的是新值。

Quill 的修改和文档用同一种格式。delta 的 retain、insert、delete 三个操作既描述文档内容也描述一次改动,updateContents(delta) 应用修改,text-change 事件抛出的也是 delta。Delta 库自带 invert 和 transform,撤销栈存逆 delta,协同按 OT 惯例做变换,操作格式和算法输入的形状完全对齐。

Draft 的修改入口是 Modifier 上的一批静态方法:insertText、removeRange、setBlockType、applyEntity 等,每个方法吃旧 ContentState 吐新 ContentState。EditorState.push 把新内容连同 changeType(insert-characters、remove-range 这类标签)压进编辑器状态,undo/redo 栈里存的是 EditorState 快照,changeType 用来向外部标注修改来源。

这一层的结构差异在于映射是不是一等公民。ProseMirror 的每个 step 自带位置映射,链式合并后任意历史位置都能换算到当前文档(第 15、16 篇),这是 collab 插件只用几百行就成立的前提(第 47、48 篇)。Quill 的 transform 是 delta 格式自带的能力。Slate 的 operation 配套 Point.transform 一族,协同库可以直接消费。Draft 的公开层没有操作级映射,栈里存整体快照,要做协同得在更外层自己补。

还有一个容易漏掉的对比点:修改的元信息怎么流动。ProseMirror 的 Transaction 带 meta 槽位,插件可以往里塞标记、往下游传话(第 21 篇),history 插件的 addToHistory 和 closeHistory 两枚标记就走这条通道,undo 栈的分组与截断靠它们控制(第 40 篇);appendTransaction 和 filterTransaction 让插件在一次提交的链路上追加修正或行使否决权(第 23 篇)。Quill 在 API 层有 source 参数,user、api、silent 三个取值标注修改来源,text-change 事件原样带出。Draft 的 changeType 起类似作用,写在 EditorState.push 里。Slate 的 operation 对象本身不带元信息字段,要标注来源得自己在编辑器外层约定。撤销、协同、审计这类消费方都吃元信息,这一层的设计决定了它们好不好写。

也要照实说成本。扁平序列的 invert 和 transform 在数学上好定义,约束树的逆操作必须携带 schema 语义:第 14 篇 Fitter 为 slice 寻找闭合节点序列的那段复杂度,就是这个选择在修改侧的成本。换来的是任何时刻文档都合法,以及 rebase 之后的文档仍然合法。

合法性在哪个环节保证

接着上一节往下落一层。ProseMirror 是改之前拦:step 应用时校验,ReplaceStep 的 slice 要经过 Fitter 找闭合(第 14 篇),非法结构进不了文档,任何时刻拿到的 doc 都满足 schema。Slate 是改完再修:变更先落地,normalizeNode 在每轮变更后检查,发现违规就地修回去,约束以代码形式写在 normalizeNode 里。Quill 靠注册表:在 Parchment 注册过的 format 和 attributor 才有效,解析时未注册的属性被丢弃,白名单即约束。Draft 默认不拦:Modifier 给什么存什么,RichUtils 这类上层工具帮忙维持常见一致性。四种策略对应四种信任模型,区别在约束写在哪、什么时候生效。写插件的人感受最直接:ProseMirror 里非法修改会在 step 应用时以失败的形式弹回来,调用方要处理失败分支;Slate 里插件作者要自己把约束写全;Quill 和 Draft 里大部分约束干脆由使用场景约定。

扩展怎么挂

ProseMirror 的插件是三件套(第 22、23 篇):StateField 让插件在 state 里存自己的数据,apply 是纯函数,插件状态跟着每次 transaction 重算;props 挂行为(按键、粘贴、点击);pluginView 让插件接管一块 UI。单个节点的渲染可以整个交出去(NodeView,第 32 篇),不改文档的视觉标注走 Decoration(第 33 篇),协同光标、查找高亮都用这条通道(第 33、51 篇)。行为扩展和渲染扩展是两个独立通道,菜单这类纯 UI 被刻意留在核心之外(第 46 篇)。插件的状态存在 state 内部,和文档、选区一起不可变替换,undo/redo、协作重放时它跟着走,不需要自己处理一致性。

Slate 的插件是「吃 editor 返回 editor」的函数,靠覆盖实例方法改行为:insertText 管输入、insertData 管粘贴、isVoid 和 isInline 定义节点性质、normalizeNode 写约束。渲染侧用 renderElement 和 renderLeaf 两个 prop 分发。扩展点集中在 editor 一个对象上,改动直接,多个插件覆盖同一个方法时调用顺序要自己链好。

Quill 走 module 体系:toolbar、keyboard、history、clipboard 都是模块,配置即可装卸,模块的状态由模块实例自己持有;新格式通过 Parchment 注册自定义 blot 或 attributor。扩展面分两块,行为找模块,格式找注册表。

Draft 的官方扩展面较窄:CompositeDecorator 给 entity 命中的范围配渲染组件,blockRendererFn 给自定义块配组件,keyBindingFn 接快捷键。更完整的能力由社区插件库在外层包出来,插件状态也只能挂在外层 React 组件里。

对比下来,扩展机制的关键差异在插件状态放哪。ProseMirror 把它收进 state,和文档同生命周期(第 19、22 篇);其余三家插件状态多在编辑器实例或外层组件上,编辑器内部不替它管一致性。

模型与浏览器 DOM 的关系

四层之外还有一层值得提:模型和 contenteditable DOM 怎么对齐。ProseMirror 维护一棵和文档对应的 ViewDesc 描述树,状态更新时做增量重绘(第 26、27 篇);浏览器自己改动 DOM 的部分(输入法、部分删除)由 DOMObserver 捕获,readDOMChange 用 diff 对齐读回(第 28 篇)。Quill 的 Parchment blot 树直接贴着 DOM,同样靠 MutationObserver 感知浏览器的改动。Slate 和 Draft 把渲染交给 React,Slate 用 renderElement/renderLeaf 逐节点出组件,Draft 每个 block 一个组件,浏览器行为引发的 DOM 变化在各自的事件层里消化。模型离 DOM 越远,中间这层对齐机制越厚,第 36 篇那批浏览器差异补丁就长在这一层上。

各自适合的场景

适用范围可由机制推导。包含表格、嵌套列表或自定义块级语义,并需要协同编辑的文档,适合采用 ProseMirror 的 schema 与 step/mapping;tables 专题(第 52~55 篇)和 collab 两篇(第 47、48 篇)分别展开了这两项能力。评论框、消息编辑器等线性内容可使用 Quill 的扁平 delta,并接入 OT 协同。文档结构尚未确定、需要任意嵌套和大量自定义节点行为的 React 项目,可使用 Slate 的自由树和覆盖式插件,并在 normalizeNode 中逐步补充约束。仅需块级结构且不需要深层嵌套的 React 项目,可使用 Draft 的扁平块模型。

这些限制也来自模型本身。扁平模型将表格表示为 embed 或 atomic block 后,排序、跨块选区和单元格内编辑需要在模型外实现。自由树的长期一致性依赖 normalizeNode,规则数量会随功能增加,每条规则还需保证收敛。强 schema 在修改时需要承担校验和闭合成本。选型时应先确认文档形态是否属于目标模型直接表达的结构,再评估模型外功能的实现成本。

下一篇汇总本系列分析的 60 篇内容。


1197 字 · 37 段落
ximing

Follow onGitHub

相关文章