ProseMirror model(上):Node 与 Fragment,文档树的骨架

5 分钟阅读
·

上一篇在控制台里看了文档的 JSON 和一次输入产生的 Transaction,结尾说要回到这棵树本身。这篇进 model 包,读两个最基本的类:Node 和 Fragment。整棵文档树就由这两种对象组成,加上挂在节点身上的 Mark,后面的位置编号、Slice、diff 全部建立在它们的字段约定上。参考代码是 prosemirror-model 的 6264de0,这篇的范围是 src/node.tssrc/fragment.ts

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架(本篇)

Node 的四个字段

src/node.tsNode 类,构造器只有四个参数:type、attrs、content、marks。构造器标了 @internal,业务代码不直接 new Node,创建节点的正规入口是 NodeType 上的 create 系列方法和 schema 上的便捷方法(文本节点走 schema.textNodeType.create 遇到 text 类型会直接抛错),创建过程会顺带做 attrs 默认值填充和内容校验,那是第 6 篇的内容。

  • type 是 NodeType,节点在 schema 里的类型对象。同一个 schema 里每种类型只有一个实例,类型比较一律用引用相等。
  • attrs 是普通对象,键值由类型的 attrs 声明决定。文件顶部有一个 emptyAttrs = Object.create(null),给没有属性的场合当空对象用。用 Object.create(null) 是为了让它没有原型,for..in 枚举不到任何键。上一篇提过 toJSON 里那个「循环只为探测空对象」的写法,前提就是这种空对象上循环体一次都不执行。
  • content 是 Fragment,装子节点。构造器里是 content || Fragment.empty,缺省时所有无内容节点共享同一个空 Fragment 单例。
  • marks 是 Mark 数组,缺省 Mark.none,同样是共享的空数组(src/mark.ts)。加粗、链接这类格式信息挂在节点上,不进树,下一篇专门展开。

四个字段全是 readonly,类的文档注释把规矩写死了:节点是 persistent data structure,不要原地修改,要改就建新的,新旧结构之间尽量共享子树。配套方法也按这个规矩写。Node.copy(content) 换内容,Node.mark(marks) 换 marks,两者都有同一个短路:传入值和现有值是同一引用时直接返回 this。不可变结构靠引用比较就能回答「变了没有」,这个短路在 view 层的增量更新里会被大量消费。

相等判断分三层。eq 先做引用相等短路,再比 markup 和内容;sameMarkup 比 type、attrs、marks 三样;hasMarkup 是底层实现:type 引用比较,attrs 用 compareDeepsrc/comparedeep.ts)做结构深比较,marks 用 Mark.sameSethasMarkup 里 attrs 缺省值的回退链是 attrs || type.defaultAttrs || emptyAttrs,调用方不传 attrs 时和类型的默认值比,heading 这类带默认属性的节点才能判等通过。

Node 上还声明了一个 text: string | undefined 字段,类定义后面跟着一行 (Node.prototype as any).text = undefined。普通节点的 text 是 undefined,文本节点由子类 TextNode 填真值。把字段挂到原型上是写给类型系统看的:这样 node.text 在 Node 类型上直接可访问,调用方不用先窄化到 TextNode。另外 children getter 直接返回 Fragment 内部的那个数组,需要原始数组时不用再包一层。

改文档的入口方法也挂在 Node 上,这篇先记下分工,细节留给后面的篇目。cut(from, to) 切出内容的一部分,底层是 content.cut,整段都要时短路返回 this;slice(from, to) 切出的是一个 Slice 对象,除了内容还记录 openStart、openEnd 两个「打开深度」,第 8 篇专门讲它为什么需要这两个数;replace(from, to, slice) 把区间替换成一个 Slice,实现在 src/replace.ts,它是 transform 阶段 ReplaceStep 的底层;resolve(pos) 把整数位置解析成带上下文的路径对象,走 ResolvedPos.resolveCached 带缓存的入口,第 7 篇展开。也就是说 Node 自己只管存数据和回答结构性问题,真正动结构的算法分散在 replace、resolvedpos 这些独立文件里。

另有两个查询类方法值得知道存在。rangeHasMark(from, to, type) 用 nodesBetween 扫区间,回答「这段里有没有某个 mark」;check() 递归校验整棵树是否符合 schema,内容、attrs、marks 三层都查,开发期拿它兜底非法文档。Node 上还有一族合法性判断方法,contentMatchAtcanReplacecanReplaceWithcanAppend,它们都围绕「这个位置允许放什么」展开,底层是内容表达式的匹配机,那是第 6 篇的主题,这篇只需要知道判断入口挂在 Node 上。

Fragment:包了层数组,多存了一个 size

Fragment 是子节点容器,在 src/fragment.ts。它内部就是一个 Node 数组(content 字段),Node 的 content 字段类型是 Fragment 而非 Node[],差别在一个额外成员:size。

构造器签名是 constructor(content, size?):size 给了直接存,没给才遍历数组把每个子节点的 nodeSize 加起来。size 是按 nodeSize 规则计数的内容总尺寸,和子节点个数是两套口径。这个缓存值在所有派生操作里算术传递:

  • replaceChild(index, node)this.size + node.nodeSize - current.nodeSize
  • addToStart / addToEndthis.size + node.nodeSize
  • append(other)this.size + other.size
  • cut(from, to):边走边累加,只统计切下来的部分。

Fragment 一旦建好,问它多大是 O(1)。位置换算、选区映射这类操作每按一次键都要跑很多遍,如果每次问尺寸都遍历子树,成本会全堆在热路径上。用数组存子节点、把和缓存住,就是 Fragment 存在的理由。

位置与下标的换算由 findIndex(pos) 完成:给一个 Fragment 内部位置,返回 {index, offset},index 是位置落在第几个子节点上,offset 是这个子节点的起始位置。实现是线性扫描、累加 nodeSize,两个端点有快捷分支:pos 为 0 返回第 0 项,pos 为 size 返回 content.length。边界约定值得记住:pos 恰好等于某个子节点的结束位置时,返回的是下一个子节点,边界位置归右边的节点。返回对象是模块级共享的一个 found,下次调用即被覆写,注释标了 @internal,调用方取出字段就得用完,不能存着这个引用。

Node 上的 childAfterchildBeforenodeAt 都建立在 findIndex 上。nodeAt 逐层下钻时有一句 pos -= offset + 1,这个 +1 是跳过子节点的开 token,下一节讲 nodeSize 时能看到它的来历。取子节点有两个入口:child(index) 越界抛 RangeError,maybeChild(index) 越界返回 null。findIndex 这种自己保证下标合法的内部路径走 child,让错误尽早暴露;对外查询走 maybeChild。forEach 的回调拿到 (node, offset, index) 三元组,offset 同样是累加 nodeSize 得出的。

拿后面图里的例子把 findIndex 走一遍。doc 的 content 是 [paragraph, image],paragraph 的 nodeSize 是 4,image 是 1,Fragment.size 是 5。findIndex(0) 命中端点分支,返回 {index: 0, offset: 0};findIndex(2) 落在 paragraph 内部,返回 {index: 0, offset: 0};findIndex(4) 恰好是 paragraph 的结束位置,按边界约定返回 {index: 1, offset: 4},也就是 image;findIndex(5) 是另一个端点分支,返回 {index: 2, offset: 5}。childAfter(4) 拿到 image,childBefore(4) 拿到 paragraph,边界位置两边各自能取到相邻节点,光标停在两个块的接缝处时靠这套约定定位。

Fragment 还维护一条关于文本节点的不变式:markup 相同的相邻文本节点不允许并列存在,必须合并成一个。append 拼接两个 Fragment 时检查 last.isText && last.sameMarkup(first),成立就用 withText 把两段文本接起来;fromArray(array) 从数组构建时做同样的检查,还用了个惰性技巧:只有真遇到要合并的位置才复制原数组(joined 变量),一路没有可合并的就直接复用原数组,一次拷贝都不发生。这条不变式让「一段格式相同的文字」在树里永远是一个节点,选区和 diff 都不用处理同格式文本被切成多段的歧义状态。

遍历入口是 nodesBetween(from, to, f):线性扫描与 [from, to) 相交的子节点,回调返回 false 就不再下钻这个子节点;进入子节点内容时 start = pos + 1,又见到了开 token 那一格。descendants 是全树遍历的便捷封装。Node 上的同名方法直接委托给 content,并把 parent 参数补成自己。Fragment.empty 是共享的空 Fragment 单例,所有无内容节点的 content 都指向它,空节点不额外分配容器。

切割有两个口径。cut(from, to) 按位置切,区间端点落在某个子节点内部时递归进去切:文本节点切成子串,非叶节点按内容位置递归。cutByIndex(from, to) 按下标切,标了 @internal,给已经算好下标的内部路径用,省掉 findIndex 的换算,两个端点同样有「整段都要就返回 this、空区间返回 Fragment.empty」的短路。

构建入口除了 fromArray 还有一个多态的 Fragment.from:null 返回 Fragment.empty,Fragment 原样返回,数组走 fromArray,单个节点包成单元素 Fragment。schema 层的 create 系列方法接 content 参数时走的就是它,所以传节点、传数组、传 Fragment 都合法。

剩下几个方法一句话带过。eq 先比数组长度再逐个子节点 eq,配合 Node.eq 完成整棵树的结构比较。toJSON 输出子节点数组,空 Fragment 输出 null,这就是上一篇 JSON 里空节点没有 content 键的来源之一;fromJSON 反过来,用 schema.nodeFromJSON 还原每个子节点再走 fromArray,还原过程同样会触发相邻文本合并。findDiffStartfindDiffEnd 是两个求差入口,本体在 src/diff.ts,第 11 篇讲,view 层把浏览器改过的 DOM 读回文档时靠它们定位变化区间。

nodeSize 和 childCount 是两套计数

Node 上有两个容易混的尺寸属性。childCount 是子节点个数,就是 content 数组的长度。nodeSize 是节点在文档位置系统里占的格子数:

get nodeSize(): number { return this.isLeaf ? 1 : 2 + this.content.size }

非叶节点占「开 token + 内容 + 闭 token」,式子里的 2 就是开闭两个 token;叶节点占 1 格。TextNode 覆写了这个 getter,返回 text.length,一个字符一格。

Node 与 Fragment 的结构

对照图里的例子算一遍。paragraph("hi"):childCount 是 1(一个文本节点),nodeSize 是 2 + 2 = 4。image 是叶节点,nodeSize 是 1。doc(paragraph("hi"), image):doc 的 childCount 是 2,content.size 是 4 + 1 = 5,nodeSize 是 2 + 5 = 7。再换一个 paragraph("hello"):childCount 还是 1,nodeSize 变成 2 + 5 = 7,和上面 doc 的 nodeSize 恰好相等,一个是被文本撑大的,一个是被子节点撑大的,放在一起看能分清两套计数各回答什么问题。

位置系统的编号全部按这套计数展开:文档里每个合法位置是一个整数,落在某个 token 或某个字符上,开 token 占的那一格就是 nodeAtpos -= offset + 1 跳过的格子。位置怎么从树根一路编号到每个字符,规则不少,第 7 篇 ResolvedPos 专门展开,这里先记住 nodeSize 的定义,后面的 cut、Slice、resolve 全都按这套口径算。

叶节点和文本节点的特例

isLeaf 的判定不在 Node 上,在 NodeType 上(src/schema.ts):contentMatch == ContentMatch.empty,类型不允许任何内容就是叶。文本节点天然是叶,image、horizontal_rule 这类不带内容的节点也是。

非文本叶节点的 nodeSize 恒为 1。一张图片在位置计数里和一个字符等价,光标从它左边挪到右边走一格,这也让叶节点可以混在 inline 内容里参与文本式的位置运算。抽取纯文本时它们由 Fragment.textBetween 特殊处理:遇到非文本叶节点,优先用调用方传的 leafText 参数(可以是字符串或函数),其次用节点类型 spec 上声明的 leafText,都没有就跳过。同一个方法还管块与块之间的分隔:传了 blockSeparator,遍历遇到会产生文本的块节点时就在前面补一个分隔符,实现里用一个 first 标志压住最开头的那个,复制纯文本到剪贴板时段落之间的换行就是这么来的。

叶节点旁边还有一个容易混的判定:isAtom。NodeType 上 isAtom = isLeaf || !!spec.atom,atom 是 spec 里单独的一个开关,表达「这个节点没有可直接编辑的内容」。多数情况下两者重合,但 spec 可以把一个带内容的节点标成 atom,view 层遇到 atom 节点就不再进去管理它的内部渲染。Node 上的 isBlock、isInline、isText、isTextblock 这一族 getter 也全是委托给 type 的,节点自己不做判断,「我是什么」这件事完全由类型对象回答。

文本节点由 TextNode 子类表示,构造器里有一条硬约束:空文本直接抛 RangeError(“Empty text nodes are not allowed”)。文档里不存在空的文本节点,「没有文字」用空 Fragment 表达。这条约束和前面的相邻文本合并不变式配合,保证文本在树里的形态唯一。TextNode 把基类的一套方法换成文本版本:withText(text) 换文本,文本相同返回 this;cut(from, to) 切子串;textBetween 直接 slice,不走递归;eq 在 sameMarkup 之外再比字符串;mark(marks)toJSON 也各自覆写,保证返回的还是 TextNode、序列化时带上 text 字段;nodeSize 返回字符数。基类原型上那个 undefined 的 text 字段,在这里变成真实字符串。textContent 这类便捷 getter 在文本节点上同样有近路,直接返回 text,不用走 textBetween 的遍历。

小结

model 层的基础就是这两个类的分工:Node 记 markup(type、attrs、marks),Fragment 记子节点和缓存尺寸。不可变性让每次「修改」都是新建加结构共享,旧文档引用永远有效;size 缓存和 findIndex 让位置运算不用反复遍历子树;nodeSize 的 token 计数把整棵树摊平成整数坐标。下一篇看 marks 那一格:粗体斜体为什么不做成嵌套节点,Mark 的集合操作怎么写。


834 字 · 43 段落
xi ming

Written by xi ming You should follow him on Github