上一篇介绍了 viewdesc.ts 中描述树的静态结构:ViewDesc 类族的职责、children 与文档树的对应以及 docViewDesc 的构建。文档变化后,DOM 需要更新以反映新文档。全量替换 innerHTML 会丢失光标和输入法组合状态,也会清空自定义 node view 的内部状态。本文分析增量更新的实现:入口处的两个判定、dirty 标记的四个级别、updateChildren 的四种放置策略、装饰的注入位置和文本节点的就地更新。参考代码是 prosemirror-view 的 ca4c78e,主要对象是 src/viewdesc.ts,入口在 src/index.ts。
系列目录
入口的两道判定
更新从 updateStateInner(src/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。update 返回 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 = 3NOT_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),以文档内容更新对应子树。
ViewDesc 基类的 markDirty 按区间递归到子节点。脏区间完全落入某个 child 的内容区间时,脏区间接触 child 外边界(from 或 to 位于 child 两端)则将当前 desc 标为 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。对每个要放置的文档节点,按顺序试四种策略:
- findNodeMatch:在已有 children 里找一个和新节点完全匹配的 desc。优先查 preMatch 登记的结果,查不到就从 index 往后扫,最多看 5 个。找到就收下这个 desc,中间的旧 desc 销毁。这是「节点没动」的快路径。
- composition 特判:正在组合输入且选区落在这个 child 里时,尝试 updateNodeAt 更新持有 composition 文本节点的那个 desc。常规输入不走这条。updater 构造时会把 composition 的文本节点存为 lock,updateNextNode 遇到 isLocked 命中的 desc 直接跳过(文本内容已与文档一致且装饰相同的除外),防止增量更新把输入法正在写的那段 DOM 改掉。
- updateNextNode:看 index 处的下一个 NodeViewDesc 能不能通过 update 变成新节点,能就就地更新;不能就试 recreateWrapper;再不行返回 false。update 的成功条件前面说过:非 NODE_DIRTY 且 sameMarkup。「类型和 attrs 没变、内容变了」的节点走这条路径,比如段落里多出一个字。
- 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 指针,再销毁旧外层 desc。标题的 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 的子节点: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 驱动的修改会经过以下路径:
以 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 用于处理节点类型变化而内容不变的情况。文本通过 nodeValue 原地更新,结构由 renderDescs 执行必要的增删,iterDeco 在对应位置注入装饰。dirty 的四个级别使浏览器 DOM 变更和文档变更共用同一套重绘逻辑。
下一篇分析浏览器修改 DOM 后的反向路径:domObserver 与 domchange.ts 如何将变更读回为文档修改,以及 readDOMChange 如何配合第 11 篇的 findDiffStart / findDiffEnd。
