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

3 分钟阅读
·

第 59 篇把 ProseMirror 和 Draft.js、Slate、Quill 的文档模型摆在一起做了对比,对比完这个系列就没有新模块可拆了。60 篇走下来,从仓库全景、文档的 JSON 结构开始,拆完核心四个包,再拆完 keymap 到 tables 的十四个扩展包,最后落到构建工程和横向对比。这篇收官,不再拆新代码,做三件事:把前面 59 篇里反复出现的三个设计抽出来,每个论断都指回具体篇目;画一张全系列地图;最后说清楚哪些东西能直接拿走,哪些拿不走。核心四包的代码版本在这里再记一次,方便对照:prosemirror-model 的 6264de0,prosemirror-transform 的 662b7a9,prosemirror-state 的 ffad5d9,prosemirror-view 的 ca4c78e,扩展包的 hash 在各篇开头都有。

系列目录

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

全系列地图

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

十个阶段里,第 2 到第 5 阶段拆核心四包共 34 篇,是全部内容的地基;中间三个阶段 18 篇给扩展生态,其中 tables 专题单独占一段;最后 3 篇工程与测试,2 篇对比与总结。排布顺序就是依赖顺序: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,应用后得到一个全新的 state。这句话里每个词都对应前面拆过的实现。

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

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

显式映射是这个系列出现次数最多的机制。第 13 篇拆 Step 的四个接口时,getMap 与 apply 平级:一步修改除了要会执行,还要会回答「每个旧位置去了哪里」。第 15 篇的 StepMap 用区间段编码这个回答,第 16 篇的 Mapping 用 mirror 数组把多步映射链式合并。这套设施建好以后被反复消费:history 的撤销栈存的是 invert 出来的逆 Step,远端修改到达时整段历史靠 Mapping 重放(第 40 篇);collab 的 rebase 把本地未提交的 steps 透过远端 mapping 逐个映射过去(第 47、48 篇);search 的查找结果是一组 range,文档一变就 map 一遍(第 51 篇);DecorationSet.map 让装饰跟着文档走(第 33 篇);SelectionBookmark 的恢复走的也是同一条路(第 20 篇)。一个 Mapping,至少五处消费方,这是全系列复用率最高的一段代码。

事务驱动保证这套映射不漏。所有修改走 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 直接弄脏文档。

拿得走的,和拿不走的

能拿走的有三样。一是 step 加 mapping 的建模方法:任何需要撤销、协同、审计的文档系统,都可以把修改定义成可逆、可映射、可序列化的最小单位,剩下的 history、collab、changeset 都是这个定义的推论。二是核心与扩展的边界判据,就是上一节那把尺子。三是插件能力分层的清单:数据、修改、交互、渲染四层挂载点,做插件机制设计时可以直接对照检查自己漏了哪一层。这三样都不绑定富文本场景,换成表格编辑器、绘图工具、配置面板,同一套问题还会出现,同一套解法也还成立。工程组织上也有可参考的部分,第 58 篇拆过,二十多个独立仓库靠一个不到四百行的脚本统一构建、测试、发布,靠约定替代配置,小团队维护多包项目时这套做法成本很低。

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

60 篇走完,再回头看第 3 篇控制台里打印的那条 transaction,当时只是一段 JSON,现在里面每个字段都能指出对应的实现文件和篇目。steps 数组里每一项的形状来自第 13、14 篇,mapping 来自第 15、16 篇,selection 和 meta 来自第 20、21 篇,它流过的管线来自第 25、29 篇。这是读源码最实际的产出:库里的每个行为都有去处,排查问题时可以从现象反查到具体函数。想继续往下挖,rfcs 仓库里有这些设计的讨论记录,website 仓库的 guide 是按使用视角重排的同一套知识,和本系列按实现视角的拆法正好互补。


1141 字 · 28 段落
xi ming

Written by xi mingFollow onGitHub