ProseMirror 系列收官:从这套代码里能拿走的设计

📅
3 分钟阅读
·

第 59 篇对比了 ProseMirror、Draft.js、Slate、Quill 的文档模型。本系列共 60 篇,从仓库结构和文档 JSON 开始,分析核心四包、从 keymap 到 tables 的十四个扩展包、构建工程和横向架构对比。本文汇总三个反复出现的设计,每项均指向具体篇目;提供系列地图;说明可复用的设计和特定于 contenteditable 的实现。核心四包的代码版本为:prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e;扩展包 hash 见各篇开头。

系列目录

日期标题
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:文档模型与更新模型对比
12-19ProseMirror 系列收官:从这套代码里能拿走的设计(本篇)

全系列地图

全系列地图:十个阶段、60 篇的分布

十个阶段中,第 2 至第 5 阶段分析核心四包,共 34 篇;中间三个阶段以 18 篇分析扩展生态,其中 tables 单独成段;最后五篇分析工程、测试、对比和总结。文章按依赖顺序排列:model 被 transform 依赖,transform 被 state 依赖,state 被 view 依赖,扩展依赖核心。因此每个机制的前置概念均在更早的篇目中说明。

model 阶段定义文档数据结构,Node、Fragment、Mark、Schema、ResolvedPos、Slice 是后续文章使用的基本概念;transform 阶段说明 Step、StepMap、Mapping 如何表达修改;state 阶段将修改纳入 Transaction 和插件容器;view 阶段用 13 篇处理 DOM 相关机制。扩展阶段每篇分析一个包,前置知识见此前文章。理解更新模型可阅读第 4、13、16、19、21、22、25 篇;理解协同可从第 16 篇继续阅读第 40、47、48 篇,其中 Mapping 在三个位置被使用。

设计一:不可变数据、显式映射、事务驱动

更新模型由以下过程组成:文档不可变;修改表示为 Step;Step 提供位置映射;多个 Step 组成 Transaction;应用 Transaction 后产生新的 state。以下各节对应这些机制的实现。

不可变的落点在第 4 篇和第 19 篇。Node 与 Fragment 创建之后没有任何写字段的方法,改一个字符要沿路径新建一串节点,没动到的子树整棵复用;Fragment 之所以不直接用数组,正是为了在不可变前提下把 size 缓存和 offset 计算做进结构里。EditorState 同理,apply 返回新对象,旧 state 原样保留。文档和状态都是值,这是后面一切机制的前提:旧文档永远在,undo 不需要快照,diff 不需要备份,任何时刻都可以拿两个 state 做比较。

位置编号也服务于这个模型。第 7 篇拆过,pos 是扁平整数,每个节点边界各占一个位置,嵌套文档就是一条数轴。这个约定初看别扭,但它的回报是把「位置在文档变动后去了哪里」变成了一个纯数学问题,不碰树结构就能算。第 8 篇的 Slice 用 openStart 和 openEnd 记录切片两端打开的深度,让「切一块再塞回去」这个操作也能在数轴上定义清楚,后面的 ReplaceStep 直接站在这两个概念上。

显式映射被多个模块使用。Step 的 getMapapply 同级;每个修改除执行外,还需要定义旧位置到新位置的映射。第 15 篇的 StepMap 用区间段编码映射,第 16 篇的 Mapping 用 mirror 数组组合多步映射。history 使用 invert 生成的逆 Step,并通过 Mapping 重放历史(第 40 篇);collab 的 rebase 将本地未提交 steps 映射到远端修改之后(第 47、48 篇);search 映射查找 range(第 51 篇);DecorationSet.map 映射装饰(第 33 篇);SelectionBookmark 恢复时也使用映射(第 20 篇)。

事务驱动保证这套映射不漏。所有修改走 dispatchTransaction 一个出口(第 25 篇),主动命令造的 transaction 和 DOMObserver 读回后翻译出来的 transaction 进同一条管线(第 28、29 篇)。Transaction 在 Transform 之上又加了选区、storedMarks、meta、time 四个状态语义(第 21 篇),其中 meta 是插件之间传话的信道。修改集中在一个点,插件系统才有机会在同一个点上观察、否决、修正全部修改意图,filterTransaction 和 appendTransaction 因此成立(第 23 篇)。

代价也要说清楚:写扩展的人必须时刻记得 map。凡是持有位置的东西,选区、装饰、查找结果、协同状态,文档一变都要跟着映射,漏掉一个就是一个潜在的错位 bug。第 33 篇 DecorationSet 的 map 实现和第 51 篇 search 的 range 映射,本质上都在替使用者承担这部分映射工作。另一笔成本在表达力上:修改必须先能写成 Step 才能发生,一个操作如果找不到对应的 step 组合,就得像第 14 篇的 Fitter 那样为 slice 计算闭合节点序列,把结构问题消化在 transform 内部,直接改树的接口始终没有放开。这个约束挡住了随意性,也抬高了实现新修改类型的门槛。

设计二:核心薄,扩展厚

核心四包的职责切得很干净:model 定义文档是什么,transform 定义修改怎么表达,state 定义改完的状态和插件容器,view 定义文档怎么显示、DOM 事件怎么读回来。第 2 篇画的依赖图单向无环,22 个包里其余 18 个全部挂在核心外面。连文档结构本身都不算核心资产,schema-basic 和 schema-list 都是扩展包(第 42、43 篇),核心只承诺「schema 这个抽象」,不承诺任何具体的节点类型。

边界判据可以从两边看。被推出核心的,都是能用「插件加核心 API」表达的功能:keymap 只靠 handleKeyDown 一个 prop 实现全部快捷键(第 38 篇);history 靠 StateField 存栈,事件分组做在 StateField 的 apply 里,按时间阈值和位置相邻判断(第 40 篇);gapcursor 靠自定义 Selection 类型加装饰伪装 DOM(第 44 篇);menu 是整套 UI,一行核心代码都不用碰(第 46 篇);tables 这种重度功能也是外部包,只靠公开 API 加自己的 Selection 类型和一批命令(第 52 到 55 篇)。留在核心的都是绕不开的:文档语义、step 语义、选区抽象、DOM 读写。

第 37 篇验证过这个边界:只用 model、state、view 三个包手写一个最小编辑器,输入、删除、加粗都能跑,一个官方扩展都不需要。反过来看 example-setup(第 56 篇),一个功能完整的编辑器也只是这些外部插件的有序组合,装配顺序就是全部配置。核心薄的回报在测试上也看得见,第 57 篇的 test-builder 能存在,前提是文档可以用纯数据构造,不依赖任何 DOM 环境,transform 的测试用例全在 Node 里跑完,每个用例就是两个 builder 造出的文档加一步操作,断言对象就是文档本身。

这个划分还有一个后果值得单独说:核心的薄是靠 view 层的厚换来的。model 和 transform 可以完全不知道浏览器的存在,代价是 view 必须独自承担 DOM 的全部不确定性,MutationObserver 的读回(第 28 篇)、findDiffStart 和 findDiffEnd 的对齐(第 11 篇)、composition 期间的更新冻结(第 31 篇)都堆在 view 里。核心薄扩展厚这句话的完整版是:抽象能收拢的复杂度进核心,收不拢的复杂度隔离进 view,能往外推的功能全部推给扩展。

自己拆库时这把尺子可以直接用:一个功能如果需要动文档语义或者 DOM 读回,进核心;如果只是往已有的修改管线上挂一段逻辑,做成插件。判据简单,难在执行到底:这套代码连光标指示线这种编辑器标配都挡在核心外面,边界一旦定下来就不为单个功能开口子。

设计三:插件系统的能力分层

第 22、23 篇拆完插件系统之后,后面每一篇扩展都是对号入座。插件能挂的点分四层:StateField 管数据,init 和 apply 都是纯函数,state 每次应用 transaction 时把它们全部重算一遍;filterTransaction 与 appendTransaction 管修改,一个负责否决,一个负责修正,appendTransaction 的修正循环没有迭代上限,防失控靠的是记账规则:每个插件对每个事务只看到一次,自己追加的事务也不会再触发自己,收敛义务留在插件契约上,条件已满足就返回 null;props 管交互,handleKeyDown、handleTextInput 这些入口按插件顺序逐个询问,someProp 这个统一查找函数就是全部调度逻辑;view 层的 pluginView、nodeViews、decorations 管渲染。

每个扩展用到的组合不同,这正是分层的意义。inputrules 的主体挂在 handleTextInput 一个 prop 上(第 41 篇);collab 用 StateField 存 unconfirmed 状态,远端 steps 到达时由 receiveTransaction 构造成一个普通 transaction,走正常 dispatch 落地(第 48 篇);columnresizing 用 widget 装饰画拖拽手柄,用 nodeView 接管表格 DOM(第 55 篇);search 用 StateField 存查询,用 decorations 画高亮(第 51 篇);history 是 StateField 加 props 的组合,两个撤销栈存在字段里,beforeinput 事件挂钩接管 historyUndo 和 historyRedo,撤销动作本身也是一次普通 dispatch(第 40 篇)。四层挂载点组合起来,覆盖了后面 20 多篇扩展的全部需求形状。

分层还带来一个不太显眼但实用的性质:插件之间没有直接通信渠道,要传话只能走 transaction 的 meta(第 21 篇),或者读对方 StateField 的值。通信面窄,组合顺序才可控,example-setup 里插件的排布顺序能成为一种配置,靠的就是这个约束。history 和 collab 共存时靠 meta 标记区分本地修改和远端修改(第 48 篇),search 的高亮和编辑器的其他装饰互不干扰,都是这个约束在起作用。插件写错的影响面也被框住了:StateField 出错只影响自己的状态,filterTransaction 出错顶多否决掉不该否决的修改,都不会绕过 state 直接弄脏文档。

可复用的设计与特定实现

可复用的设计包括:将修改定义为可逆、可映射、可序列化的最小单位,以支持 history、collab 和 changeset;根据文档语义和 DOM 读回划分核心与扩展;将插件接口分为数据、修改、交互、渲染四层。这些机制也可用于表格编辑器、绘图工具和配置面板。第 58 篇的多仓库工程还展示了用约定管理构建、测试和发布的方式,适用于维护者较少的多包项目。

拿不走的是 view 层的复杂度。第 36 篇的浏览器补丁集、第 31 篇的 IME 处理、第 30 篇的选区双向同步,合在一起说明一件事:只要底层还是 contenteditable,DOM 这一层的问题就抽象不掉,只能集中、隔离、逐个打补丁。这套代码的处理方式是把脏代码圈在 domobserver、domchange、browser 这几个文件里,让补丁不往 model 和 transform 渗。第 35 篇的坐标换算是另一个例子,endOfTextblock 一个函数就攒了一摞浏览器行为判断,这类知识没有原理可言,全靠逐个平台验证积累。设计思路可以学,想绕过这层复杂度另起炉灶,前面踩过的坑一个都不会少。

第 3 篇控制台输出的 transaction 由本系列的多个模块共同定义:steps 数组对应第 13、14 篇,mapping 对应第 15、16 篇,selectionmeta 对应第 20、21 篇,处理管线对应第 25、29 篇。按实现结构阅读源码后,可以从行为反查具体函数。进一步的设计讨论位于 rfcs 仓库,website 仓库的 guide 按使用视角组织相关内容。


1073 字 · 28 段落
ximing

Follow onGitHub

相关文章