本篇是 model 阶段的第九篇。此前已分析 Node 与 Fragment 的结构、Mark、Schema 与内容表达式、ResolvedPos、Slice 与 replace、DOM 序列化与解析以及 diff。node.ts 和 fragment.ts 中还有三类常用方法:遍历用的 nodesBetween 一族、校验用的 canReplace 一族,以及定位用的 nodeAt 和 childBefore 一族。transform、state、view 通过这些入口操作文档,因此需要明确它们的边界行为。文末汇总 model 层的位置约定与 API 分组。参考代码是 prosemirror-model 的 6264de0。
系列目录
遍历族:forEach、nodesBetween、descendants
Node.forEach(src/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 里的 leafText;blockSeparator 靠一个 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.checkContent 和 checkAttrs,再检查 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 时返回当前子节点;offset 等于 pos 时,pos 恰好是某子节点的起点,改取前一个子节点,并将 offset 回退一个 nodeSize。
还是拿 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,否则要求 sameMarkup 且 content.eq。sameMarkup 比较 type、attrs、marks 三样,转调 hasMarkup(type, attrs, marks);hasMarkup 里 attrs 用 compareDeep 深比较,未传时回退到 type.defaultAttrs 或空对象,marks 用 Mark.sameSet 按集合语义比较,顺序无关。TextNode 的 eq 额外要求文本相等。第 11 篇 diff 的终止比较使用引用相等加 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 的边界规则都涉及这三种坐标的换算。
按用途可将 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 抽象开始。
