富文本编辑框架Prosemirror - 从State到DOM

上一篇文章简单介绍了 prosemirror 内部如何对输入行为作出反应,这一篇文章主要介绍一下 prosemirror 如何完成从 state 到浏览器 DOM 的渲染。

视图

使用 prosemirror 时我们有以下三种定义编辑器内视图渲染的方式:

  1. 直接使用 nodeType/markType 中的 toDOM 属性,实际渲染由prosemirror-model提供的DOMSerializer.renderSpec负责
  2. 使用 nodeView 渲染自定义的节点
  3. 使用 decoration 渲染与 doc 内容无关的节点

​ 从使用者的角度看,我们在 doc 插入了什么内容,dom 中就会渲染出相应类型的内容,这一种方式看起来很像 React 的数据驱动视图的形式。

实际上作者在 statedom 这一层做了很多工作,帮我们把复杂度都抹平了,平时使用起来无需关注到这部分逻辑,但了解这一步过程才能帮助我们在使用时更好地排查问题以及了解编辑器设计的理念。

ViewDesc

prosemirror 内部存在一种用于描述视图对象的特殊结构——ViewDesc,他会绑定在编辑器的 DOM 中的 pmViewDesc 属性上,整篇文档的 desc 挂载在 view 实例的 docView 上。作者在文档中没有提供这部分的介绍,但这部分时 view 中最重要的一环,直接与我们看到的内容相关。

ViewDesc 可以看作是 dom 与编辑器 state 的中间层,相当于是 prosemirrorvirtual-domViewDesc 会挂载到相应的 dompmViewDesc 属性上,并保存相应的 domnode:

img

prosemirror-view 总共定义了 7 种 ViewDesc

  • NodeViewDesc
    • prosemirror 基本节点的 desc,涉及 dom 与节点的绑定,更新和销毁等
  • CustomNodeViewDesc
    • 继承 NodeViewDesc,主要扩展了 update 的判断,以及暴露诸如 selectdestroy 等的钩子,NodeView 对应
  • TextViewDesc
    • 继承 NodeViewDesc,文本节点的 desc
  • MarkViewDesc
    • markdesc,为了简化和可预测,有固定的嵌套顺序
  • WidgetViewDesc
    • decorationdesc,为 state.doc 中的数据无关
  • CompositionViewDesc
    • 联想输入的 desc,用于表明和保护输入节点
  • BRHackViewDesc
    • 插入一个 br,用于处理空段落的高度高度问题

viewDesc 主要提供了匹配节点,获取索引,状态标记等等方法,帮助 prosemirror 更好地处理 statedom 的状态同步

视图更新

​ 对于 prosemirror 来说,视图的更新面向的不是用户做了什么操作,而是面向 state

用户做了什么操作这部分由 DOMObserver 去处理并应用到 state 上,视图显示的状态与 state 强相关,抹平了处理用户行为的复杂性,简化了更新的处理,但同样会带来一些问题,如对用户的行为无法感知导致的节点不能复用的问题等。

​ 这里我们从插入一个节点开始,探究 prosemirror 视图更新的过程。这篇文章略去 DOMObserverMutation 的分析,直接从视图更新开始,假设我们已经拿到了新的 state,接下来我们来分析下 prosemirror 是如何将新的 state 应用到视图上的。

更新过程

​ 拿到新的state之后会调用view.updateState(newState)updateState会调用docView.update更新视图。docView 实际上就是 NodeViewDesc 的一个实例,默认的 NodeViewDesc 的更新方法为:

1
2
3
4
5
update(node, outerDeco, innerDeco, view) {
if (this.dirty == NODE_DIRTY || !node.sameMarkup(this.node)) return false
this.updateInner(node, outerDeco, innerDeco, view)
return true
}

这一步会判断当前节点的数据是否发生变化,如果发生变化,返回 false,会重建整个节点。返回 true 则证明更新成功,进行子节点的更新。

​ 我们在看下updateInner这个方法:

1
2
3
4
5
6
7
updateInner(node, outerDeco, innerDeco, view) {
this.updateOuterDeco(outerDeco)
this.node = node
this.innerDeco = innerDeco
if (this.contentDOM) this.updateChildren(view, this.posAtStart)
this.dirty = NOT_DIRTY
}

他会先更新外部的 decoration,然后进行更新过程中最核心的处理updateChildren,主要可以分为下面几个部分:

1
2
3
4
5
6
7
8
updateChildren() {
// ...
const updater = new ViewTreeUpdater(docView)
// ...
iterDeco(node, decos, onDeco, onNode);
// ...
if (updater.changed) renderChildren()
}
  1. 首先会根据 docView 创建一个 updater,主要服务于更新过程(代表当前的节点更新树,拥有子节点的子节点会创建自己的 updater)。updater 中会匹配 docView 中的 nodedescs,从后往前匹配,保存相同的 desc(preMatched)和最后一个匹配的 descoffset(preMatchedOffset)
  2. 根据 docdecorations 的信息顺序遍历更新,根据匹配到的 nodedeco 的分别调用 onNode 以及 onDeco 方法更新。
  3. updater 中有更新,属性 changed 会被置为 true,并进行子节点的渲染(根据实际的 dom 以及更新后的 descs
  4. 从上面的描述,可以看到最核心的方法在第二部份,我们主要分析下第二步中 onNode 做的事情

节点更新

1
2
3
4
5
6
7
8
9
10
11
onNode = (child, outerDeco, innerDeco, i) => {
// Make sure the wrapping mark descs match the node's marks.
updater.syncToMarks(child.marks, inline, view)
// Either find an existing desc that exactly matches this node,
// and drop the descs before it.
updater.findNodeMatch(child, outerDeco, innerDeco, i) ||
// Or try updating the next desc to reflect this node.
updater.updateNextNode(child, outerDeco, innerDeco, view, i) ||
// Or just add it as a new desc.
updater.addNode(child, outerDeco, innerDeco, view, off)
}
  1. syncToMarks()用于更新 mark

  2. 之后便开始对 Node 的更新操作,总结起来就是三步:

    1. findNodeMatch:在 updater 中寻找是否有 viewDescnode 等于当前 node(最多匹配当前位置的后四位,不匹配最后一位。至于为什么最多只匹配后四位?可能是出于性能考虑吧,作者没有明确说这一点),若没有,进入下一步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
findNodeMatch(node, outerDeco, innerDeco, index) {
let found = -1, preMatch
// ...
if (preMatch && preMatch.matchesNode(node, outerDeco, innerDeco)) {
found = children.indexOf(preMatch)
} else {
for (let i = this.index, e = Math.min(children.length, i + 5); i < e; i++) {
// matches
}
}
if (found < 0) return false
this.destroyBetween(this.index, found)
this.index++
return true
}

如何确定是同一个节点?

主要就是判断是内存地址,节点属性,decorations,自元素是否是一致的。

1
2
3
4
5
6
7
8
9
matchesNode(node, outerDeco, innerDeco) {
return this.dirty == NOT_DIRTY && node.eq(this.node) &&
sameOuterDeco(outerDeco, this.outerDeco) && innerDeco.eq(this.innerDeco)
}

// Node
eq(other) {
return this == other || (this.sameMarkup(other) && this.content.eq(other.content))
}
  1. updateNextNode:若 updaterchildren 中当前位置是 NodeViewDesc 类型,分两种情况
    1. 若在 updaterpreMatched 中存在且索引 preMatch 加上 preMatchOffset 等于当前 node 再 docView.childrenindex,尝试更新节点,否则视为流转到下一步(这一步这么判断的原因是:如果 preMatch+preMatchOffset != index,则证明 updater 中的数据有误,docView.children 中的数据可能有修改,此时以 state 的数据为准)
    2. 若在 updaterpreMatched 中不存在,尝试更新节点,否则视为流转到下一步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
updateNextNode(node, outerDeco, innerDeco, view, index) {
if (this.index == this.top.children.length) return false
let next = this.top.children[this.index]
if (next instanceof NodeViewDesc) {
let preMatch = this.preMatched.indexOf(next)
if (preMatch > -1 && preMatch + this.preMatchOffset != index) return false
let nextDOM = next.dom

// ...

if (next.update(node, outerDeco, innerDeco, view)) {
if (next.dom != nextDOM) this.changed = true
this.index++
return true
}
}
return false
}
  1. addNode:这一步就是直接在 updaterchildren 中当前索引的位置直接插入新创建的 viewDesc
1
2
3
4
addNode(node, outerDeco, innerDeco, view, pos) {
this.top.children.splice(this.index++, 0, NodeViewDesc.create(...))
this.changed = true
}

渲染到视图

若有需要重建的节点或者删除的节点,updater.changed 会被置为 true,进入 dom 层面的操作。

  • 此时的操作以 update 后的 viewDescs(此时即为 docView.children)的数据为准,调整当前视图中 dom 展示的节点,不会有位置的调整,不匹配就销毁,不存在就重建。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function renderDescs(parentDOM, descs) {
let dom = parentDOM.firstChild
for (let i = 0; i < descs.length; i++) {
let desc = descs[i], childDOM = desc.dom
// dom为当前视图中的dom,childDOM为desc对应的dom
if (childDOM.parentNode == parentDOM) { // 证明Child在视图中存在
while (childDOM != dom) dom = rm(dom) // rm()会返回nextSibling,清除不匹配的dom
dom = dom.nextSibling
} else {
parentDOM.insertBefore(childDOM, dom)
}
// ...
}
while (dom) dom = rm(dom)
}

通过上面我们了解 prosemirror 中视图更新处理的过程,实际上就是 stateDOM 的过程,prosemirror 中视图的更新都通过这个过程完成。

更新过程

​ 基于上面说到的规则,我们分析下以下几种输入行为下的更新过程

新增

  • 新增节点不能在 findNodeMatch 找到,会在当前位置重建,其他的节点都能在旧的 docView 找到,不用重建

img

更新

  • 与新增节点一致,主要区别在于 updateNextNode 这一步,调用就 viewdescupdate 方法,若 update 返回 false,增视为需要重建节点,否则视为成功更新。若这一步为 customNodeView,我们则可以干预这个过程
  • 下图展示返回 false 的情况,旧的 node2 会在匹配到 node3 时被 destroy

img

  • 图展示返回 true 的情况,所有节点都能匹配到不需要重建

img

删除

  • 删除节点后旧的 viewdesc 中能匹配到所有的 node,匹配到 node 会销毁新旧 index 之间的所有的 viewdesc

img

移动

  • 移动的情况比较特殊,跟拖动的距离以变化的位置都有关系(主要和 findNodeMatch 的处理有关系)
    • 把节点从前面移动到后面!

img

  • 这种情况下,重建的节点会是拖动的节点,比如上图的情况中,findNodeMatch 在匹配到 3 时,拖动的节点 2 会被销毁,直到遍历到最后被重建。

  • 把节点从后面拖动到前面,这时候的处理分情况:

    • 非最后一位移动,且距离小于 4

img

  • 这种情况下,因为 4findNodeMatch 中可以被找到 index: 0, found: 4),会导致 04 之间的节点被销毁。所以这种情况下,123 会被重建,而 4 不会被重建。

  • 最后一位移动或者是距离大于 4

img

  • 这种情况下,由于 5 不会在 findNodeMatch 中找到,所以会在首位重建节点。匹配到 4 之后遍历结束,销毁剩余节点。所以这种情况下 5 会被重建。

Mark 更新过程

Mark 的更新过程与 Node 的更新有比较大的区别,Markstate 中是作为 node 的一个属性,而在 ViewDesc 中,他则是作为一个高层级的节点,他可以包括其他节点(包括自己本身),所以他在 statedocView 中的结构是不一致的。

比如<p><strong>123</strong></p> :

img

img

prosemirror 在对 dom 做渲染时,是以更新后的 ViewDesc 为准,所以 prosemirror 更新的逻辑是这样:

  • 在每次 onDecoonNode 都会调用SyncToMarks

    ,主要做几个操作

    • 当前有 marks,匹配后两位内是否存在相同的 mark
      • 存在:销毁当前位置与匹配到的位置之间的内容,帮当前顶层和位置推入 updater.stack,位置重置为 0,把 MarkViewDesc 放到 updater.top,开始对 MarkViewDesc 中的内容做更新。
      • 不存在:创建一个新的 MarkViewDesc
    • 当前没有 marks,``updater.stack 中有内容,MarkViewDesc 出栈,updater 状态恢复

所以会有一个奇怪的现象:当后面第二位有同样的 mark 时,会导致中间的节点没有必要的重建:

img

这个过程的变化如下图所示:

img

因为 3 和 1 被设置的 mark 一致,所以会匹配到 3,1 和 nodeView 被销毁,这个行为自然是不应该的,作者的解释是为了修复这个Issues,重用 mark 时做了更激进的匹配(这也是一个优化的点,贡献代码的机会 XD)。

总结

​ 这篇文章主要讲了 prosemirror 是如 state 与 DOM 之间的关系,举了几个比较简单的例子,实际情况下,还需要考虑 Decoration 的渲染,mark 与 decoration 与 node 嵌套的渲染,以及 ltr,rtl 的顺序等等的处理,节点的比对和渲染复杂度指数上升。节点复用在这些情况下无法保证,所以开发一些功能时,不应当依赖某个视图的生命周期。

初窥 prosemirror 的渲染流程,与 react 一样,使用 prosemirror 时我们一般不需要关心到视图这一层,这也是现在各主流富文本编辑器框的一套思路。后面的文章会继续探究用户形如何被捕获,state 内部如何更新,索引如何构建等的流程。