DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档

6 分钟阅读
·

前两篇看的都是文档到 DOM 的方向:ViewDesc 树怎么把 state.doc 画出来,增量更新怎么只重画变化的部分。编辑器里还有反方向的一条路。用户在 contenteditable 里打字、删除、用输入法上屏,这些操作由浏览器直接改 DOM,文档模型事先完全不知情。把 DOM 里的变化读回来、翻译成 transaction,是 view 层的另一半职责。这条读回路径落在两个文件上:src/domobserver.ts 的 DOMObserver 负责发现和归集 DOM 变化,src/domchange.ts 的 readDOMChange 负责把变化区间反推成文档修改。参考代码是 prosemirror-view 的 ca4c78e。

系列目录

日期 标题
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,怎么读回文档(本篇)

为什么让浏览器先改 DOM

ProseMirror 不拦截每一次按键再自己改 DOM。全拦截意味着要自己实现浏览器的全部编辑行为:光标移动、输入法组合、拼写检查、自动纠正,每一项在各浏览器里都有差异。实际策略相反,大部分输入放行,让浏览器直接改 contenteditable 里的 DOM,事后用 MutationObserver 把改动收集起来,解析回文档。少数结构性按键例外,Enter、Backspace 在很多场景下会被 handleKeyDown 拦下来直接发 transaction;readDOMChange 里也留着对应的检测分支,把「看起来像 Enter 的 DOM 变化」还原成按键事件,重新走一遍命令路径。普通文本输入的主体路径是放行加读回。

MutationObserver 的注册与 flush 时机

EditorView 构造时创建 DOMObserver(src/index.ts),回调直接指向 readDOMChange:

this.domObserver = new DOMObserver(this, (from, to, typeOver, added) =>
  readDOMChange(this, from, to, typeOver, added))

start() 以全量选项开始观察 view.dom:childList、characterData、attributes、subtree 全开,并且带上 characterDataOldValue 和 attributeOldValue。oldValue 后面有用,characterData 变化时对比新旧值可以识别「内容没变的虚报」。

MutationObserver 的回调不做任何分析,把 record 全部推进 queue,然后默认立即调 flush()。两个例外走 flushSoon(),即 20 毫秒后 flush:一个是 IE11 删除内容时回调先于 DOM 实际更新的问题,等一拍再读才能读到新值;另一个是 Safari 在表格单元格里结束 composition 时的异常插入,置上 badSafariComposition 标记延后处理。flushSoon 挂起期间再进 record 只进队列,不重复定时;forceFlush() 取消挂起的 flushSoon 立刻 flush。keydown 的处理函数在 keyCode 不等于 229 时会调 forceFlush(src/input.ts),保证上一个事件造成的 DOM 变化在按键处理前已经读回。

stop() 的语义也值得看。它先 takeRecords() 把观察器里还没回调的记录取出来放进 queue,安排 20 毫秒后 flush,然后 disconnect。stop 不丢记录,只是停止接收新记录。start 和 stop 的配对出现在 updateStateInner 里:视图自己写 DOM 之前先 stop,写完再 start。ProseMirror 自己触发的 mutation 因此不会走一遍读回流程,queue 里只装浏览器造成的变化。selectionchange 事件也接在同一个对象上(connectSelection),onSelectionChange 检查焦点和抑制标记后同样汇到 flush()。

flush() 做四件事。第一,pendingRecords() 把 observer.takeRecords() 的余量合入 queue 并清空队列。第二,比较缓存的 currentSelection 和当前 DOM 选区,判断这次要不要顺带读回选区;suppressingSelectionUpdates 是一个 50 毫秒的抑制窗口,给已知的选区抖动场景用。第三,editable 状态下逐条调 registerMutation,把所有记录聚合出一个文档区间:from 取最小、to 取最大、typeOver 求或,同时收集 added 节点。中间夹着两段针对 BR 的清理 hack,退格或删除键(lastKeyCode 为 8 或 46)在 inline-flex 节点旁产生的假 BR、Gecko 成对出现的多余 BR,直接从 DOM 摘掉,不让它们进入后续解析。第四,docView.markDirty(from, to) 把 ViewDesc 树的对应子树标脏,然后调 handleDOMChange,也就是 readDOMChange。顺带还有一个一次性的 checkCSS:编辑器根节点的 white-space 不是 pre-wrap 一类时打一条警告,普通换行模式下的空白折叠会让 DOM 文本和文档对不上,读回路径依赖这个 CSS 前提。readDOMChange 返回后如果 docView 仍是 dirty,说明 DOM 现状和 ViewDesc 对不上,updateState(view.state) 触发一次重画;否则只做选区对齐。

进入 readDOMChange 之前还有一个分支要先排除。有些浏览器在 focus 之后会把 DOM 选区重置到文档开头,flush 里对此有专门的检测:没有内容变化(from 小于 0)、选区刚刚变过、lastFocus 在 200 毫秒以内、最近 300 毫秒内没有触摸和点击、读回的选区折叠并等于文档起点的 Selection.near(doc.resolve(0), 1)。全部命中时判定是误重置,调 selectionToDOM 把 state 里的选区写回 DOM,并 scrollToSelection,这条变化就此消化掉,不进 readDOMChange。

registerMutation:一条记录怎么变成文档区间

registerMutation 处理单条 MutationRecord。先用 docView.nearestDesc(mut.target) 找到变化所属 DOM 对应的 ViewDesc,位置换算都经过这个 desc。三种记录类型各有一套算法:

  • childList:用 previousSibling 和 nextSibling 圈出变化在父节点里的子节点下标区间,从 domIndex(prev) + 1 到 domIndex(next),再交给 desc.localPosFromDOM 换算成文档位置,bias 分别取 -1 和 1。如果变化落在 contentDOM 之外(desc 的 dom 和 contentDOM 不同,且 target 不在 contentDOM 里),按整节点上报 desc.posBefore 到 desc.posAfter。
  • attributes:上报 desc 的整个内容区间,posAtStart - border 到 posAtEnd + border,让属性变化按整节点重解析。
  • characterData:上报 desc 的 posAtStart 到 posAtEnd,并计算 typeOver,即 mut.target.nodeValue == mut.oldValue。有的浏览器会为没有实际改动的文本发记录,新旧值相等时标记 typeOver,readDOMChange 找不到差异时拿它兜底。

上报之前有两道过滤。一道是属性记录的特例:根节点上的属性、contenteditable 的变化、Firefox 对空 style 的虚报,直接丢弃。另一道是 desc.ignoreMutation(mut),NodeView 接管自己 DOM 的入口,最后一节展开。

readDOMChange:用 diff 对齐 DOM 与文档

readDOMChange 收到的是聚合后的 from、to、typeOver 和 added 列表。from 小于 0 表示没有内容变化、只有选区变化,走 selectionFromDOM 读回选区,和 state.selection 比较,不同才 dispatch 一个只改 selection 的 transaction。选区的来源(lastSelectionOrigin)会保留下来:pointer 来的打 pointer meta,键盘来的 scrollIntoView,50 毫秒内的最近来源才生效,更早就按无来源处理。

有内容变化时,先用 $before.sharedDepth(to) 找到两端位置共享的深度,把 from、to 向外扩到 shared + 1 层的节点边界。这一步把「段内几个字符的变化」扩成「整个段落的替换」,保证后面解析的单位是完整节点。

接下来 parseBetween 把变化区间的 DOM 重新解析成文档,复用的是第 10 篇的 DOMParser:parser.parse(parent, {…}) 以变化区间的父元素为输入,topNode、topMatch、topOpen 按 from 让解析器知道自己位于文档的哪个位置;preserveWhitespace 在 whitespace 为 pre 的节点里取 full,其余情况传 true。解析器可以用 someProp(“domParser”) 换成定制的。解析范围来自 docView.parseRange,它把文档区间换算成父元素里的子节点下标区间;Chrome 下退格有时会在删除位置留下一个没有 ViewDesc 的 BR,这里有一段从后往前扫描的修正,把这种 BR 排除在解析范围之外。findPositions 把当前 DOM 选区的 anchor 和 focus 带进去,解析完能拿到选区在新内容里的文档位置。ruleFromNode 回调让每个 ViewDesc 自己出解析规则(desc.parseRule()),widget 装饰返回 ignore,段落末尾的 BR 按惯例忽略,视图层自己留在 DOM 里的这些节点不干扰解析。

解析出 parse.doc 之后,对比对象是现有文档切出的 compare = doc.slice(parse.from, parse.to)。两者求差用的就是第 11 篇的 findDiffStart / findDiffEnd,包在 findDiff 函数里:先 findDiffStart 从头找第一个不同的位置 start,再 findDiffEnd 从尾往回找,得到 endA(旧内容里的终点)和 endB(新内容里的终点)。这里有两处针对文本输入的修正。一是 preferredSide,Backspace 之后 100 毫秒内偏好把差异锚在末尾,因为退格删的是光标前面的内容。二是两端交叉的情况:插入一段结尾和已有内容相同的文本时,findDiffEnd 可能回到 start 之前(endA < start),这时按 preferredPos(选区位置)判断差异算在哪一侧,再用 isSurrogatePair 检查边界,避免把差异起点劈在一个代理对中间。

findDiff 返回 null 时,说明 DOM 和文档内容其实一致。typeOver 为真且选区是同段内的 TextSelection 时,按「输入了和选区内容相同的文本」处理,构造一个覆盖选区的空变化;否则只修选区就返回。

diff 求出的 change 还有一个「扩回选区」的修正。选中一段文本再输入,如果输入内容恰好以选中文本的开头或结尾几个字符打头,diff 会把变化算小,只报没匹配上的那一截。readDOMChange 检查这种情况:diff 报的是纯插入(change.start 等于 change.endB)且选区是 TextSelection 时,change.start 落在选区起点之后两个字符以内,就把 start 拉回到 selection.from;endA 落在选区终点之前两个字符以内,就把变化扩到 selection.to。没有这个修正,覆盖输入会残留一两个旧字符。

拿到 change(start、endA、endB 三元组)后还有一轮语义识别。整段结构变化看起来像按了 Enter 时(非 inline 变化、两端不在同一 inline 父节点、中间没有非空白字符),放弃这个 change,改成重新派发 handleKeyDown 的 Enter 走命令路径;looksLikeBackspace 对「块尾删除导致块合并」这类变化做同样的事。这两条分支让 keymap 和 inputrules 对浏览器自己完成的结构操作仍然生效,beforeinput 支持不完整的浏览器也能走通回车和退格的命令路径。

最后按变化的形状构造 transaction。inlineChange(两端在同一 inline 父节点内,且旧文档一侧能覆盖到 endA)再细分四种:纯删除用 tr.delete,并 ensureMarks 保住 marksAcross 算出的格式,删掉带格式的文本后再输入时不至于丢样式;内容长度不变、只差一个 mark 时由 isMarkChange 判定,走 addMark 或 removeMark,拼写检查和自动纠正造成的下划线增删主要走这条路;两端落在同一个文本节点里时直接 insertText,派发前先问 handleTextInput,inputrules 就挂在这个钩子上;其余情况用通用的 tr.replace(chFrom, chTo, parse.doc.slice(…)),新内容从解析结果里按 change.start - parse.from 到 change.endB - parse.from 切出来。选区经 parse.sel 恢复,恢复前还有一层浏览器对策:Chrome 在 composition 期间可能把选区报在错误位置,Edge 在空块或 BR 之间输入后光标不前进,这两类情况识别出来就不写选区。scrollIntoView 带上,composition 期间再给 transaction 打上 composition meta。

DOM 变化的读回路径

composition 期间的抑制

中文输入法的组合期是这条路径上最敏感的场景,第 31 篇会专门讲,这里只交代和 observer 相关的安排。compositionstart 的处理函数做的第一件事是 domObserver.flush(),把组合开始前积压的记录先读干净,然后把 input.composing 置 true。组合期间 MutationObserver 照常回调,readDOMChange 照常跑,生成的 transaction 会带上 composition meta,值是一个递增的 compositionID,插件可以据此识别同一轮组合产生的多次修改。readDOMChange 开头会先取这个 ID:compositionPendingChanges 有值就用它,否则组合进行中用当前 compositionID,取完把 compositionPendingChanges 清零。

被抑制的是视图层的重画。updateStateInner 在 composing 状态下更新文档时,先用 findCompositionNode 找到输入法正在使用的文本节点,ViewDesc 树里对应位置换成 CompositionViewDesc 占位,后续 update 跳过这块 DOM,不把输入法正在协商的内容重画掉。compositionend 时再清算:pendingRecords() 里还有积压记录,就把 compositionPendingChanges 记成当前 compositionID,flush 推迟一个 microtask 执行(Promise.resolve().then(…)),等组合结束、DOM 稳定后再读回。Safari 在表格里结束组合时的乱序插入是特例,observer 回调检测到 TR 上的 childList 记录会置 badSafariComposition 并走 flushSoon,compositionend 对它做 forceFlush,再由 fixUpBadSafariComposition 把插错位置的节点挪回单元格。

ignoreMutation 惯例

读回路径的前提是「DOM 变化来自浏览器编辑」。view 层还有另一类 DOM 变化来自 NodeView 和 widget 自己的 DOM 操作,比如代码块 NodeView 内部的高亮重绘。这类变化不应该读回文档,过滤点就是 ignoreMutation。

它在 ViewDesc 基类上的默认实现只有一行:

ignoreMutation(mutation: ViewMutationRecord): boolean {
  return !this.contentDOM && mutation.type != "selection"
}

没有 contentDOM 的 desc(叶节点、widget)默认忽略一切内容变化,因为它们的 DOM 不给用户编辑。各子类在这个基础上加码:WidgetViewDesc 忽略所有非 selection 变化,selection 是否忽略看 widget 的 spec.ignoreSelection;CompositionViewDesc 忽略新旧值相等的 characterData,即组合期常见的内容没变、只刷新选区的虚报;CustomNodeViewDesc 和 CustomMarkViewDesc 把判定转发给 NodeView spec 的 ignoreMutation,把「我的 DOM 里哪些变化不算文档变化」的决定权交给 NodeView 作者。registerMutation 里 desc.ignoreMutation(mut) 返回 true 的记录不上报,聚合出的区间就不会覆盖这块 DOM。

选区变化走同一个约定。DOMObserver.ignoreSelectionChange 会构造一个 {type: “selection”, target} 的假记录去问 container 所属的 desc,NodeView 可以用同一个 ignoreMutation 实现同时管住内容读回和选区读回。编辑中的嵌入组件想自己持有焦点时,对 selection 类型也返回 true,框架就不再把选区写回 state。

小结

读回路径的全貌:浏览器改 DOM,MutationObserver 把记录推进 queue,flush 聚合成文档区间,markDirty 标脏 ViewDesc,readDOMChange 用 DOMParser 重解析变化区间、用 findDiffStart 和 findDiffEnd 与旧文档求差,再按差异形状生成 replace、insertText 或 addMark 的 transaction,交回 dispatchTransaction。自己写 DOM 时用 stop 和 start 把观察器隔开,NodeView 用 ignoreMutation 声明自治区域,输入法组合期用 CompositionViewDesc 保护正在组合的 DOM。view 层的双向同步到这里讲完了,下一篇看事件方向:input.ts 里 keydown、beforeinput 这些事件怎么进编辑器,editHandlers 的约定,以及 captureKeys 的按键过滤。


1163 字 · 38 段落
xi ming

Written by xi ming You should follow him on Github