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

📅
2 分钟阅读
·

上一篇分析 EditorView 构造函数时,第 4 步调用 docViewDesc(...) 将当前文档渲染到 this.dom,并将结果保存为 this.docViewdocView 是定义在 prosemirror-view/src/viewdesc.ts 的 ViewDesc 描述树。view 层的渲染、选区、坐标换算和 DOM 读回都依赖这棵树。本文说明其静态结构,包括类族职责、children 数组与文档树的对应、顶层 docView 的构建,以及每个 desc 的三个 DOM 引用。下一篇分析增量更新相关的 dirty 标记、复用判定和 ViewTreeUpdater。参考代码是 prosemirror-view 的 ca4c78e。

系列目录

日期标题
05-10ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17ProseMirror 仓库全景:22 个包怎么分工
05-24跑通一个最小 ProseMirror:先看文档长什么样
06-07ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05ResolvedPos:一个数字位置怎么变成路径
07-12Slice 与 replace:切一块文档出来再塞回去
07-19DOMSerializer:文档怎么变成 DOM 和 HTML
08-02DOMParser:parseDOM 规则与 HTML 解析
08-09findDiffStart / findDiffEnd:两份文档怎么求差
08-16model 收官:Node 上的辅助方法与位置约定总结
09-06ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03StepMap:一步修改怎么映射每个位置
10-11Mapping:多步映射的链式合并,rebase 的地基
10-18structure.ts:split/join/lift/wrap 的可达性判断
10-25Transform 类:构建修改的 API 层
11-08ProseMirror state(上):EditorState,不可变编辑器状态
11-15Selection 体系:四种选区与选区书签
11-22Transaction:Transform 加上状态语义
12-06Plugin 系统(上):StateField 与插件状态
12-13Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20state 收官:动手写三个插件验证理解
01-03ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10ViewDesc(上):文档到 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) 判断保存的文本是否等于给定字符串,更新器以此识别文本节点;inParent 检查 DOM 文本节点是否仍位于父 desc 的 contentDOM 中,浏览器移动节点后该判断会失败。CustomNodeViewDesc 也继承 NodeViewDesc,用于 nodeViews 提供的自定义渲染,并持有用户返回的 node view 对象 specupdateselectNodestopEvent 会先检查 spec 是否实现对应方法。CustomNodeViewDesc 被单独定义,使额外检查仅发生在自定义节点上;NodeViewDesc.create 则不要求用户接口继承内部类,避免暴露内部实现细节。

MarkViewDesc 对应一个 mark,持有 mark 字段。mark 按固定的嵌套顺序渲染,因此某些场景会产生额外拆分。两个相邻文本节点的 marks 数组相同时可以共用一个 MarkViewDesc;marks 不同时,即使仅内层 mark 不同,也需要拆分。

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 的 size 为 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 创建子树;随后将 desc 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 的复用判定、dirty 的四个等级和 ViewTreeUpdater 的更新过程。


982 字 · 45 段落
ximing

Follow onGitHub

相关文章