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

7 分钟阅读
·

上一篇把多包仓库的构建工程看完了,到这篇为止,核心四包加全部扩展的机制都拆过一遍。这篇换个角度:把 ProseMirror 放回富文本编辑器这个品类里,和 Draft.js、Slate、Quill 三家对照,看同一批问题在不同架构下的答案。对比集中在四层:文档怎么存、修改怎么表达、合法性谁来保证、扩展怎么挂。

先交代口径。ProseMirror 一侧的论断都能对应回前面拆过的机制,我会点名篇目;另外三家只写公开文档和源码里可确认的机制层事实,不推测内部实现,也不做优劣排名,结构差异本身就是结论。ProseMirror 侧参考代码是 prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9。

系列目录

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


1229 字 · 37 段落
xi ming

Written by xi ming You should follow him on Github