这是 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.ts 和 src/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.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,说明 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 引用相等直接命中,否则要求 sameMarkup 且 content.eq。sameMarkup 比较 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 的边界规则,都是围绕这三种坐标的换算在转。
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 抽象开始。

