ViewDesc(下):增量更新怎么做到只改动的部分

3 分钟阅读
·

上一篇看了 viewdesc.ts 里描述树的静态结构:ViewDesc 类族的分工、children 与文档树的对应、docViewDesc 的构建。树建好以后,编辑器日常做的事是更新它:每敲一个字、每加一次粗体,DOM 都要跟上新文档。全量重建最省事,整段 innerHTML 换掉就行,代价是光标丢失、输入法组合中断、自定义 node view 的内部状态清空。这篇看增量更新怎么绕开这些代价:入口处的两道判定、dirty 标记的四个级别、updateChildren 的四种放置策略、装饰在什么位置注入,以及文本节点的就地更新。参考代码是 prosemirror-view 的 ca4c78e,主要对象是 src/viewdesc.ts,入口在 src/index.ts

系列目录

日期 标题
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(下):增量更新怎么做到只改动的部分(本篇)

入口的两道判定

更新从 updateStateInnersrc/index.ts)开始。上一篇提过它的完整路径,这里只取和文档相关的部分。第一步判断要不要碰 DOM:

let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco)

判定里的 outerDeco 和 innerDeco 来自插件:updateStateInner 先用 viewDecorations(this)computeDocDeco(this) 从各插件的 decorations prop 算出这两份装饰,再参与比对。装饰变了和文档变了一样会触发更新。matchesNode 在 NodeViewDesc 上的实现(src/viewdesc.ts)要求四个条件同时成立:dirty 是 NOT_DIRTY、node.eq(this.node)(新文档节点和 desc 持有的节点完全相等)、外层装饰数组逐项相等、内层装饰源相等。文档不可变,一次输入会产生新的文档对象,顶层的 node.eq 必然不成立,所以文档动过就一定进入更新分支。反过来,只是移动选区,或者插件状态变了而文档没动,整棵 DOM 子树在这道判定上就被跳过,一次 DOM 读写都不发生。

第二步调 this.docView.update(state.doc, outerDeco, innerDeco, this)。NodeViewDesc.update 只有两个失败条件:this.dirty == NODE_DIRTY,或 !node.sameMarkup(this.node)。sameMarkup 比 eq 宽松,只比较节点类型、attrs 和 marks,不看内容。顶层 doc 节点的类型永远相同,正常情况下 update 必定返回 true。返回 false 时才走全量重建:先 updateOuterDeco 刷新外层装饰,再 destroy() 整棵 desc 树,最后 docViewDesc(...) 从文档重新构建。全量重建在这套代码里是兜底分支,常规路径进不去。

update 成功后进入 updateInner:刷新 outerDeco、换掉持有的 node 和 innerDeco,有 contentDOM 就调 updateChildren,最后把 dirty 归回 NOT_DIRTY。增量更新的主要工作都在 updateChildren 里。

dirty 标记:另一条更新来源

updateChildren 复用子树的前提是知道哪些子树可信,dirty 字段记录的就是这个。src/viewdesc.ts 开头定义了四个级别:

const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3

NOT_DIRTY 表示 desc 和文档、DOM 完全一致;CHILD_DIRTY 表示子孙里有脏的,自身的内容列表还可信;CONTENT_DIRTY 表示直接内容需要重新对齐;NODE_DIRTY 表示这个节点对应的 DOM 结构已经不可信,不能复用,必须重建。

多数脏标记在浏览器改动 DOM 时产生,和 transaction 无关。用户直接在 contenteditable 里输入时,浏览器先改了 DOM,MutationObserver 攒下一批变更,domobserver.ts 的 flush 算出变更覆盖的文档区间,调 view.docView.markDirty(from, to),然后读回变更、派发 transaction。transaction 应用完,flush 末尾检查 view.docView.dirty,非零就再跑一轮 view.updateState(view.state),把脏的子树按文档重画。

markDirty 的递归(ViewDesc 基类)按区间下钻。脏区间完整落在某个 child 的内容区间里时,先给自己标一级:脏区间沾到 child 的外边界(from 或 to 贴着 child 两端)标 CONTENT_DIRTY,否则标 CHILD_DIRTY。接着看 child:脏区间正好覆盖 child 的全部内容,且 child 的 contentDOM 已丢失(contentLost)或 dom 已脱离父 contentDOM,说明浏览器把这段 DOM 的结构改坏了,child 直接标 NODE_DIRTY,等下次更新重建;否则把区间换算成 child 的局部坐标递归进去。脏区间只擦到 child 的边、没完整落进去时,child 视自身结构标 CONTENT_DIRTY 或 NODE_DIRTY,不再下钻。整个循环走完,当前 desc 自己标 CONTENT_DIRTY。

两处补充。一是 MarkViewDesc.markDirty 会把脏信息上移:mark desc 自己不驱动重画,重画以节点为单位,所以它把 dirty 转给最近的 node view 祖先,自己归回 NOT_DIRTY。二是组合输入(IME)结束时,input.ts 对 compositionNodes 逐个调 markParentsDirty,沿父链标 CONTENT_DIRTY 和 CHILD_DIRTY,让 composition 期间被浏览器临时改写的 DOM 在下一轮更新中被文档内容盖掉。IME 的细节留给后面的篇章,这里只需记住 dirty 的来源不止 domObserver 一处。

updateChildren 与四种放置策略

NodeViewDesc.updateChildren 做两件事:先用 iterDeco 遍历当前节点的内容和新装饰,借 ViewTreeUpdater 把 children 数组同步成新文档的形状;再看有没有变化,有就调 renderDescs 把 DOM 对齐到 children。

ViewTreeUpdater 持有一个游标 index、一个 mark desc 栈、一个 changed 标记和一份 preMatch。对每个要放置的文档节点,按顺序试四种策略:

  1. findNodeMatch:在已有 children 里找一个和新节点完全匹配的 desc。优先查 preMatch 登记的结果,查不到就从 index 往后扫,最多看 5 个。找到就收下这个 desc,中间的旧 desc 销毁。这是「节点没动」的快路径。
  2. composition 特判:正在组合输入且选区落在这个 child 里时,尝试 updateNodeAt 更新持有 composition 文本节点的那个 desc。常规输入不走这条。updater 构造时会把 composition 的文本节点存为 lock,updateNextNode 遇到 isLocked 命中的 desc 直接跳过(文本内容已与文档一致且装饰相同的除外),防止增量更新把输入法正在写的那段 DOM 改掉。
  3. updateNextNode:看 index 处的下一个 NodeViewDesc 能不能通过 update 变成新节点,能就就地更新;不能就试 recreateWrapper;再不行返回 false。update 的成功条件前面说过:非 NODE_DIRTY 且 sameMarkup。「类型和 attrs 没变、内容变了」的节点走这条路径,比如段落里多出一个字。
  4. addNode:前面都失败,NodeViewDesc.create 新建 desc 插进 children。

遍历结束后 destroyRest 清掉游标后面剩余的旧 desc。widget 不走这四种策略,onWidget 回调直接调 placeWidget:index 处的 desc 能通过 matchesWidget 就复用,否则新建 WidgetViewDesc 插入。

preMatch 容易被略过,作用很实际。它在 updater 构造时执行,从 fragment 末尾和 children 末尾同时反向扫描,把引用相等的节点配成对,存进 matched 映射。场景是在文档头部插入内容:不可变数据的结构共享让新文档的尾部和旧文档的尾部引用同一批节点,preMatch 先把它们登记住。正向扫描到中途时,findNodeMatch 可以按 fragment 下标直接找到登记过的 desc;updateNextNode 也会检查目标 desc 是否已被登记给别的位置(登记的 fragment 下标和当前下标不符就直接放弃)。没有这层登记,正向扫描可能把尾部某个 desc 误配给前面同类型同内容的节点,造成不必要的重建。

recreateWrapper 处理一种特定情况:节点类型变了但内容完全没变,比如段落切成标题。条件列得很全:旧 desc 干净、新节点不是 atom、旧 children 非空、content 相等、内外装饰一致。满足时新建一个外壳 desc,把旧 children 整个搬过去(逐个改 parent 指针),旧外壳销毁。标题的 DOM 是新壳,里面的文本 DOM 一个没动。

matchesX:复用判定的分工

四种策略背后是一套 matchesX 判定。基类 ViewDesc 上四个方法全部返回 false,各子类按需覆盖:

  • NodeViewDesc.matchesNode:要求 NOT_DIRTY,节点 eq、外层装饰和内层装饰都相等。findNodeMatch 的精确复用靠它。
  • MarkViewDesc.matchesMark:要求 dirty 不等于 NODE_DIRTY 且 mark.eq。它比 matchesNode 宽松一档:CONTENT_DIRTY 或 CHILD_DIRTY 的 mark 外壳照样复用,因为 mark 的内容本来就要重画,外壳(比如那个 strong 元素)没坏就不用换。
  • WidgetViewDesc.matchesWidget:要求 NOT_DIRTY 且 widget 类型 eq。widget 没有「内容变了」的中间态,类型不同就整个重建。
  • TrailingHackViewDesc.matchesHack:要求 NOT_DIRTY 且 dom.nodeName 相同。hack 节点是 addTextblockHacks 在 textblock 末尾补的 BR 或 IMG(空段落、文本以换行结尾等情况),用来让 contenteditable 下的光标有处可去,和文档内容无关,同名就能留。

共同点是把 dirty 放进判定:脏的 desc 不可信,匹配直接失败,更新逻辑不会去复用一个状态不明的子树。dirty 标记和 matchesX 是同一套约定的两半:前者标出哪些子树不可信,后者在复用前查证。

iterDeco:装饰在什么位置注入

updateChildren 遍历的序列由 iterDeco(src/viewdesc.ts)加工:iterDeco 把 fragment 和装饰源揉在一起,通过两个回调交出结果,onWidget 给 widget 装饰,onNode 给文档节点。

没有局部装饰时走便宜路径:逐个 child 调 onNode,附上空的外层装饰和 deco.forChild(offset, child) 算出的内层装饰。有局部装饰时进入完整逻辑,三件事值得拆开看。

第一,widget 的位置。循环里先收集 to == offset 的 widget(widget 的 from 和 to 相同,贴在某个位置上),同一位置有多个时按 side 排序,再依次调 onWidget。这决定 widget desc 在 children 里的插入位置:在 offset 对应的那个子节点之前(或在末尾),和文档节点平级。

第二,文本节点的切分。装饰的起点或终点落在文本节点内部时,文本被 cut 成两段,前半段本轮处理,后半段存进 restNode 下一轮继续。这样每个文本 desc 对应的 DOM 文本节点不会被装饰区间斜穿,行内装饰(比如评论高亮)的边界总是落在文本节点边界上。高亮的 span 因此能包住完整的一段文本,DOM 结构保持整齐。

第三,外层装饰的过滤。outerDeco 对 inline 非叶子节点会滤掉 inline 类装饰,其余节点拿全部进行中的装饰。onNode 回调里,updater 先用 syncToMarks 把 mark desc 栈对齐到新节点的 marks(前缀相同的 mark desc 保留,多余的弹栈销毁,缺的查找或新建),再放节点本身。装饰就是这样被翻译成 desc 树里的 widget desc、mark desc 和外层包裹属性的。

文本节点的就地更新

最高频的更新路径值得单独看。段落里插入一个字符,最终走到 TextViewDesc.update(src/viewdesc.ts):

if (this.dirty == NODE_DIRTY || (this.dirty != NOT_DIRTY && !this.inParent()) ||
    !node.sameMarkup(this.node)) return false
this.updateOuterDeco(outerDeco)
if ((this.dirty != NOT_DIRTY || node.text != this.node.text) && node.text != this.nodeDOM.nodeValue) {
  this.nodeDOM.nodeValue = node.text!
  if (view.trackWrites == this.nodeDOM) view.trackWrites = null
}
this.node = node
this.dirty = NOT_DIRTY
return true

核心操作只有一行:this.nodeDOM.nodeValue = node.text。给 DOM 文本节点的 nodeValue 赋值,浏览器原地更新文字,节点本身不替换。这是整个增量更新里成本最低的写法,也是光标保持的关键:浏览器对「文本节点的值变了」这类变更能维持选区,对「节点被替换」则不能。

失败条件也值得读。NODE_DIRTY 直接放弃;脏了的文本节点还要求 inParent(nodeDOM 还挂在父 contentDOM 上)才允许就地更新,防止修一个已经被浏览器挪走的节点;sameMarkup 不通过说明 marks 变了,外层包裹结构要重排,交给上层处理。中间的 node.text != this.nodeDOM.nodeValue 是个防御:DOM 里的文字可能已经和文档一致(普通输入时字符是浏览器写进去的,读回再派发 transaction 之后两者已经相等),相同就不再赋值。TextViewDesc.markDirty 另有一条升级规则:文本节点外面包着装饰层(dom 不等于 nodeDOM)且脏区间沾到文本两端时标 NODE_DIRTY,因为两端的变更可能意味着装饰包裹结构被破坏了,就地改文本救不回来。

trackWrites 那行对应 index.ts 里处理的 Chrome 选区问题:更新前记录选区焦点所在节点,更新后检查它有没有被写过,写过就强制重置选区。细节属于选区同步,后面的篇章再展开。

renderDescs:最后一道对齐

updateChildren 的收尾:

if (updater.changed || this.dirty == CONTENT_DIRTY) {
  if (localComposition) this.protectLocalComposition(view, localComposition)
  renderDescs(this.contentDOM!, this.children, view)
  if (browser.ios) iosHacks(this.dom as HTMLElement)
}

children 数组此时已经是新文档的形状,renderDescs 负责让 DOM 跟上。它用一个 dom 指针顺着 contentDOM 的子节点走,对 children 里的每个 desc:desc.dom 已经在 contentDOM 下,就删掉指针和 desc.dom 之间多余的节点,指针前移;不在就 insertBefore 插进去。遇到 MarkViewDesc 递归进它的 contentDOM 对齐 mark 的子树。循环结束后指针后面还有剩余节点,全部删掉。删除统一走 rm 这个两行的小函数:记下 nextSibling、removeChild、返回 nextSibling,指针因此能一路向前不回退。另外 renderDescs 在真正动过 DOM(written 为真)且 trackWrites 跟踪的正是这个父节点时会清掉 trackWrites,配合前面说的 Chrome 选区处理。没有任何变化时(changed 为假且 dirty 不是 CONTENT_DIRTY)renderDescs 不会被调,DOM 零读写。

到这里可以把一次修改的完整路径串起来:

一次输入触发的增量更新路径

以 transaction 驱动的修改为例(浏览器直接输入时,前面还挂着 domObserver 标脏和读回的一段,下一篇展开):updateStateInner 里 matchesNode 不通过,docView.update 递归到发生变化的段落;updateChildren 用 iterDeco 遍历,没变的兄弟节点被 findNodeMatch 和 preMatch 直接跳过;内容变了的文本节点走 updateNextNode 进 TextViewDesc.update,一次 nodeValue 赋值收尾;renderDescs 扫一遍确认 DOM 无需增删。整次更新对 DOM 的写入只有那一次文本赋值。

小结

增量更新的设计可以归成三层。复用层:matchesNode、matchesMark、matchesWidget、matchesHack 四个判定加上 preMatch,保证没变的子树原样保留,判定全部以 dirty 为前提。策略层:updateChildren 里 findNodeMatch、composition 特判、updateNextNode、addNode 四种放置策略按序兜底,recreateWrapper 挂在 updateNextNode 内部,处理「换了类型、内容没变」的情况。写入层:文本走 nodeValue 就地赋值,结构走 renderDescs 的最小增删,装饰由 iterDeco 在正确的位置注入。dirty 四级标记贯穿三层,把「浏览器改了 DOM」和「文档变了」两个方向的更新统一到同一套重画逻辑里。

下一篇看反向的那条路:浏览器改了 DOM 之后,domObserver 和 domchange.ts 怎么把变更读回成文档修改,readDOMChange 又怎么和第 11 篇的 findDiffStart / findDiffEnd 配合。


1076 字 · 51 段落
xi ming

Written by xi mingFollow onGitHub