ViewDesc(上):文档到 DOM 的描述树

3 分钟阅读
·

上一篇看 EditorView 构造函数时,第 4 步调了 docViewDesc(...),把当前文档渲染进 this.dom,产物存成 this.docView。当时一句话带过,这篇把它打开。docView 是一棵 ViewDesc 描述树,定义在 prosemirror-view/src/viewdesc.ts,连注释一共一千五百多行,view 层的渲染、选区、坐标换算、DOM 读回全都建立在它上面。内容多,拆成两篇:这篇只看静态结构,包括类族怎么分工、children 数组怎么对应文档树、顶层 docView 怎么建出来、每个 desc 上三个 DOM 引用的关系;增量更新的半边(dirty 标记、复用判定、ViewTreeUpdater)留给下一篇。参考代码是 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 的描述树(本篇)

为什么需要 ViewDesc 这一层

文件顶部有一段注释,直接列出 ViewDesc 的三个用途:文档变化时做增量重绘;给定一个 DOM 位置,算出它对应文档里的哪个位置;把自定义节点渲染(node view)接进树里。注释还交代了树的形态:一棵双向链接的可变树,根是 view.docView

这三个用途解释了这层数据结构存在的理由。文档是不可变数据,DOM 是浏览器维护的可变状态。每次 transaction 之后如果把整棵 DOM 推倒重建,成本是一个问题,更麻烦的是焦点、选区、输入法组合状态都挂在 DOM 上,重建一次丢一次。反过来,抛弃结构信息直接在 DOM 上做 diff,又没法回答「这个 DOM 节点对应文档哪个位置」。ViewDesc 在两者之间放一棵中间树:每个节点记住自己对应的文档节点、DOM 元素和子 desc 数组,更新时按结构对比着复用,换算位置时沿树走。后面会看到,view 层几乎所有功能都是在这棵树上做的查询或修改。

基类 ViewDesc 的字段

所有描述节点的基类是 ViewDesc,构造函数四个参数:parent(父 desc)、children(子 desc 数组)、dom(这个 desc 对应的 DOM 节点)、contentDOM(子 desc 的 DOM 挂载点,没有内容的 desc 传 null)。构造函数里有一行容易漏看的赋值:

dom.pmViewDesc = this

它在 DOM 节点上挂了一个 expando 属性指回 desc。这条规定了双向链接的一半:从 desc 到 DOM 靠 dom 字段,从 DOM 到 desc 靠 pmViewDescposFromDOM、事件处理、DOM 读回,凡是手里只有 DOM 节点想找结构的代码,都从这个属性起步。destroy 时对应地把它清掉,并递归销毁子树。

基类上还有一组位置相关的 getter。size 默认把 children 的 size 加起来;border 默认 0,它表示节点开始和结束 token 占的位置数;posBeforeChild(child)posAtStart 开始累加前面兄弟的 size;posAtStartposBeforeposAfterposAtEnd 全都沿 parent 链求和推出。这套计数和 model 层 ResolvedPos 的 token 约定完全一致:ViewDesc 树不接外来的位置信息,每个 desc 的位置都是临时算出来的。文档变了、children 变了,位置跟着变,不需要维护一份会过期的缓存。

matchesWidgetmatchesMarkmatchesNodematchesHack 四个方法在基类上都返回 false,由子类各自实现。它们是更新时「这个旧 desc 能不能拿来渲染新数据」的判定接口,下一篇讲增量更新时会逐个用到。parseRule 默认返回 null,DOM 读回解析时每个 desc 可以给出自己的解析规则,比如 TrailingHackViewDesc 返回 {ignore: true} 让解析器跳过补丁节点。stopEvent 默认 false,事件分发时用来询问某个 desc 要不要拦截冒泡上来的事件。ignoreMutation 的默认实现是一条经验规则:没有 contentDOM 的 desc 自己管自己的内容,非选区类的 DOM 变更都可以忽略。

基类上还有一个 dirty 字段,取值是文件顶部定义的四个常量:NOT_DIRTYCHILD_DIRTYCONTENT_DIRTYNODE_DIRTY。这一篇只需要知道它存在:首次构建时所有 desc 都是 NOT_DIRTY,脏标记怎么打、怎么消费是更新半边的事。

类族分工

ViewDesc 类族

NodeViewDesc 是数量最多的子类,对应一个文档节点,持有 nodeouterDeco(节点外侧的装饰)、innerDeco(节点内部的装饰源)、nodeDOM(节点自己的 DOM 元素)。它覆写了 size 返回 node.nodeSize,覆写 border 返回 this.node.isLeaf ? 0 : 1,非叶节点前后各占一个位置,与 model 层的约定对齐。

TextViewDesc 继承 NodeViewDesc,对应文本节点。它的 dom 就是 DOM 文本节点,contentDOM 是 null。它额外实现了 isText(text),返回自己保存的文本是否等于给定字符串,更新器靠它在一堆 desc 里认出文本节点;inParent 检查自己的 DOM 文本节点是否还在父 desc 的 contentDOM 之下,浏览器把节点挪走时这个判定会失败。CustomNodeViewDesc 也继承 NodeViewDesc,在用户通过 nodeViews 提供了自定义渲染时使用,多持有一个 spec(用户返回的 node view 对象),updateselectNodestopEvent 这些方法都先问 spec 有没有自己的实现。文件里有两处注释交代这里的取舍:CustomNodeViewDesc 上方的注释说,单独开子类是为了让额外的检查只发生在真正被定制的节点上,普通节点不用付这个成本;NodeViewDesc.create 上方的注释则从另一侧说,用户侧的接口故意不采用继承,免得把一堆内部实现细节暴露给用户代码。

MarkViewDesc 对应一个 mark,持有 mark 字段。它的注释里有一条重要约定:mark 按固定嵌套顺序绘制,为了简单和可预期,某些情况下拆得比看上去需要的更多。两个相邻文本节点如果 marks 数组相同,可以共用一个 MarkViewDesc;marks 不同,即使只是内层差一个,也要拆开。

WidgetViewDesc 对应 widget 装饰,children 为空,size 是 0。构造函数里处理了 toDOM 的返回值:不是元素节点就包一层 span,然后设 contentEditable = false、加 ProseMirror-widget class;spec.raw 为真的 widget 跳过这层包装。TrailingHackViewDesc 是 contenteditable 怪癖的补丁节点(空 textblock 结尾的 BR、Safari 和 Chrome 需要的 IMG),同样 size 为 0,parseRule 返回 {ignore: true} 让解析时跳过它。CompositionViewDesc 用在输入法组合期间,包住焦点处的文本节点防止重绘把它毁掉,size 是文本长度。后三个类的共同点是:文档树里没有对应物,它们是 ViewDesc 树多出来的节点。

children 与文档树的对应

块级节点的对应关系很直接:NodeViewDesc 的 children 按顺序对应 node.content 的子节点,一个段落里三个 inline 节点就是三个孩子。

内联内容带 mark 时多一层。文档模型里 mark 不进树,挂在 inline 节点的 marks 数组上;ViewDesc 树里它是真实的一层,MarkViewDesc 包着 TextViewDesc。这是两棵树形状不一致的第一处。第二处是 widget:它作为 size 0 的孩子插在某个偏移处,文档里没有它。第三处是 TrailingHackViewDesc,同样是纯 DOM 侧的补丁。

所以 children 数组的内容是「文档内容 + 装饰 + 补丁」三路的合并结果。位置换算之所以不被这些额外节点搞乱,靠的就是 size 约定:widget 和 hack 占 0 个位置,posBeforeChild 累加时它们不产生偏移,文档 pos 在 ViewDesc 树上保持连续。

dom、contentDOM、nodeDOM 三个引用

三个 DOM 引用的关系

每个 desc 至少持有 domcontentDOM 两个引用,NodeViewDesc 再多一个 nodeDOM。三者的分工:

  • dom 是这个 desc 的最外层 DOM 节点,pmViewDesc 挂在它上面,位置换算、事件归属判断都以它为准。
  • contentDOM 是子 desc 的挂载容器。段落 desc 的 contentDOM 就是那个 <p> 元素,孩子的 DOM 都插在它里面。叶子 desc 的 contentDOM 是 null。
  • nodeDOM 是节点自己的元素,不含外层包装。

没有装饰时三者指向同一个元素。差别出现在 node 类型的装饰上:computeOuterDeco 把装饰的 attrs 整理成一层层的属性表,patchOuterDeco 遇到带 nodeName 的装饰会在 nodeDOM 外面新建包裹元素,并标上 pmIsDeco 记号。这时 desc.dom 指向最外层包裹,nodeDOM 仍然是节点自己的元素。选中原生节点时 class 加在 nodeDOM 上(selectNode),避免和装饰包裹层混在一起。

顶层的 docView 是特例:docViewDesc 创建它时 dom、contentDOM、nodeDOM 传的都是编辑器的最外层 div,三个引用合一。

还有一个边界情况和 contentDOM 有关。NodeViewDesc.create 里,非文本、非 BR 的节点如果没有 contentDOM,说明它是自绘内容的叶子,create 会给它的 dom 设 contentEditable = false 防止光标走进去,节点类型 spec 标了 draggable 的再加上 draggable = true。另外 contentLost 这个 getter 检查 contentDOM 是否脱离了 dom:Chrome 在退格时偶尔会重建父元素,把内容搬到新父节点里,contentDOM 就丢了,解析和重绘都靠这个标记识别这种情况。

沿树的位置换算

ViewDesc 树上最高频的两类查询是 pos 到 DOM、DOM 到 pos 的双向换算,入口都在基类上,选区同步和坐标换算(后面的篇目)完全建立在它们上面。

posFromDOM(dom, offset, bias) 把 DOM 位置翻译成文档位置。它从给定的 DOM 节点沿 parentNode 向上扫,找到第一个带 pmViewDesc 且属于这棵树的祖先,然后调它的 localPosFromDOM。后者分两种情况:DOM 位置落在 contentDOM 里时,按 bias 的正负找位置之前或之后的兄弟 desc,用 posBeforeChild 累加出文档位置;落在外层包装或装饰层里时,走一组启发式分支判断该返回 desc 的起点还是终点,判断不了就用 bias 兜底。bias 是调用方给的倾向,处理的是 DOM 位置的天然歧义:两个文本节点的边界、元素开头和结尾,同一个 DOM 位置可能对应两个文档位置。nearestDesc(dom, onlyNodes) 是同一方向的轻量版,只找 desc 不算位置,事件分发用它判断事件来自哪个节点,onlyNodes 为真时跳过没有对应文档节点的 desc(比如 mark 和 widget)。

反方向是 domFromPos(pos, side):先在 children 里按 size 累加,定位 pos 落在第几个孩子;落在孩子内部就递归下去;落在孩子边界上,则按 side 的正负向前或向后找第一个可用的 DOM 节点,返回 {node, offset},offset 是 DOM 层的子节点下标,靠 domIndexprosemirror-view/src/dom.ts)换算。实现里有一段回退循环,专门处理 side 大于等于 0 的零宽 widget:如果目标位置前面有这样的 widget,要继续往前退,把 DOM 位置放到 widget 之前,这是 widget 的 side 语义在 DOM 层的落实。

第三个查询 descAt(pos) 返回位于给定位置之后的那个 desc,如果位置落在某个孩子的边界上还会尽量下钻到最内层。EditorView 对外的方法(比如按 pos 找 DOM 节点)靠它定位。这三个函数加上前面讲的 size、border 约定,构成了「文档位置 ↔ DOM 位置」的完整换算通道,中间不需要任何额外的映射表。

docViewDesc 的构建路径

回到 EditorView 构造时那一行调用,看 docViewDesc(doc, outerDeco, innerDeco, dom, view) 本体,它只做三件事:

applyOuterDeco(dom, outerDeco, doc)
let docView = new NodeViewDesc(undefined, doc, outerDeco, innerDeco, dom, dom, dom, view, 0)
if (docView.contentDOM) docView.updateChildren(view, 0)

先给编辑器外层 div 应用 doc 级装饰,然后以这个 div 为 dom 建一个 parent 为 undefined 的 NodeViewDesc,最后 updateChildren 填充子树。updateChildren 是首次构建和后续增量更新共用的入口,首次构建时 children 是空数组,所有分支自然落到「新建」上。

新建走 NodeViewDesc.create 这个工厂方法,它的决策顺序是:先查 view.nodeViews[node.type.name] 有没有自定义;文本节点没有自定义就 document.createTextNode,自定义返回的 dom 必须是文本节点,否则抛 RangeError;非文本节点没有自定义就用 DOMSerializer.renderSpec 渲染节点 spec 的 toDOM 规格(这套规格在 model 阶段的 DOMSerializer 一篇讲过,view 这里复用同一份代码)。最后按情况返回 CustomNodeViewDesc、TextViewDesc 或普通的 NodeViewDesc。

updateChildren 的主体是一个 iterDeco 遍历加一个 ViewTreeUpdateriterDeco(parent, deco, onWidget, onNode) 把「遍历节点内容」和「叠加装饰」两件事合在一起:没有局部装饰时就是逐个子节点回调 onNode;有装饰时维护一个 active 装饰数组,遇到装饰边界落在文本节点内部的情况,用 cut 把文本节点切开(剩下的半截存在 restNode 里下一轮接着用),保证每段文本上的装饰集合是一致的。widget 装饰在对应偏移处通过 onWidget 回调交给 updater。

首次构建时 updater 的分支全部走 addNodeNodeViewDesc.create 建出 desc,有 contentDOM 就递归 updateChildren 建子树,然后 splice 进 children。整棵 ViewDesc 树就是这样自顶向下递归长出来的。

拿一个具体文档走一遍。文档是 doc(paragraph(text(“hello”, marks=[strong]))):docViewDesc 建出顶层 NodeViewDesc(doc),updateChildren 里 iterDeco 先遇到 paragraph,addNode 建出 NodeViewDesc(paragraph) 并递归;paragraph 的 updateChildren 里,onNode 回调先调 updater 的 syncToMarks(child.marks, ...),此时 marks 是 [strong],栈是空的,于是新建一个 MarkViewDesc(strong) 放进 children,updater 的当前 top 进到这层 mark desc 里;接着 addNode 把 TextViewDesc 建进 mark desc 的 children。最终 DOM 是 div > p > strong > 文本节点,三层 desc 各管一层,和前面那张三列图一致。

children 建好之后还要落到 DOM。renderDescs(contentDOM, children, view) 负责把 contentDOM 的实际子节点同步成 children 数组描述的样子:已经在正确位置的跳过,缺的就地 insertBefore,多余的删掉,遇到 MarkViewDesc 递归进它的 contentDOM 同步内层。首次构建时 contentDOM 是空的,全部是插入。这个函数同样是首次构建和增量更新共用,差别只在「已有多少可以复用」。

这一篇把 ViewDesc 的静态半边看完了:基类的字段与位置换算,六个子类的分工,children 数组三路合并的构成,dom、contentDOM、nodeDOM 的关系,以及顶层 docView 从空 div 到整棵树的构建路径。下一篇看动态半边:文档变了一次之后,matchesNodematchesMark 怎么判定旧 desc 能不能复用,dirty 的四个等级怎么打标,ViewTreeUpdater 又怎么做到只改动过的那一小块。


1007 字 · 45 段落
xi ming

Written by xi mingFollow onGitHub