model 收官:Node 上的辅助方法与位置约定总结

5 分钟阅读
·

这是 model 阶段的第九篇,也是收官篇。前面八篇把 Node 与 Fragment 的结构、Mark、Schema 与内容表达式、ResolvedPos、Slice 与 replace、DOM 序列化与解析、diff 都拆完了,但 node.ts 里还有一批方法没来得及展开:遍历用的 nodesBetween 一族、校验用的 canReplace 一族、定位用的 nodeAt 和 childBefore 一族。这些方法单个看都不复杂,但它们构成了上层(transform、state、view)操作文档时的日常入口,边界行为值得逐个过一遍。这篇做完这件事,最后把 model 层的位置约定和 API 心智模型收成两张表。参考代码是 prosemirror-model 的 6264de0,本篇涉及的代码都在 src/node.tssrc/fragment.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 上的辅助方法与位置约定总结(本篇)

遍历族:forEach、nodesBetween、descendants

Node.forEachsrc/node.ts)最简单,直接转调 Fragment.forEach,只看直接子节点,回调拿到 (node, offset, index)。offset 是这个子节点相对当前节点内容起点的偏移,实现里就是一个循环里累加 nodeSize 得出,没有别的开销。

真正干活的是 nodesBetween(from, to, f, startPos = 0)。Node 版转调 this.content.nodesBetween(from, to, f, startPos, this),多传一个 parent 指向自己;Fragment 版(src/fragment.ts)才是实现本体:

for (let i = 0, pos = 0; pos < to; i++) {
  let child = this.content[i], end = pos + child.nodeSize
  if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) {
    let start = pos + 1
    child.nodesBetween(Math.max(0, from - start),
                       Math.min(child.content.size, to - start),
                       f, nodeStart + start)
  }
  pos = end
}

三个细节值得记住。

第一,回调返回 false 的语义是剪掉这个节点的子树,不是终止整个遍历。条件写在 && 链里,f(...) !== false 不成立只是不进递归,外层循环照常走到 to 为止。想提前退出得自己在回调里设标志位,后面 rangeHasMark 就是这么做的,代价是找到之后剩下的兄弟节点还会被扫一遍。

第二,递归的进入条件是 child.content.size 非零。文本节点的 content 是 Fragment.empty,size 为 0,所以文本节点只会出现在回调里,不会被递归进入。这正好符合文本节点在 ProseMirror 里「扁平、无内容」的定位,回调拿到的文本节点 pos 就是它起始的那个位置。

第三,递归时 from/to 要换算进子节点坐标:start = pos + 1 里的这个 +1 就是跳过子节点的开始 token,from 和 to 各自减掉 start 再用 Math.max(0, ...)Math.min(child.content.size, ...) 夹住。回调拿到的 pos 是 nodeStart + pos 逐层累加的结果,对 doc 调用时它就是文档的扁平位置;想换个基准可以传 startPos

descendants(f) 没有独立实现,就是 nodesBetween(0, this.content.size, f),全树遍历。

用一个具体文档把回调拿到的 pos 对一遍。文档是 doc(paragraph("ab"), paragraph("cd")):第一个 paragraph 占扁平位置 0 到 4(开始 token 在 0,文本占 1 到 3,结束 token 在 3),第二个 paragraph 从 4 开始。对它调 descendants,回调依次收到 (paragraph, 0)(text "ab", 1)(paragraph, 4)(text "cd", 5)。文本节点的 pos 是它起始位置,不是它内部某个偏移;如果回调对第一个 paragraph 返回 false,text "ab" 那一次回调就不会发生,但 (paragraph, 4) 照常。这个行为和上面三条实现对得上。

建在 nodesBetween 上面的还有两个文本工具。textBetween(from, to, blockSeparator, leafText) 内部就是一次 nodesBetween:文本节点切出落在区间里的部分;非文本叶节点用 leafText 参数,没传就看节点 spec 里的 leafTextblockSeparator 靠一个 first 标志保证第一段前面不加分隔符。分隔符的插入条件也值得看一眼:node.isBlock && (node.isLeaf && nodeText || node.isTextblock),也就是只给「有文本内容的叶 block」和「textblock」前后加,blockquote 这类容器 block 自己不贡献分隔符,分隔符出现在它内部的 textblock 之间。textContent getter 对有 spec.leafText 的叶节点直接调它,其余情况等价于 textBetween(0, content.size, "")。TextNode 把这两个方法都覆写了:textContent 直接返回 this.text,textBetween 直接切 this.text.slice(from, to),都不用走遍历。

rangeHasMark(from, to, type) 也是 nodesBetween 的应用:回调里查 type.isInSet(node.marks),找到就把 found 置真并返回 false。前面说过这个 false 只剪当前子树,外层循环不会停,所以它不是严格的提前退出,找到之后剩下的兄弟节点还会被回调扫一遍(只是回调体里 found 已真,直接返回 false)。文档树通常不深,这个代价可以忽略,但读代码时别把它当成 break。

校验族:canReplace、canReplaceWith、canAppend

这一族回答的问题是「按 schema,这个内容变动合不合法」,公共依赖是 contentMatchAt(index):拿到第 index 个子节点之前的内容匹配状态,实现是 this.type.contentMatch.matchFragment(this.content, 0, index)。内容本身不合法时直接 throw。内容表达式匹配机的结构第 6 篇拆过,这里只用它的接口。

canReplace(from, to, replacement = Fragment.empty, start = 0, end = replacement.childCount) 是第一个要记住坐标分歧的地方:from/to 是子节点下标,不是扁平位置。校验分三步:

let one = this.contentMatchAt(from).matchFragment(replacement, start, end)
let two = one && one.matchFragment(this.content, to)
if (!two || !two.validEnd) return false
for (let i = start; i < end; i++) if (!this.type.allowsMarks(replacement.child(i).marks)) return false
return true

先用 from 处的匹配状态把替换片段匹配掉,再用匹配后的状态把 to 之后剩下的原有内容匹配完,要求最终落在 validEnd(内容表达式允许在此结束)。两步都过了还不算完,替换进来的每个子节点的 marks 还要过一遍 this.type.allowsMarks,因为内容表达式只管节点类型序列,不管 marks 约束。

canReplaceWith(from, to, type, marks) 是单类型版本:先查传入 marks 是否被允许,再 contentMatchAt(from).matchType(type),再把 to 之后的内容匹配完,同样以 validEnd 收尾。

canReplace 的 replacement 默认是 Fragment.empty,所以 canReplace(from, to) 不带替换内容单独调用,问的就是「把这段下标区间删掉之后内容还合不合法」。删除命令的预判走的就是这个形态。

canAppend(other) 判断另一节点的内容能否接到自己末尾。other 有内容时等价于 canReplace(childCount, childCount, other.content);other 是空节点时没有内容可匹配,改用 this.type.compatibleContent(other.type),要求两个类型至少存在一种都能容纳的子类型。注释里写了动机:避免把两个完全不相干的空节点合并到一起。

有一个方法是这一族里刻意缺席的:canSplit。split 的校验要沿 ResolvedPos 逐层检查每一级能否在当前位置断开,还要为断口两侧构造默认的闭合节点,这已经属于修改类逻辑,所以放在 prosemirror-transform 的 structure.ts 里,等 transform 阶段再拆。model 层只提供内容匹配这一级纯校验,不碰位置解析和默认节点构造,这条边界本身也是 API 设计的一部分。

最后是 check(),递归校验整棵树:先 type.checkContentcheckAttrs,然后处理 marks 的合法性。做法是把 marks 数组逐个 addToSet 重建一份,再和原数组做 sameSet 比较。addToSet 会按 schema 里的 mark 排名去重并重排,原数组如果有重复 mark 或顺序非法,重建结果就对不上,抛 RangeError。最后 forEach 递归子节点。正常编辑路径不跑它,它是给调试和测试用的全量自检,成本是整棵树遍历一遍。

定位族:nodeAt、childBefore、resolve

这一族的共同底层是 Fragment.findIndex(pos),把扁平位置换成「第几个子节点 + 该子节点起始偏移」。它的边界约定要先记住:

  • pos == 0 返回 (0, 0)pos == this.size 返回 (content.length, size),index 越出末尾,靠 maybeChild 兜成 null。
  • 循环里命中条件是 end >= pos,其中 end == pos(pos 恰好落在某子节点结束处)返回 (i + 1, end),也就是边界位置算到下一个子节点头上。
  • pos 越界抛 RangeError。
  • 返回值是模块级共享对象 found,下次调用就被覆盖,注释写明了这一点。调用方都是解构后立即使用,这是有意的零分配设计,不能把这个对象存起来。

nodeAt(pos) 找 pos 后面的那个节点,实现是一个下钻循环:

for (let node: Node | null = this;;) {
  let {index, offset} = node.content.findIndex(pos)
  node = node.maybeChild(index)
  if (!node) return null
  if (offset == pos || node.isText) return node
  pos -= offset + 1
}

maybeChild 为空说明 pos 在内容末尾,返回 null;offset == pos 说明 pos 恰好是某个子节点的起点,返回这个子节点;遇到文本节点直接返回;否则 pos -= offset + 1 继续往子节点里钻,+1 同样是跳过开始 token。所以对一个落在 block 节点内部边界上的 pos,nodeAt 会一直深入到最内层那个以它为起点的节点,而不是返回外层的 block,调用前要清楚自己想要哪一层。

childAfter(pos)childBefore(pos) 只看直接子节点,都返回 {node, index, offset}。childAfter 基本就是 findIndex 加 maybeChild 的封装,pos 落在子节点内部时返回的就是这个子节点。childBefore 多两个分支:pos == 0 时返回 {node: null, index: 0, offset: 0};findIndex 给出的 offset 小于 pos,说明 pos 严格落在某子节点内部,返回它;offset 等于 pos,说明 pos 恰好是某子节点的起点,取前一个子节点,offset 相应回退一个 nodeSize。两个方法对称,但 childBefore 的边界分支多一个,读代码时容易看错,照实记录。

还是拿 doc(paragraph("ab"), paragraph("cd")) 推演。pos 2 落在第一个 paragraph 内部:findIndex 返回 (0, 0),offset 小于 pos,childBefore 返回第一个 paragraph。pos 4 是第二个 paragraph 的起点:findIndex 因 end == pos 返回 (1, 4),offset 等于 pos,childBefore 回退取第 0 个子节点,offset 算回 0。同一个位置 4,childAfter 给第二个 paragraph,childBefore 给第一个,边界归属就靠 findIndex 的 end == pos 分支分开。

resolve(pos) 是定位族里使用频率最高的入口,转调 ResolvedPos.resolveCached(this, pos)。ResolvedPos 的字段语义第 7 篇拆过,这里只看缓存:resolveCached 用一个 WeakMap 按 doc 存缓存,key 是文档对象本身,文档一旦被替换,旧缓存随旧 doc 一起被回收,不会串。每篇文档对应一个 ResolveCache,内部是 12 个槽的数组(resolveCacheSize = 12)环形复用,查找时线性扫一遍比对 elt.pos。一轮选区计算或渲染会反复 resolve 相同或相近的位置,12 个槽对这个访问模式够用。resolveNoCache 是内部逃生口,直接调 ResolvedPos.resolve。resolve 本体的入参检查是 0 <= pos <= doc.content.size,越界抛 RangeError,这个范围正好是文档内所有合法扁平位置。

比较与序列化

三族之外还有一层比较与序列化相关的小方法,一并交代清楚。

eq(other) 判断两个节点是否代表同一段文档:先 this == other 引用相等直接命中,否则要求 sameMarkupcontent.eqsameMarkup 比较 type、attrs、marks 三样,转调 hasMarkup(type, attrs, marks)hasMarkup 里 attrs 用 compareDeep 深比较,未传时回退到 type.defaultAttrs 或空对象,marks 用 Mark.sameSet 按集合语义比较,顺序无关。TextNode 的 eq 额外要求文本相等。注意第 11 篇 diff 的终止比较用的不是 eq,而是引用相等加 sameMarkup(文本节点再单独比文本);eq 是把 sameMarkup 和逐子节点递归组合起来、对外提供的整树相等判断。

toJSON 的输出抠得很省:attrs 只在非空时写(用一个带 break 的 for-in 判断对象是否有键),content 为空不写 content 字段,marks 为空不写 marks 字段。fromJSON 是反向的严格校验:marks 必须是数组,text 节点的 text 必须是字符串,构造完还要过一次 checkAttrs。第 3 篇打印过的那份文档 JSON 就是这两个方法的输出格式。

位置约定与 API 心智模型

把前面九篇的内容收拢,model 层其实只有三种坐标:

  • 扁平位置 pos:整篇文档唯一的编号,节点开始和结束各占一个位置,非叶节点 nodeSize = 2 + content.size,进入子节点内容要 +1。nodesBetween、slice、replace、nodeAt、resolve 的区间参数用它。
  • 父内容内偏移 offset:forEach 回调、findIndex 的返回值用它,语义是子节点相对父节点内容起点的距离。
  • 子节点下标 index:child(i)、contentMatchAt、canReplace 系列的区间参数用它。

最容易踩的坑是第一种和第三种的分歧:canReplace 的 from/to 是下标,replace 的 from/to 是扁平位置,名字都像区间,单位完全不同。第 7 篇 pos 编号约定、第 8 篇 Slice 的 openStart/openEnd、本篇 findIndex 的边界规则,都是围绕这三种坐标的换算在转。

Node 辅助方法分组

API 心智模型按用途分五组,一张表收住:

分组 方法 语义 坐标单位
遍历 forEach 直接子节点逐个回调 offset
遍历 nodesBetween / descendants 区间或全树的后代遍历,回调返回 false 剪子树 扁平位置(相对节点内容)
遍历 textBetween / textContent / rangeHasMark 文本拼接与 mark 探测 扁平位置
校验 contentMatchAt 取下标处的内容匹配状态 下标
校验 canReplace / canReplaceWith / canAppend 替换与追加的合法性预判 下标
校验 check 全树递归自检,不合法抛错 -
定位 nodeAt / childAfter / childBefore 位置前后的节点查询 扁平位置
定位 resolve 位置解析为 ResolvedPos,带缓存 扁平位置
结构 copy / mark / cut / slice / replace 生成新节点,旧节点不动 扁平位置
比较与序列化 eq / sameMarkup / hasMarkup / toJSON / fromJSON 相等判定与 JSON 往返 -

model 阶段到这里结束。九篇下来,这个包的全貌是:Node、Fragment、Mark 三个不可变数据结构,Schema 与内容表达式提供的类型系统,扁平 pos 与 ResolvedPos 构成的位置系统,Slice 与 replace 提供的结构修改原语,再加上 DOM 双向序列化和 diff 两个对外接口。它只描述文档长什么样、什么内容合法,不描述文档怎么改。修改的表达在下一个包:prosemirror-transform 把每次改动拆成 Step,StepMap 负责位置映射,canSplit 这类结构校验也在那里出现。下篇从 Step 抽象开始。


850 字 · 48 段落
xi ming

Written by xi ming You should follow him on Github