前面三篇把 model 的静态结构看完了:Node 与 Fragment 组成的树、挂在内联节点上的 Mark、约束树形状的 Schema。树是嵌套的,但编辑器里表示光标和选区端点用的是单个数字,state 里的 selection、transform 里的 step 全都拿这个数字当坐标。这篇看这个数字的编号约定,以及 Node.resolve 返回的 ResolvedPos 怎么把一个数字展开成完整的路径信息。参考代码是 prosemirror-model 的 6264de0,内容集中在 src/resolvedpos.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:一个数字位置怎么变成路径(本篇) |
pos 的编号方式:每个 token 占一位
ProseMirror 把文档内容看成一个扁平的 token 流:每个非叶节点的开始标签占 1 个位置、结束标签占 1 个位置,文本节点每个字符占 1 个位置,没有内容的叶节点(比如图片、横线)整体占 1 个位置。位置编号落在 token 之间的缝隙上,文档开头是 0,结尾是 doc.content.size。
拿一份具体文档数一遍。文档结构是 blockquote 里包一个写着 one 的 paragraph,后面跟第二个写着 two 的 paragraph,JSON 形态是 doc(blockquote(paragraph("one")), paragraph("two")):
图里能看到几个关键点。位置 0 在 blockquote 之前,位置 1 已经进入 blockquote、在第一个 paragraph 之前,位置 2 进入 paragraph、在文本之前。3 和 4 在文本内部,5 是文本结束、paragraph 内容结束的位置。6 在 paragraph 结束标签之后、blockquote 结束标签之前,7 在 blockquote 结束之后,同时也在第二个 paragraph 之前。12 是整个文档的末尾。
这个编号约定有两个直接推论。
第一,nodeSize 和位置跨度严格一致。非叶节点的 nodeSize 是内容 size 加 2(首尾两个 token),文本节点的 nodeSize 是字符数,原子叶节点是 1。所以一个节点在父内容里占多少个位置,看 nodeSize 就知道,不用递归数。第四篇说 Fragment 缓存 size、按 nodeSize 累加算 offset,靠的就是这个一致性。
第二,同一层相邻两个节点之间只有一个位置。位置 7 既是 blockquote 的紧后方,也是第二个 paragraph 的紧前方,编号本身不区分「贴着前一个」还是「贴着后一个」。需要消歧的场景(比如映射一个被删除区间的端点)得靠额外参数,这个留到 transform 阶段的 StepMap 再谈。
为什么用这套编号而不用「路径数组」当坐标:数字可以直接比较大小、可以做加减,一次编辑之后把旧位置映射到新位置只需要按区间平移,后面 StepMap 和 Mapping 全靠这个性质。代价是拿到数字之后不知道它在树的什么位置,得靠 resolve 把上下文算回来。
resolve:从数字到路径
ResolvedPos.resolve(doc, pos) 是展开的核心。先检查 0 <= pos <= doc.content.size,越界直接抛 RangeError,然后逐层下钻:
for (let node = doc;;) {
let {index, offset} = node.content.findIndex(parentOffset)
let rem = parentOffset - offset
path.push(node, index, start + offset)
if (!rem) break
node = node.child(index)
if (node.isText) break
parentOffset = rem - 1
start += offset + 1
}findIndex 在当前层的内容里做线性扫描,累加每个子节点的 nodeSize,找到 pos 落在第几个子节点上,返回下标 index 和这个子节点的起始偏移 offset。rem 是位置进入这个子节点的相对偏移。之后分三种情况:
rem == 0:位置正好落在某个子节点之前的缝隙,当前层就是最后一层,停。- 子节点是文本:位置在文本内部,文本节点没有内容可以下钻,停。
- 其余情况:位置在子节点内部,
parentOffset = rem - 1(减 1 跳过子节点的开始 token),start 累加offset + 1,进入下一层。
结果存在一个扁平数组 path 里,每层三个元素:节点、index、这层内容的起始绝对位置。depth 直接由数组长度算出:path.length / 3 - 1。所以 ResolvedPos 对象本身很轻,三个字段(pos、path、parentOffset),其余全部是基于 path 的派生计算。
拿上面那份文档完整走两遍。先 resolve(3),也就是 one 里 o 和 n 之间的位置。第 0 层对 doc 调 findIndex(3),blockquote 的 nodeSize 是 7,累加到它就超过了 3,得 index 0、offset 0,rem 是 3,path 压入 doc、0、0。blockquote 有内容且不是文本,parentOffset 变成 2,start 变成 1。第 1 层对 blockquote 的内容调 findIndex(2),落在 paragraph 上,path 压入 blockquote、0、1,继续,parentOffset 变成 1,start 变成 2。第 2 层对 paragraph 的内容调 findIndex(1),落在文本节点上,path 压入 paragraph、0、2,子节点是文本,循环结束。最终 path 有 9 个元素,depth 是 2,parentOffset 是 1。textOffset 是 pos 减去 path 的最后一个元素,3 减 2 得 1,说明位置在文本内部第 1 个字符之后。
再 resolve(7),两个块之间的位置。第 0 层 findIndex(7) 直接命中 blockquote 的末尾,返回 index 1、offset 7,rem 为 0,循环当场结束。path 只有 doc、1、7 三个元素,depth 是 0,parentOffset 是 7,textOffset 是 0。同一个对象结构,深度不同,字段含义不变。
findIndex 本身在 src/fragment.ts,有两个边界行为值得记住:pos 等于 0 时直接返回 index 0;pos 等于内容 size 时返回的 index 是子节点总数,也就是「越过最后一个子节点」的下标。累加过程中 pos 正好等于某个子节点末尾时,返回的是下一个子节点的下标。这保证了 findIndex 的结果总是「位置前方的子节点个数」,和 index() 的语义对齐。另外它的返回对象是一个复用的模块级变量,下一次调用会被覆写,源码里标了 @internal,resolve 内部用完就读走,调用方不能存着它。
调试时还有个 toString 可以用,格式是逐层的 类型名_上一层index,最后接 parentOffset。位置 3 打出来是 blockquote_0/paragraph_0:1,位置 7 是 :7。读 ProseMirror 内部的测试断言和报错时经常碰到这个格式。
depth、parent、index 这一族字段
字段族的访问方式很统一,node(depth)、index(depth)、start(depth) 这些方法都接收可选的 depth 参数,resolveDepth 负责解释:不传是当前 depth,负数是 this.depth + val,所以 $pos.node(-1) 拿到的是父节点的父节点。path 的三元组布局在访问里随处可见:node(d) 取 path[d*3],index(d) 取 path[d*3+1],start(d) 取 path[d*3-1] + 1(depth 为 0 时是 0)。
几个常用字段的含义:
depth:位置指向的父节点离 root 有几层。位置直接在文档顶层(比如上图的 0、7、12)depth 是 0,在顶层 paragraph 里 depth 是 1,在 blockquote 内的 paragraph 里 depth 是 2。parent:node(depth),位置直接所在的节点。注意一个特例:即使位置在文本节点内部,parent 也是包含文本的那个块节点。源码注释原文是 “text nodes are ‘flat’ in this model, and have no content”,文本节点在这个模型里没有内容的角色,不当父节点。index():在最深层,位置落在第几个子节点之前(或内部)。配合parent.child(index())取位置后方(或所在)的子节点。indexAfter():指到位置之后的下标。实现是index(d) + (d == this.depth && !this.textOffset ? 0 : 1),即只有在最深层且不在文本中间时才与 index 相同。parentOffset:pos 减去父节点内容的起始位置,相对偏移。pos - parentOffset就是父内容起点的绝对位置,sameParent判断两个位置是否同父时比较的就是这个值。textOffset:位置在文本节点内的偏移,不在文本内部时为 0。它是区分「位置在节点之间」和「位置在文本中间」这两种情形的判据,nodeAfter、indexAfter、marks 全都靠它分支。doc:node(0),位置所在的根节点。ResolvedPos 不存 doc 的引用字段,每次都是从 path 头部取,和 parent 走同一条路径。
start/end/before/after 的边界语义
这组方法把「第 d 层节点的边界」翻译成绝对位置,看起来相似,语义各差一位:
start(d)是第 d 层节点内容开始的位置(开始 token 之后),end(d)是内容结束的位置(结束 token 之前),end(d) = start(d) + node(d).content.size。before(d)是第 d 层节点开始 token 之前的位置,after(d)是结束 token 之后的位置。after(d) = before(d) + node(d).nodeSize,正好差一个完整的节点跨度。
以位置 3(blockquote 内文本 o 和 n 之间)为例,它的 depth 是 2:start(2) 是 2(paragraph 内容起点),end(2) 是 5,before(2) 是 1,after(2) 是 6。往上走一层,before(1) 是 0,after(1) 是 7。
再以位置 7 核对 depth 0 的情形:start(0) 是 0,end(0) 是 12,整个文档内容的跨度。before(0) 和 after(0) 直接抛 RangeError,顶层节点 doc 没有「外面」。注释里那句 There is no position before the top-level node 就是这个意思。
还有一个约定容易被忽略:depth 传 this.depth + 1 时,before 和 after 返回 pos 自身。这个约定是给 NodeRange 用的。NodeRange.start 的实现是 $from.before(this.depth + 1),NodeRange.end 是 $to.after(this.depth + 1)。NodeRange 的 to 往往比 range 本身深(比如 range 圈在 paragraph 层,而 $from 在文本里),depth + 1 让「刚好贴着范围边界的位置」能原样透传,不用再造一个浅一层的 ResolvedPos。NodeRange 的消费者之一是 transform 的 liftTarget,它按 range 的 depth 和 startIndex/endIndex 找提升目标;deleteRange 不用 NodeRange,直接拿 sharedDepth 和 start/end/before/after 找删除边界。这组方法到 transform 阶段会频繁见面。
nodeBefore/nodeAfter 与位置上的 marks
nodeBefore 和 nodeAfter 取位置两侧的节点,文本中间的情形做了特殊处理:
get nodeAfter(): Node | null {
let parent = this.parent, index = this.index(this.depth)
if (index == parent.childCount) return null
let dOff = this.pos - this.path[this.path.length - 1], child = parent.child(index)
return dOff ? parent.child(index).cut(dOff) : child
}位置在文本中间时(dOff 非 0),nodeAfter 用 cut(dOff) 把文本节点切开,只返回后半段;nodeBefore 对称地返回 cut(0, dOff) 的前半段。位置在节点之间时就是紧邻的前后兄弟,超出边界返回 null。也就是说这两个 getter 回答的问题是「紧贴这个位置的内容是什么」,在文本中间时答案是半段文本。
用示例文档核对一遍。位置 3 在 one 中间,nodeBefore 是切出来的 o,nodeAfter 是 ne。位置 5 在第一个 paragraph 的末尾,resolve 之后 index 是 1、textOffset 是 0,nodeBefore 是整个文本节点 one,nodeAfter 因为 index 已经等于 childCount 而返回 null。位置 5 还想查 paragraph 之后有什么,$pos.nodeAfter 做不到,得往父层查:用 index(1) 对着 node(1) 取兄弟,或者拿 after(2) 得到位置 6,在浅一层重新 resolve 再问。
marks() 回答「在这个位置继续输入会带上什么格式」。逻辑分三种:父节点没有内容,返回空;在文本中间,直接返回所在文本节点的 marks;在节点之间,取前后两个兄弟节点的 marks 做对比,以一侧为基准,把 inclusive === false 且另一侧没有的 mark 从结果里剔除。第五篇提过 MarkSpec 的 inclusive 字段,这里就是消费点。举个对照场景:光标停在加粗文本和普通文本的边界,main 是前方的加粗节点,strong 没有设 inclusive 为 false,结果里保留 strong,继续打字还是加粗;光标停在链接文本末尾,main 里的 link 因为 inclusive 为 false 且后方节点没有 link 被剔掉,继续打字落在链接外面。marksAcross($end) 是这个逻辑的变体,给删除操作保留 marks 用,删除一段内容后光标处该保留什么格式,要问删除终点那一侧。
resolve 的成本与缓存
resolve 的成本可以估算:每层一次 findIndex,findIndex 在兄弟节点间线性扫描,总成本是 O(深度 × 每层兄弟数)。单次不贵,但调用频率高。编辑器里每次按键、每次选区变化、每个插件想查上下文,都要 resolve 一两次,这些调用大量落在相同的位置上(选区两端、同一个光标位置被不同插件反复查)。
所以 Node.resolve 走的是 ResolvedPos.resolveCached:
const resolveCacheSize = 12, resolveCache = new WeakMap<Node, ResolveCache>()缓存键是文档对象本身,WeakMap 存每份文档一个 ResolveCache,里面是一个 12 槽的环形数组,命中条件是 pos 完全相等,没命中就 resolve 一个新对象覆盖到当前槽位,槽位指针轮转。这个设计能成立依赖两个前提。一是文档不可变:编辑产生的是新 Node,旧缓存跟着旧文档一起被丢弃,不存在缓存失效问题,WeakMap 也不阻止回收。二是访问有局部性:一次交互涉及的 resolve 集中在少数几个位置上,12 个槽足够装下。命中条件只做位置相等判断,不做近似复用,相邻两个位置各自占一个槽,路径上的公共前缀不会被拆出来共享,实现上这是最简单的方案,换来的是缓存逻辑总共十几行。
Node.resolveNoCache 是绕过缓存的入口,存在的原因在 src/replace.ts 里能看到。prepareSliceForReplace 为了计算切片的闭合,临时用 copy 拼出一个一次性的节点,再在这个节点上 resolve 两个位置。这个临时节点用完即弃,往缓存里写永远不会命中,白占槽位,所以直接走 resolve。这个细节能说明缓存键选文档对象的另一个好处:临时对象根本不经过 WeakMap,没有分配缓存的开销。
ResolvedPos 上还剩几个组合方法值得知道名字:sharedDepth(pos) 找两个位置共享父节点的最深深度,blockRange 圈出块级范围,min/max 取先后。它们都是 start/end 这些基础件的组合,没有再引入新的语义。
到这里,一个数字怎么变成路径、路径上能查到什么都清楚了。下一篇看 Slice:从文档里切一块内容出来时,openStart 和 openEnd 记录的「打开的深度」是什么,以及 replace 怎么把切片闭合并塞回文档。

