ProseMirror 核心模型(Node / Fragment / Slice / 不可变 / 位置)

返回 🗂 富文本编辑专题 · 相关私有笔记

ProseMirror 没有把文档交给浏览器的 DOM 去当唯一真相,而是用一棵自己定义的、不可变的 Node 树来描述文档,再把这棵树渲染到 contentEditable。本笔记讲这棵树长什么样,以及为什么它能成为 Schema、Transaction、协同和视图同步的共同基础。

首读:先看一份文档值,再认识 API

若尚未建立 DOM / Schema / State / View 的总图,先读 从 contentEditable 到一次编辑闭环。本页不要求先读完 PM Schema:你此刻只要知道 Schema 是“允许哪些文档形状的语言”,不需要先知道 NodeSpecNodeType 或内容状态机。

先看模型在存储或网络中最常见的纯数据形态:

const docJSON = {
  type: "doc",
  content: [{
    type: "paragraph",
    content: [
      { type: "text", text: "Hello " },
      { type: "text", text: "world", marks: [{ type: "strong" }] },
    ],
  }],
}

它表示的不是 HTML,而是一棵“结构 + 行内注解”的文档值:

doc
└─ paragraph
   ├─ text "Hello "
   └─ text "world"  + strong mark

首读只需建立以下词汇表:

概念最小定义现在先不要深究
Node文档树中的一个值,例如 docparagraph 或一段 text它的全部查询和工厂 API
text node承载字符的特殊叶子 Node为什么不能直接 create()
Mark贴在行内内容上的格式或语义,例如 strong、linkmark-set 的排序、去重与互斥
Fragment一个 node 的规范化子节点序列appendcut、diff 等低层操作
position文档树中“内容之间的缝隙”的整数坐标ResolvedPosNodeRange
Slice复制、粘贴或替换时带开放边界的一段文档openStart / openEnd 的所有细节

NodeTypeMarkType 不是另一种文档数据:它们是 Schema 创建的运行时“类型对象”。首次读模型时,把 node.type 先理解为“这个 node 是 paragraph 还是 heading”;完整对象和工厂方法在本文后半的进阶参考中再学。

原理:文档值不可变,DOM 只是可变投影

浏览器会直接改 contentEditable DOM,并保留 IME、原生选区等瞬时状态;ProseMirror 则把可保存、可协作的文档放在不可变 Node 值中。一次正常编辑的概念闭环是:

DOM 输入 → Transaction 描述变更 → 新的 doc / EditorState → EditorView 最小同步 DOM

这页只负责其中的 doc 值:不原地改旧树,而是得到新树并共享未变的子树。这样比较、撤销和协作可以基于稳定的快照与显式变更来工作。历史并不“隐含在引用链里”:可重放的历史由 transaction 的 Step、history 插件或协同协议保存;结构共享只让旧值和新值可以廉价共存。

实际编写扩展时,始终把 Node / Fragment / Mark 当作只读值:不要 mutate attrs、marks 数组或子节点属性。ProseMirror 为性能通常不会 Object.freeze 它们,错误的就地修改可能静默污染被共享的树。

首读路线与进阶边界

第一次读完本页时,应能回答三件事:文档真相是什么、Schema 约束什么、一次编辑为什么产生新值。然后按需继续:

  1. 想读 transaction、selection 或 command:继续本文的“位置坐标系”和 “Slice”,再读 State 与 Transaction
  2. 想实现自定义 block / mark:回到 PM Schema 的最小 Schema 与 NodeSpec / MarkSpec
  3. 想实现列表提升、复杂粘贴、差异计算或 JSON 导入:再读本文标为“进阶参考”的 mark-set、Fragment diff、ResolvedPos / NodeRangeNodeType 与验证章节。

下面保留完整 API 深度,但每个进阶节都会说明它解决的具体问题;它们不是理解上面最小文档例子的前置。

深入:文档是一棵不可变的值

ProseMirror 的核心反直觉点是:文档不是一个有状态的对象,而是一个值(value)

  • 浏览器的 DOM 是命令式、可变的:你调用 el.appendChild(...)node.textContent = ...就地改一棵活的树,改完之后旧状态就消失了,没人记得”上一刻文档长什么样”。
  • ProseMirror 的文档是函数式、不可变的:你永远不修改现有 Node,任何”修改”都产生一棵新树,旧树原封不动地继续存在。

把文档当成值带来三个直接后果,后面会反复用到:

  1. 未变化子树可以复用引用,所以“是否同一份快照”可用 doc1 === doc2 快速判断;要判断两个独立构造的值是否等价,仍应使用 eq 等深比较 API;
  2. 旧树可以廉价保留为快照;完整的 undo / redo 还依赖 history 保存和映射 Step,而不只是保存一个引用;
  3. 协同编辑可以把“从旧值到新值的每一步变更”记录为可序列化、可重放的数据(参见 State 与 Transaction 的 Step / Mapping)。

整棵树由四层概念组成:Node(节点,带 type/attrs/content/marks)、Fragment(规范化的子节点序列)、Mark(行内注解,如加粗)、text(作为叶子节点存在的文本)。下面逐层拆。

深入:Node,文档树的节点

每个 Node 由四个核心属性构成:

  • type:NodeType,定义节点是什么(paragraphheadingimage……)。首读把它当作“节点种类”即可;它由 Schema 创建,NodeType 运行时对象本身会在本文后半解释,Schema 的声明侧见 PM Schema
  • attrs:Object,该节点的具体属性(如 heading 的 level)。
  • content:Fragment,它的子节点序列。注意:content 永远是 Fragment,绝不会是 null 或裸数组——空容器节点也持有一个空 Fragment。
  • marks:readonly Mark[],作用在该节点上的标记集合。

Node 是完全不可变的:创建新内容时总是创建新的 Node 对象,从不就地改属性。

// NodeType.create:创建一个具体类型的节点
// create(attrs?, content?, marks?) -> Node
const para = schema.nodes.paragraph.create(null, schema.text('hello'))
const doc = schema.nodes.doc.create(null, para)
 
// 常用判定属性(由 Schema 推导出的角色)
para.isBlock      // true —— 块级
para.isTextblock  // true —— 直接容纳 text/inline 的块
para.isLeaf       // false —— 还有子内容
para.isInline     // false

text 是特殊的叶子节点,不能 create()

文本不是普通 Node:text 节点不能用 NodeType.create() 构造,必须用 schema.text(str, marks?)。强行用 NodeType 造 text 会抛 RangeError: NodeType.create can't construct text nodes。这是因为 text 节点要满足”相邻同 marks 合并、不允许空文本”等不变量,由 Schema 特殊处理。

const strong = schema.marks.strong.create()
const t = schema.text('hello', [strong]) // 加粗的 'hello'
t.text      // 'hello'
t.marks     // [Mark { type: strong }]
t.isText    // true

块级是树,行内是平的:混合模型

ProseMirror 对内容采用混合模型:

  • 块级结构是树形的——段落、列表项、标题彼此嵌套,天然是 Node + Fragment 的树。
  • 行内内容是平坦的——一句话里某几个词加粗,用 marks 注解在 text 节点上,而不是用一层层 <strong> 包裹节点。

这是有意为之的设计:它贴合编辑的心智。如果把 strong 也建模成”包裹节点”,那”半句加粗、其中又有一段斜体”会变成深度嵌套、顺序敏感的 span 树,极难操作;而把行内样式拍平成 Mark 数组,toggle / 比较 / 合并都简单得多。Mark 的细节见下文与 PM Schema

进阶参考:Mark 与 mark-set 代数

首读到这里可以跳过。只有实现 toggle mark、stored marks、格式合并或诊断 mark 冲突时,才需要 mark-set 的排序、判等和互斥算法。

前文已说明 marks 是挂在行内节点上的注解、其顺序由 schema 规范化。本节补的是另一面:Mark 本身的字段,以及如何对”一组 mark”做增删查改——这是命令层实现”加粗 / 取消加粗 / 判断光标样式”的底层原语。关键在于,ProseMirror 把 marks 建模成”集合”而非”列表”:集合按 schema 的 type.rank 排序、用 eq 去重,于是相同的一组 mark 永远得到逐位相等的数组,比较与合并都退化成简单操作。

Mark 这个值:type + attrs

一个 Mark 实例只有两个字段,且都是 readonly:

  • type: MarkType —— 指向 schema 里声明的标记类型(如 strong / em / link)。
  • attrs: Attrs —— 该 mark 的属性(如 linkhref)。

也就是说 Mark 是一个不可变值对象:它不携带”作用于哪段文本”的信息,作用范围由它被挂在哪些行内节点上决定。这与前文”文档是不可变的值”一脉相承——Mark 同样是值,任何”修改”都通过返回新值完成。MarkType / excludes / inclusive 这些声明属于 schema,详见 PM Schema

判等以 eq 为准,而非引用相等

mark.eq(other) // type 相同,且 attrs 经 compareDeep 深比较相等

eq(other: Mark) → boolean 先比引用,否则比 type 相同且 attrs 深比较相等。这一点是后面所有集合操作的判等基准:isInSet / removeFromSet / sameSet / addToSet 去重全部走 eq,而不是引用相等。

两个独立构造、但同型同属性的 mark 会被视为相等——会被 addToSet 去重、被 isInSet 命中。不要因为”不是同一个对象”就以为它们不同。

对集合的三个核心操作:add / remove / isIn

这三个方法都把”有序 mark 集合”(readonly Mark[])当作不可变输入,返回结果而绝不原地改写:

  • addToSet(set) → readonly Mark[] —— 把当前 mark 加入有序集合,返回新数组。
  • removeFromSet(set) → readonly Mark[] —— 按 eq 找到并移除当前 mark,返回新数组。
  • isInSet(set) → boolean —— 线性扫描,用 eq 判断当前 mark 是否在集合中。

addToSet 的语义最丰富,需要拆开看:

  • 若当前 mark 已用 eq 命中集合中、或集合里已有某个 mark 的 type **排斥(excludes)**当前 type,则原样返回传入的 set(同一引用,不分配新数组)。
  • 否则,先移除所有被当前 type 排斥的 mark,再按 type.rank 升序把当前 mark 插入到正确位置,得到去重且按 schema 顺序规范化的新集合。
const strong = schema.marks.strong.create()
const withStrong = strong.addToSet(node.marks) // 不改 node.marks,得新集合
 
// toggle 加粗:在则去、不在则加 —— isInSet + add/remove 是其底层
const next = strong.isInSet(node.marks)
  ? strong.removeFromSet(node.marks)
  : strong.addToSet(node.marks)

排斥是不对称的,极易记反:

  • 集合里已有 mark 排斥当前 type → 原样返回 set,当前 mark 不被加入
  • 当前 type 排斥集合里的某些 mark → 把那些 mark 移除后再加入当前 mark。
    一句话:谁后来,谁的排斥意愿被尊重——但前者的体现是”拒绝加入”,后者的体现是”踢掉旧的”。

这种”返回新集合”的不可变性,正是命令层能放心组合操作的基础:每一步都拿到独立的新值,不会污染原文档(参见 State 与 TransactionstoredMarks 与命令如何消费这些原语)。同时,当操作无实际变化(已存在 / 不存在 / 被排斥)时直接返回原 set 引用——既省一次分配,也让上层能用引用相等(prev === next 这类判断)快速识别”这步没变化”。

两个静态方法:setFrom 与 sameSet,以及 none

  • Mark.none: readonly Mark[] = [] —— 全库共享的空 mark 集合常量。无 mark 的节点复用它,避免到处分配空数组。
  • Mark.setFrom(marks?) → readonly Mark[] —— 把 null / 单个 Mark / 一个无序 Mark[] 规范化成有序集合:空输入返回 Mark.none,单个 mark 包成单元素数组,数组则复制后按 type.rank 升序排序返回。
  • Mark.sameSet(a, b) → boolean —— 判断两个集合是否完全相同:先比引用,再比长度,然后逐位用 eq 比较(顺序敏感)。
Mark.setFrom([em, strong]) // 复制并按 rank 排序,但不做 excludes 去重
Mark.sameSet(a, b)         // 逐位 eq;依赖两侧都已按 rank 规范化

setFrom 只排序、不处理排斥与去重。要得到真正经过排斥规整的集合,应从空集出发用 addToSet 逐个累加,而不是把一堆 mark 丢给 setFrom

为什么 sameSet 这种”逐位比较”就能当集合相等用?正因为集合已被 type.rank 规范化、顺序唯一——同一组 mark 必然得到逐位相等的数组,规范形式唯一,于是无需做无序集合比较。代价是它顺序敏感:若你手工拼了一个未排序的 Mark[] 去和规范集合比,可能元素相同却判不相等。凡是要参与比较的集合,务必先经过 setFromaddToSet 规范化。

这套”集合 + 唯一规范形式”的设计贯穿全库:文档 diff、协同编辑里对行内片段的比较都依赖它,marks 的稳定排序让远端与本地的同一片格式产生逐位相等的结果(参见 协同编辑)。而把 marks 渲染成 DOM 节点的过程,则交给 View 与 NodeView

易错点速查

  • 这些方法返回新集合,务必接住返回值;但无变化时返回同一引用,不能假设每次都拿到新数组。
  • 判等一律走 mark.eq(other)(type 同 + attrs 深比较),不是引用相等。
  • addToSet 的排斥是不对称的(谁排斥谁,效果方向相反),易记反。
  • 集合顺序由 MarkType.rank(schema 声明顺序)决定,不是插入顺序;别依赖”后加的在后面”。
  • setFrom 只排序、不去重 / 不排斥;需要排斥规整请用 addToSet 累加。
  • sameSet 顺序敏感,依赖集合已 rank 规范化;未排序数组参与比较会误判不等。

深入:Fragment,规范化的子节点序列

Fragment 是一个有序的子节点序列容器,概念上像数组,但带有强约束以保证规范化:

  • 相邻且 marks 相同的 text 节点必须合并;
  • 不允许空 text 节点

通过 Fragment.from(nodes) 创建(接受 Fragment | Node | Node[] | null),工厂方法会自动做合并与规范化。Fragment 同样不可变。

// Fragment.from 会把相邻同 marks 的 text 自动合并
const frag = Fragment.from([
  schema.text('hello', [strong]),
  schema.text(' ',     [strong]), // 与前后 marks 相同
  schema.text('world', [strong]),
])
// 结果:单个 text 节点 'hello world'(带 strong),而非三个
 
frag.childCount   // 1 —— 直接子节点个数
frag.size         // 11 —— 位置坐标系下的总大小(见下一节)

为什么要这么严的规范化? 因为它保证了文档的唯一规范形式(canonical form):两个逻辑上相同的文档,必然得到完全相同的树结构。这对比较、去重、缓存至关重要——否则”同一句加粗文本”可能存在无数种等价但结构不同的表示,版本对比和协同 rebase 都会失去可靠基准。

测空要用 fragment.childCount === 0,不要用真值判断——空 Fragment 本身是个真值对象。

进阶参考:Fragment 的结构操作与 diff

首读时先记住 Fragment 是规范化的子节点序列即可。只有需要手写结构替换、比较两份文档或理解增量 DOM 同步时,再读本节。

前面已经把 Fragment 定位成”规范化的子节点序列”(相邻同 marks 合并、无空 text),并交代了它的不可变性与结构共享。但只讲了”它长什么样”,没讲透”怎么操作它、以及引用不等时如何只定位差异”。本节补这一面:Fragment 提供了一组结构操作方法(全部返回新 Fragment、原值不变),以及一对 diff 方法(findDiffStart / findDiffEnd)——后者正是 ProseMirror”廉价版本对比 / 最小化重绘”的真正机制,它与结构共享天然咬合。

结构操作:每个方法都返回新 Fragment,无需改动时回退到自身

Fragment 的所有”修改”都不就地改原对象(与 前文讲的不可变约定一致),而是返回一个新 Fragment。更关键的是:在”其实没必要新建”时,这些方法会直接返回原 Fragment 本身,以复用结构、避免无谓分配。

  • append(other: Fragment): Fragment —— 把 other 拼到本序列之后,返回新 Fragment。拼接边界处若两侧是同 markup 的相邻 text 节点,会被合并(再次规范化)。任一方为空时直接返回另一方(结构共享,不复制)。
  • cut(from: number, to = this.size): Fragment —— 按整数位置坐标截取 [from, to) 区间。边界落在 text 节点内部会切分该 text,落在块节点内部会递归截取其内容。当 from 为 0 且 to 等于 this.size 时原样返回自身。
  • replaceChild(index: number, node: Node): Fragment —— 把第 index子节点替换为 node。注意 index 是子节点序号(不是位置坐标);若新旧子节点引用相同(current === node)则原样返回自身。
  • addToStart(node: Node): Fragment / addToEnd(node: Node): Fragment —— 在序列开头 / 末尾插入 node,size 增加 node.nodeSize,原 Fragment 不变。
// 注意:cut 的入参是位置坐标(进入/离开/字符各计长度),不是子节点索引
const frag = Fragment.from([
  schema.text('hello'),          // 位置 0..5
  schema.nodes.image.create(),   // atom 叶子,占 1,位置 5..6
])
 
frag.cut(0, frag.size) === frag  // true —— 全量截取,回退到自身(结构共享)
frag.cut(2, 4).textContent       // 'll' —— 边界落在 text 内部,切分出残段
 
// addToStart / cut 都返回新 Fragment,原 frag 始终不变
const bigger = frag.addToStart(schema.text('> '))
bigger.size === frag.size + 2    // true
frag.childCount                  // 仍是 2 —— 原值未被触碰

最常见的错误是忘记接收返回值、误以为这些方法是原地修改。它们全都返回新值;frag.append(other) 本身不会改变 frag

findIndex:位置坐标 → 子节点索引的内部映射

位置坐标系(整数)和子节点索引(0,1,2…)是两套对照体系,findIndex 是它们之间的底层桥:

  • findIndex(pos: number): {index: number, offset: number} —— 把相对位置 pos 映射到”第几个子节点(index)+ 该子节点起始处的偏移(offset)“。pos 落在某子节点边界上时返回该边界的 index / offset;越界抛 RangeError

findIndex 在源码里标注为 @internal,且返回对象会被复用——下次调用时同一个对象会被覆盖。若要保留 index / offset,必须立即解构出来,不能持有该对象引用。日常代码里定位树上下文应优先用 Node.resolve() 得到 ResolvedPos(详见后文 ResolvedPos 一节),findIndex 多是内部细节。

findDiffStart / findDiffEnd:从两端向中间收敛的最小差异区间

这是本节的重点。前文给的结论是”引用相等即整棵相等”,但引用不等时如何只定位差异?答案就是这对方法,它们从两端各自向中间扫描,得到一个最小的差异区间。

  • findDiffStart(other: Fragment, pos = 0): number | null —— 从头比较两个 Fragment,返回第一个不同处的整数位置(以Fragment 坐标计);完全相同则返回 null。text 节点逐字符比较定位到具体字符偏移,块节点递归进入子内容。
  • findDiffEnd(other: Fragment, pos = this.size, otherPos = other.size): {a: number, b: number} | null —— 从尾向前比较,返回最后一个不同处之后的边界。因尾部对齐点在两侧的绝对位置通常不同,故返回 {a, b} 两个位置(a 为本 Fragmentbother 的坐标);相同则返回 null
// 只改了中间一个字符:'hello world' → 'hello WORLD'
const a = Fragment.from(schema.text('hello world'))
const b = Fragment.from(schema.text('hello WORLD'))
 
const start = a.findDiffStart(b)          // 6 —— 'w' / 'W' 第一处分叉(a 坐标)
const end   = a.findDiffEnd(b)            // { a: 11, b: 11 } —— 尾部对齐边界
// 视图层只需重绘 a 坐标的 [6, 11) 区间,而非整个文本/整棵树
 
// 完全相同 → 两者都用 null 信号
const same = a.findDiffStart(a)           // null

返回值的形态决定了判空必须用 pos == null 这类显式判断,不能用真值判断:findDiffStart 返回 number | null,而位置 0 是合法返回(第一个字符就不同),用真值判断会把 0 当成”无差异”而漏掉。

两个易混点必须分清:其一,findDiffEnd 返回的字段是 a / b(不是 start / end),a 是本 Fragment 坐标、bother 坐标,因尾部对齐点两侧绝对位置通常不同,不能混用;其二,findDiffStart 给的是单个数(头部对齐,两侧坐标相同),findDiffEnd 给的是一对数(尾部对齐,两侧坐标常不同)。组合起来,本 Fragment 的最小差异区间是 [start, end.a)other 的是 [start, end.b)

为什么这样设计:diff 与结构共享咬合

diff 算法内部会优先利用引用相等判断(概念上即 childA === childB)。由于不可变与结构共享,未改动的子树常保留相同引用,于是可整段跳过(pos += child.nodeSize),只在引用不同的区域继续深入。这会显著减少典型局部编辑的比较工作;具体成本仍取决于树形、共享程度和实际差异,不能把它机械地理解为固定的 O(差异规模) 保证。

于是一次“按差异定位”的过程是这样的:findDiffStart 从头扫到第一处引用分叉,findDiffEnd 从尾扫到最后一处分叉,两端向中间收敛,中间夹出的就是变化区间。View 可借此进行增量同步,而不是把整个文档当作全量替换;它与 State 与 Transaction 的 Step / Mapping 是两条互补路径:Step 是“已知怎么改”的前向描述,diff 则是“只有两个值、要反推差异”时的兜底手段。

结构相等与序列化:eq / toJSON / fromJSON

最后补齐”值层面”的三个方法,它们和不可变模型配合使用:

  • eq(other: Fragment): boolean —— 深结构相等:先比 content.length,再逐个用 node.eq 深比较每个子节点。这是”值相等”而非引用相等——引用相等可短路判定相同,但 eqtrue 不代表两者引用相同。
  • toJSON(): any —— 序列化:非空时返回各子节点 toJSON() 组成的数组;Fragment 返回 null(而非空数组)
  • static fromJSON(schema: Schema, value: any): Fragment —— 反序列化,与 toJSON() 互逆:value 为假值(含 null)时返回 Fragment.empty;value 必须是数组,否则抛 RangeError;逐元素用 schema.nodeFromJSON 还原并规范化。schema 的来源见 PM Schema
const f1 = Fragment.from(schema.text('hi'))
const f2 = Fragment.from(schema.text('hi'))
f1 === f2        // false —— 引用不等
f1.eq(f2)        // true  —— 但值相等(深比较)
 
Fragment.empty.toJSON()                       // null,不是 []
Fragment.fromJSON(schema, null) === Fragment.empty  // true —— null 往返到空

序列化往返要处理这个 null 约定:空 Fragment 出去是 nullnull 进来是 Fragment.empty。别假设 toJSON() 总返回数组。

本节速查(Gotchas)

  • 结构操作(append / cut / replaceChild / addToStart / addToEnd)全返回新 Fragment、原值不变;“无需改动”时回退到自身。忘记接收返回值是最常见错误。
  • cut 的入参是位置坐标;replaceChildindex子节点序号——两套体系别混。
  • findDiffStart 返回 number | null,findDiffEnd 返回对象 | null:判”无差异”用显式的 pos == null,别用真值判断(位置 0 是合法返回)。
  • findDiffEnd 返回 {a, b} 不是 {start, end};a 是本 Fragment 坐标、bother 坐标,尾部对齐两侧位置常不同,不可混用。
  • findIndex 标注 @internal返回对象会被复用(覆盖);要保留就立即解构,别持有引用。日常用 resolve()
  • eq 是深比较(逐个 node.eq),不是引用相等;反向可短路,但 eqtrue 不蕴含引用相同。
  • toJSON() 对空 Fragment 返回 null(非 []);fromJSONnull / 假值映射到 Fragment.empty——往返序列化要处理这个约定。

深入:位置坐标系,用一个整数定位文档中的任意缝隙

ProseMirror 用整数表示文档中的位置,位置总是指向”两段内容之间的缝隙”,而非某个字符本身。计数规则非常简单:

  • 进入一个节点 = 1 个位置;
  • 离开一个节点 = 1 个位置;
  • 每个文本字符 = 1 个位置;
  • 叶子节点 / atom(如 hrimage、行内原子节点)= 1 个位置。

<doc><p>hello</p><p>world</p></doc> 为例,位置序列是:

0(进入 doc)
 1(进入 p1)
  2 3 4 5 6  → h e l l o(5 个字符)
 7(离开 p1)
 8(进入 p2)
  9 10 11 12 13 → w o r l d
 14(离开 p2)
15(离开 doc)

由这套规则,nodeSize 可以机械推导,做位置运算时永远用 nodeSize,不要靠估算:

  • text 节点:nodeSize = 字符数;
  • 叶子 / atom 节点:nodeSize = 1;
  • 容器节点:nodeSize = 1(进入) + content.size + 1(离开)
const doc = schema.nodes.doc.create(null, [
  schema.nodes.paragraph.create(null, [
    schema.text('a'),
    schema.nodes.image.create(), // 行内 atom 叶子,占 1
    schema.text('b'),
  ]),
])
// 位置:0 进入doc / 1 进入p / 2 'a' / 3 image / 4 'b' / 5 离开p / 6 离开doc
doc.nodeSize                // 7  = 1 + content.size(5) + 1
doc.firstChild.nodeSize     // 5  = 1 + 3 + 1
doc.firstChild.content.size // 3  = 1字符 + 1叶子 + 1字符

为什么用整数位置而不是 DOM 那套 (node, offset) 二元组? 因为整数把文本字符和块级结构统一进了同一套坐标:不必区分”字符位置”和”节点位置”,选区表达、位置映射(Mapping)、变换都因此变得机械、无歧义。DOM 的 node + offset 二元性恰恰是富文本里大量边界 bug 的来源。

别把子节点索引(0, 1, 2…)和绝对位置(0, 1, 7, 8…)搞混。两者之间的转换交给 Node.resolve()

进阶参考:ResolvedPos,把整数位置解码成树上下文

只有当一个命令需要知道“当前位置在哪个父节点、左右是什么兄弟”时,才把整数 pos resolve 成 ResolvedPos。理解 transaction 前,先掌握上一节“位置是缝隙”已足够。

裸整数告诉你”在哪”,但编辑命令往往还需要知道”我在谁里面、我是第几个兄弟、我左右各是什么节点”。Node.resolve(pos) 返回一个 ResolvedPos,把抽象位置解码成富上下文:

  • depth:位置落在的最深层级(0 = root)。
  • parent:该 depth 处的父节点(等价于 node(depth))。
  • node(depth?):取任意层级的祖先节点。
  • index(depth?) / indexAfter(depth?):在该层中的子节点索引。
  • start(depth?) / end(depth?):该层节点的绝对位置边界。
  • nodeBefore / nodeAfter:最深层级上、紧邻该位置前后的兄弟节点。
  • marks():该位置上生效的 marks(会考虑相邻 marks 的 inclusive 属性)。

ResolvedPos 内部以栈式结构存了从根到 depth 的整条路径信息,一次 resolve 就能拿到全部祖先上下文。

// doc: <doc><p>hello</p><p>world</p></doc>
// 位置 7 在两个段落之间(p1 之后、p2 之前)
const $pos = doc.resolve(7)
$pos.depth       // 1 —— 在 doc 里,不在任何 paragraph 里
$pos.parent      // doc 节点
$pos.nodeBefore  // <p>hello</p>
$pos.nodeAfter   // <p>world</p>
$pos.index(0)    // 1 —— 下一个子节点是 doc 的第 1 个(从 0 数,p2)
$pos.start(0)    // 0 —— doc 内容的起始绝对位置
 
// 落在文本里:位置 3 在 'hello' 的第二个 l 前
const $inText = doc.resolve(3)
$inText.depth    // 2 —— doc > paragraph,更深一层
$inText.parent   // <p>hello</p>

这就是坐标系与树结构之间的桥:liftwraptoggleMarksetBlockType 这类命令几乎都先 resolve$from / $to,再用 depth / index() / start() 在恰当的祖先层级上做判断和操作,代码因此更声明式,无需手写递归遍历。

depth位置落入的最深祖先层级:顶层段落之间是 depth = 1,段落文本内部是 depth = 2。调 start(depth) / end(depth) 时,depth 必须 <= $pos.depth,传更大的值行为未定义。

进阶参考:NodeRange,一段兄弟节点的平铺区间

NodeRange 服务于 lift、wrap、列表缩进等结构命令;普通文本编辑、简单 node / mark 扩展都不需要先学它。

ResolvedPos 描述的是”文档里的一个点”。但很多块级结构操作的作用对象根本不是单个光标位置,而是某一父节点里、某一层级上的一段连续兄弟节点:把几段从引用块里提升出来(lift)、把选中的几段包进列表 / 引用(wrap)、列表的缩进与反缩进——它们的语义都是”对这一截兄弟做手术”。NodeRange 就是为此而生的输入单元:它把”哪一段兄弟、在哪一层、对应到整篇文档的什么绝对位置”一次性算好,封装成一个自洽、不可变的值。

三个 readonly 字段:把”哪一层的一段兄弟”钉死

NodeRange 由一对端点和一个深度构造:

// new NodeRange($from, $to, depth)
// $from / $to 必须至少到 depth 这一层为止落在同一个节点里(同一父节点),
// 否则这个区间没有意义。
const range = new NodeRange($from, $to, depth)
  • $from:ResolvedPos,区间起点(已解析位置)。
  • $to:ResolvedPos,区间终点(已解析位置)。
  • depth:number,该区间”指入”的那个节点(即父节点)的深度。depth 把”在哪一层做结构操作”一次性钉死——表达的是”depth 这一层级上的一段兄弟”。

三个字段都是 readonly,实例本身是不可变值。区间的所有其它信息(父节点、索引、绝对位置)都不另外存储,而是从这一对 ResolvedPos 现算出来的派生视图。

派生属性:父节点、半开索引区间、绝对位置

NodeRange 暴露一组 getter,把上面三个字段翻译成结构操作真正需要的坐标。注意它们分别落在 depthdepth + 1 两套层级上:

  • parent:parent = this.$from.node(this.depth),区间所指入的父节点。这一段兄弟,就是这个父节点子节点序列里的一截。
  • startIndex:startIndex = this.$from.index(this.depth),区间在父节点子序列中的起始子节点索引(闭端)。
  • endIndex:endIndex = this.$to.indexAfter(this.depth),结束子节点索引(开端,指向最后一个被选中子节点之后)。因此区间覆盖的兄弟是 [startIndex, endIndex) 这一半开区间。
  • start:start = this.$from.before(this.depth + 1),区间在整篇文档整数坐标系中的起始绝对位置,即第一个被选中子节点之前那一刻。
  • end:end = this.$to.after(this.depth + 1),结束绝对位置,即最后一个被选中子节点之后那一刻。

把”区间所在父节点”用 depth 取(node(depth) / index(depth)),而把”子节点边界”用 depth + 1 取(before(depth+1) / after(depth+1)),是因为这两件事天然差一层:父节点在 depth,它的子节点缝隙在 depth + 1。这套设计与前文 ResolvedPos 一节的索引 / 绝对位置同源——NodeRange 没有发明新坐标,只是在 ResolvedPos 之上拼出”一段兄弟”的视角。

blockRange:从一对端点算出区间

通常不手写 new NodeRange(...),而是用 ResolvedPos.blockRange 让它自己找出该在哪一层分叉:

// blockRange(other = this, pred?) -> NodeRange | null
blockRange(other: ResolvedPos = this, pred?: (node: Node) => boolean): NodeRange | null

它从”当前位置”与给定 other 位置在块级内容上”分叉”的那一层算出区间:

  • 两端落在同一个文本块里 → 返回围绕该文本块的区间;
  • 两端落在不同块里 → 返回它们在共同祖先中围绕这些块的区间;
  • 算不出合法区间 → 返回 null

可选的第二个参数 pred 是一个谓词,会逐层用候选父节点调用,只有 pred 返回 true 的那一层才被接受——用来把区间限制到符合条件的容器层级(比如”必须是某种列表容器”),而不是用来过滤被选中的子节点。

// doc: <doc><blockquote><p>aaa</p><p>bbb</p></blockquote></doc>
const $from = doc.resolve(/* 落在第一段 'aaa' 里 */ 4)
const $to   = doc.resolve(/* 落在第二段 'bbb' 里 */ 9)
 
// 只给一个端点:other 取默认 this,围绕该位置自身算区间
const single = $from.blockRange()
 
// 跨两个端点:必须显式把另一个 ResolvedPos 传进去
const range = $from.blockRange($to)
if (range) {
  range.depth       // blockquote 这一层
  range.parent.type // blockquote —— 这两段是它的子节点
  range.startIndex  // 0
  range.endIndex    // 2 —— 开端,覆盖 [0, 2) 即两段
}

协作链路:model 层算范围,transform 层改文档

NodeRange 本身只描述范围,不修改任何东西。约定的用法是把它交给 prosemirror-transform 里以区间为输入的结构变换函数,由它们校验并生成具体的 Step:

  • liftTarget(range):尝试算出这段兄弟可以被提升到的目标深度;遇到 isolating 父节点不会跨越,算不出返回 null
  • findWrapping(range, nodeType, attrs?):尝试找出把这段兄弟用 nodeType 包裹的合法包裹层序列(必要时在内 / 外补节点),找不到返回 null
const range = $from.blockRange($to)
if (range) {
  const target = liftTarget(range)        // number | null
  if (target != null) tr.lift(range, target)
 
  const wrappers = findWrapping(range, schema.nodes.bullet_list)
  if (wrappers) tr.wrap(range, wrappers)  // 把这段兄弟包进无序列表
}

这清晰体现了 model 层与 transform 层的分工:model 层负责”算出哪一段”(blockRangeNodeRange),transform 层负责”改文档”(liftTarget / findWrapping → Step)。生成的 Step 与位置映射归 State 与 Transaction 管,最终影响到 View 与 NodeView 的渲染。

与裸位置的对比:为什么需要这层抽象

单个 ResolvedPos 只能回答”我在哪个点”,而结构变换需要的是”对哪一截兄弟、在哪一层动手”。如果不抽象出 NodeRange,每个 lift / wrap 命令都得各自重复”找共同祖先、算首尾子节点索引、再换算成绝对位置”这套逻辑,且极易把闭端 / 开端、depth / depth + 1 算错。NodeRange 把这套换算收敛成一个一次算好、自洽、不可变的值,让上层命令只需 $from.blockRange($to) 一行就拿到结构操作所需的全部坐标。

常见坑(Gotchas)

下面几条几乎都是”差一层 / 差一个索引”或”忘了判空”导致的。

  • endIndex 是开端(exclusive):覆盖的兄弟是 [startIndex, endIndex) 半开区间,endIndex 指向”最后一个被选中节点之后”,不是最后一个节点的下标。它底层是 $to.indexAfter(depth) 而非 index(depth)
  • start / enddepth + 1,父节点用 depth:start / endbefore(depth+1) / after(depth+1),而 parent / startIndexdepth。别把”父节点深度”与”子节点边界要 +1”混为一谈。
  • blockRange 第一个参数是 ResolvedPos 不是谓词:签名是 blockRange(other = this, pred?),谓词在第二位。只给一个端点用 $from.blockRange()(围绕自身),跨两端必须显式 $from.blockRange($to)
  • blockRange 会返回 null:两端无法在块级内容上分叉、或 pred 始终不通过时即为 null。调用方必须判空;同理 liftTarget / findWrapping 也会返回 null,拿到 NodeRange 不等于一定能 lift / wrap。
  • 它是某一具体文档版本上的派生视图:字段都是 readonly,文档一旦被变换产生新值,旧的 NodeRange 不会自动跟随,要在目标文档上重新 resolve 与计算。
  • pred 过滤的是父层级,不是子节点:它用候选父节点调用,用来挑出符合条件的那一层容器,而不是用来筛掉区间里的某些兄弟。

深入:Slice,从文档里“切”出来的一段,带开放端

复制、剪切、replace、insert 处理的不是完整的子树,而是 Slice——一段可能”被切开”的内容。它有三个字段:

  • content:Fragment,切出来的内容;
  • openStart:number,起始端被切开的深度;
  • openEnd:number,结束端被切开的深度

openStart / openEnd 不是布尔,而是深度值(典型 0–2)。openStart = 1 的含义是:“content 的第一个节点,它上面还有一层父节点是被从中间切开的”。

// doc: <doc><p>hello</p><p>world</p></doc>
// 位置:0 进入doc / 1 进入p1 / 2-6 hello / 7 离开p1 / 8 进入p2 / 9-13 world / 14 离开p2 / 15 离开doc
 
// 整段第一个段落:两端都是闭合的
doc.slice(1, 7).openStart   // 0
doc.slice(1, 7).openEnd     // 0
 
// 从 p1 文本中间(3)切到 p2 文本中间(11)
const s = doc.slice(3, 11)
s.openStart  // 2 —— 起点切穿了 doc>paragraph 两层
s.openEnd    // 2 —— 终点同理
// s.content 里是被切开的两段:'llo' 的残段 + 'wo' 的残段

为什么要记录开放端? 因为粘贴 / replace 时需要”智能闭合”:把一段开放的内容塞回文档时,openStart / openEnd 告诉 replace 操作哪些上层节点是被切开的,从而决定是把它们与目标位置的同类节点合并,还是保留为独立结构,最终结果还得满足 Schema 约束。没有开放端,粘贴就只能靠手工猜测节点包裹层级,极易产生非法文档。

// 剪贴板场景常用:以两端最大可能的开放深度构造 Slice
const s2 = Slice.maxOpen(fragment)        // openIsolating 默认 true
const empty = Slice.empty                 // 标准空切片

Slice 是 State 与 TransactionReplaceStep 的核心载荷,也是 View 与 NodeView 处理粘贴的关键。

进阶参考:Node 的遍历与查询 API

前面几节确立了一个基调:文档是不可变的值,Node 树不能就地改。但”不可改”不等于”不能读”——恰恰相反,正因为值是稳定的,ProseMirror 才能放心地给 Node 配上一整套只读的遍历、定位、取值、比较与切取 API。这一节把这套读取面讲清楚:它让你在不破坏不可变约定的前提下,把一棵树看个透;而所有”修改”,本质上都是用这些只读取值拼出一棵新树

心智锚点:本节所有方法分两类——一类是纯查询(返回子节点 / 文本 / 布尔),不产生新节点;另一类是 cut / copy / replace,返回新树、绝不改原节点。看到后者却忽略返回值,等于什么都没做。

取直接子节点:child / maybeChild / childCount

最基础的一层是按索引取直接子节点(注意是 index,不是位置坐标):

  • child(index: number) → Node:返回第 index 个直接子节点;index 越界会抛错,不会返回 null
  • maybeChild(index: number) → Node | null:同样按索引取,但越界时返回 null,是 child 的安全版本。
  • childCount: number:只读 getter,直接子节点的个数(内部委托给 content.childCount)。
// 已知索引一定合法时用 child;长度不确定时用 maybeChild
for (let i = 0; i < node.childCount; i++) {
  const c = node.child(i)          // 这里 i 一定在范围内,放心用 child
  // ...
}
const last = node.maybeChild(node.childCount) // 故意越界 → null,而不是抛错

易踩坑:childCount子节点个数,不是位置长度。位置长度是 content.size,整个节点占的位置是 nodeSize(坐标系一节已讲)。两者别混。长度不确定时用 maybeChild,不要用 try/catch 去兜 child 的越界异常。

按位置定位:nodeAt / childAfter / childBefore

当你手里是一个位置坐标而不是索引时,用这组方法。这里的 pos 都是相对本节点内容起点的整数坐标,不是绝对文档位置——在子树上调用时,别把文档级位置直接传进来。

  • nodeAt(pos: number) → Node | null:返回紧跟在 pos 之后的那个节点,会逐层下钻定位,文本节点也会作为 text Node 返回;定位不到返回 null
  • childAfter(pos: number) → {node, index, offset}:找到 pos 之后的那个直接子节点,连同它的 index 和它相对本节点的 offset 一起返回;没有则 nodenull
  • childBefore(pos: number) → {node, index, offset}:对称地找 pos 之前的那个直接子节点。
// nodeAt 会钻进嵌套结构;childAfter 只看最外层的直接子节点
const deep = doc.nodeAt(5)            // 可能是深处的一个 text/leaf 节点
const { node, index, offset } = doc.childAfter(5) // 只返回 doc 的某个直接子节点

nodeAtchildAfter/childBefore 的区别就在”钻不钻”:前者一路下沉到最具体的节点,后者只在当前这一层切分。需要”我在这层的哪个孩子里”用后者;需要”这个位置上具体是哪个节点”用前者。若要更丰富的层级信息(depth / parent / nodeBefore / nodeAfter 等),则应升级到 doc.resolve(pos) 得到 ResolvedPos(见坐标系一节)。

浅层遍历 vs 深层遍历:forEach / descendants / nodesBetween

这是本节最容易用错的三个方法,关键在遍历深度回调参数语义的差异。

  • forEach(f: (node, offset, index) => void):只遍历直接子节点,不递归;回调拿到子节点、它相对父节点的 offset、它的 index。纯遍历,无返回值。
  • descendants(f: (node, pos, parent, index) => void | boolean):对每一个后代递归调用回调;回调返回 false不再下钻进该节点的子树。
  • nodesBetween(from, to, f, startPos = 0):只对与 [from, to] 区间有重叠的后代递归调用,包含包住这两个端点的所有祖先节点;回调返回 false 同样剪枝。
// 浅层:只数一层孩子
para.forEach((child, offset, index) => {
  // child=本段落的直接子节点;offset=它在段落内的位置
})
 
// 深层:递归全树,返回 false 剪掉某棵子树
doc.descendants((node, pos, parent, index) => {
  if (node.type.name === "code_block") return false // 不进 code_block 内部
  // 返回 undefined / true 都会继续深入
})
 
// 区间内(含祖先):常用于收集落在选区范围内的节点
doc.nodesBetween(from, to, (node, pos, parent) => {
  // node 可能是包住端点的段落/列表项,也可能是其中的叶子
})

三处易错,逐条记牢:

  • forEach 回调是 (node, offset, index);descendants / nodesBetween 回调是 (node, pos, parent, index),且 parent 可能为 null。参数顺序不同,别照抄。
  • 返回 false 才”剪枝”(跳过子树);返回 undefined / true继续深入。别误以为返回 true 是终止遍历。
  • nodesBetween 会把包住端点的祖先也回调进来,不是只回调落在区间里的叶子。需要按层级过滤时,用回调里的 parent / pos 自行判断。

要在 State 与 Transaction 里遍历选区命中的节点,nodesBetween 几乎是默认工具。

取文本:textBetween / textContent

把结构压成纯字符串时有两条路:

  • textContent: string:只读 getter,把本节点及后代里所有文本节点拼成一个字符串;块边界之间不会自动加分隔符
  • textBetween(from, to, blockSeparator?, leafText?) → string:取 [from, to] 之间的文本。给了 blockSeparator 时会在不同块的文本之间插入它;leafText 指定非文本叶子节点的占位文本(可为字符串或函数 (leafNode: Node) => string),不给则回退到 schema 中该节点的 NodeSpec.leafText(见 PM Schema)。
doc.textContent                        // 跨段落会粘连:"第一段第二段"
doc.textBetween(0, doc.content.size, "\n") // 段间补换行:"第一段\n第二段"
// 给图片这类叶子节点一个占位
doc.textBetween(0, doc.content.size, "\n", node => `[${node.type.name}]`)

易踩坑:跨段落只想要”看起来正常”的文本时,别用 textContent——它不加分隔符,段落会粘在一起。要分隔就用 textBetween 并传 blockSeparator(常用 "\n")。

查询 marks 的存在:rangeHasMark

  • rangeHasMark(from, to, type: Mark | MarkType) → boolean:测试给定 mark 或 mark type 是否在 [from, to] 区间内出现过(出现即 true)。参数既可传具体 Mark,也可传 MarkType

这常用于实现”加粗”这类切换命令时,先判断选区里是否已经存在该 mark,从而决定”加”还是”删”。

const isBold = doc.rangeHasMark(from, to, schema.marks.strong)

节点相等与 markup 比较:eq / sameMarkup / hasMarkup

正因为文档是值,“两段文档是否相等”是个有意义且高频的问题。三个方法,粒度由粗到细:

  • eq(other: Node) → boolean:深度结构相等——类型、属性、marks 与全部内容都相等才为真。
  • sameMarkup(other: Node) → boolean:只比 markup(类型 / 属性 / marks),不比子内容
  • hasMarkup(type: NodeType, attrs?, marks?) → boolean:测本节点的 markup 是否匹配给定的 type / attrs / marks(后两者可选);同样不看子内容。
// 判断"整段文档是否相等"用 eq
const unchanged = newDoc.eq(oldDoc)
// 只关心"还是不是同一种标记"(比如段落变没变成标题)用 hasMarkup / sameMarkup
const stillParagraph = node.hasMarkup(schema.nodes.paragraph)

易踩坑:sameMarkup / hasMarkup 只看 markup,不看子内容;要”整段相等”必须用 eq

只读切取与重建:cut / copy / replace

最后一组是”用旧值拼新值”的核心,它们全部返回新树,绝不就地改:

  • cut(from, to = this.content.size) → Node:返回一个新节点,只保留 [from, to] 之间的内容;to 省略时到内容末尾。
  • copy(content: Fragment | null = null) → Node:返回一个 markup 与本节点相同、但内容换成给定 content 的新节点;不传则内容为空。content 是一个 Fragment(见前面的 Fragment 一节)。
  • replace(from, to, slice: Slice) → Node:用给定 Slice 替换 [from, to] 之间的内容,返回新树。slice 必须能”对接”(开放端能与周围内容连接、内容节点对目标父节点合法),否则抛 ReplaceError
const head = doc.cut(0, 10)          // 只取前一段内容的新文档
const empty = para.copy()            // 同 markup、空内容的新段落
const blank = para.copy(Fragment.empty)
const next = doc.replace(from, to, slice) // 结构变更在 model 层的底层入口

replace 是 EditorState / Transaction 文档变更落到 model 层的底层入口——上层的命令与事务最终都靠它把 Slice 嵌回文档,细节见 State 与 Transaction

易踩坑:

  • cut / copy / replace 都返回新值;忽略返回值 = 没改。
  • replaceslice 必须能对接,否则抛 ReplaceError,不是静默失败;构造 Slice 时要让开放端与上下文匹配(回顾 Slice 一节的开放端语义)。

理解了”读取面分层(浅层 forEach / 深层 descendantsnodesBetween)+ 取值面全部返回新树(cut / copy / replace)“,就握住了后续看懂位置映射、命令、事务乃至 协同编辑 如何在结构共享之上工作的底座:它们没有任何”就地改”的魔法,只有用这些只读 API 在旧树上拼出新树。渲染与节点视图层对这棵树的消费,则见 View 与 NodeView

进阶参考:JSON 表示,文档的规范序列化形态

前面几节把文档当成内存里的不可变值来讲:它是带方法的活 Node 对象,挂着 type / attrs / content / marks,能 resolve 位置、能切 Slice。但当文档要离开内存——存盘、走网络、在协同里收发——就需要一种脱离 DOM、脱离渲染、不含方法的纯数据投影。这就是 JSON 形态,也是 prosemirror-model 里被称为”线格式(wire format)“的东西。本节补的是这条往返链路:toJSON 怎么把活对象压成纯对象,fromJSON 又凭什么(以及凭谁)把它复原回来。

节点的 JSON 形状:稀疏对象,省略默认值

Node.toJSON() 的签名是 toJSON(): any,产物是一个可以直接喂给 JSON.stringify 的纯对象。它的字段是有条件写入的,而非全量铺开:

  • type:总是有,值为 type.name(字符串)。
  • attrs:仅当节点存在自有属性时才挂。
  • content:仅当 content.size 非零时才挂,值是 content.toJSON()
  • marks:仅当 marks 非空时才挂,值是每个 mark.toJSON() 收成的数组。

也就是说,空 attrs、空 content、空 marks 都不会出现在 JSON 里——这是一种刻意的稀疏表示:默认值用”字段缺席”来编码,反序列化端再按约定补回。

text 节点是这个形状里的一个分叉点。它没有 content 字段,改用一个 text 字符串承载文字:

// 容器节点(段落):有 content,无 text
{ type: "paragraph", content: [ /* 子节点 JSON ... */ ] }
 
// text 节点:有 text,无 content;带 marks 时附 marks 数组
{ type: "text", text: "hi", marks: [ { type: "strong" } ] }

这与前面”块树 + 行内平坦”的混合模型是一致的:容器走 content 数组,叶子文字走 text 字符串。手写或改写 JSON 时把两者混用(比如给 text 节点塞 content、或漏掉 text)会在反序列化时直接抛错。

关键提醒:toJSON 的产物是纯数据(plain object),它没有 Node 上的任何方法——不能在它身上调 nodeSizeresolvechild(...)。要把它当文档用,必须先 fromJSON 复原成活 Node

mark 的形状更简单。Mark.toJSON() 产出 { type: string, attrs? },同样只在存在自有 attrs 时才写 attrs。这正是上面节点 JSON 里 marks 数组每一项的形状。

Fragment 与 Slice:同一套”省略默认值”的约定

容器节点 JSON 里的 content,本质就是 Fragment.toJSON() 的产物:逐个 child.toJSON() 拼成数组。但有个边界值得记住——FragmenttoJSON() 返回 null,不是 []。这就是为什么”空 content”在节点 JSON 里干脆整条字段都省略掉。

前文 Slice 一节已讲过它的开放端语义,此处不重述,只补它的线格式:Slice.toJSON() 产出 { content, openStart?, openEnd? },其中 content 为空时整体返回 null,openStart / openEnd 仅在大于 0 时写入(开放深度为 0 即省略)。可见 Slice 走的是同一套稀疏对象约定。Slice 的 JSON 是协同编辑、复制粘贴里传输片段的标准载体(见 协同编辑)。

反序列化端的对应方法把”省略”这件事兜了回来:

  • Fragment.fromJSON(schema, value):value 为假值(含 null / undefined)直接返回 Fragment.empty;非数组抛 RangeError;否则对数组每项调用 schema.nodeFromJSON 还原,再用 Fragment.fromArray 组装。这正是”空 content 被省略后仍能还原成空片段”的机制来源。
  • Slice.fromJSON(schema, json):json 为假值返回 Slice.empty;openStart / openEnd 缺省取 0,若提供了但不是 numberRangeError

反序列化:为什么 fromJSON 一定要传 schema

Node.fromJSON(schema, json) 的签名是 static fromJSON(schema: Schema, json: any): Node。它的流程能解释 JSON 形态的全部设计取舍:

// 还原文档的推荐入口(已绑定方法,见下文)
const doc = schema.nodeFromJSON(json)
// 等价于显式静态调用:
const doc2 = Node.fromJSON(schema, json)

它内部做的事:

  • json 为空抛 RangeError
  • json.marks 若存在必须是数组(否则抛 RangeError),逐个经 schema.markFromJSON 还原。
  • json.type === "text",要求 json.text 是字符串,走 schema.text(...) 构造文本节点。
  • 否则先 Fragment.fromJSON 还原 content,再用 schema.nodeType(json.type).create(attrs, content, marks) 构造,并立即 checkAttrs 校验属性。

对应的 Mark.fromJSON(schema, json) 同理:json 为空抛 RangeError;schema.marks[json.type] 查不到时抛 "There is no mark type ... in this schema";否则 type.create(json.attrs)checkAttrs

这里藏着 JSON 形态的本质设计:JSON 与 schema 是解耦地描述”结构”的——JSON 里 type / mark 名都是字符串,不绑定任何具体类型对象。但要把这些字符串重新映射回 NodeType / MarkType,就必须有一份 schema 当字典。所以反序列化无法脱离 schema,这也是 guide 反复强调”没有 schema 就无法把 JSON 解析回文档”的根因。schema 的角色见 PM Schema

Schema 实例上 nodeFromJSONmarkFromJSON 这两个便捷方法,在构造函数里就被赋成已绑定(bound)的闭包:json => Node.fromJSON(this, json)json => Mark.fromJSON(this, json),把 this(schema)咬进了闭包。因为已绑定,可以安全地直接当回调传:

// 已绑定,直接当回调:
const nodes = jsonArray.map(schema.nodeFromJSON)
 
// 对比:静态方法需要显式传 schema,不能这样裸传
// jsonArray.map(Node.fromJSON) // ✗ 丢了 schema

别把这两组混淆:schema.nodeFromJSON / schema.markFromJSON 是已绑定实例方法,而 Node.fromJSON / Mark.fromJSON 是需要显式传 schema 的静态方法。

check():fromJSON 之后的结构兜底

有一个容易踩的盲区:fromJSON 只对 attrs 调了 checkAttrs,并不校验 content 是否符合该节点类型的内容表达式。换句话说,一段类型名都对、但子结构非法的 JSON,fromJSON 可能照样吞下去,留下一个结构不合法的 Node

要补这层校验,用 Node.check(),签名 check(): void。它递归校验本节点及其子孙:

  • 对自身 contenttype.checkContent、对 attrstype.checkAttrs;
  • 把 marks 依次 addToSet 重建,再用 Mark.sameSet 与原 marks 比对,不一致抛 "Invalid collection of marks ...";
  • 对每个子节点递归 check;任一不合法即抛 RangeError
const doc = schema.nodeFromJSON(externalJson)
doc.check() // 回灌外部/历史 JSON 后做一次递归结构兜底,不合法即抛

把外部来源、历史版本、或跨 schema 的 JSON 回灌进编辑器时,fromJSON 之后显式 check() 是个低成本的安全网。回灌成功后,这个文档就能作为初始 doc 喂给状态层(见 State 与 Transaction),再由视图层渲染(见 View 与 NodeView)。

为什么把它叫”规范序列化形态”

之所以称 JSON 是文档的”规范形态”,是因为它是结构本身的最小完整投影:只携带 type / attrs / content / marks / text 这几类纯数据,不含任何 DOM 细节、不含方法。这让它能被 JSON.stringify 安全持久化、能在网络上传输,是”保存文档”与”协同编辑”的共同基础。而省略默认值并非有损——每个被省略的字段(空 attrs / content / marksnullFragment / Slice、为 0 的开放深度)在反序列化端都有约定好的默认补回,所以序列化—反序列化是可往返(round-trip)的。

实践上要记住的几条:读 JSON 时对 attrs / content / marks 一律做 undefined / null 兜底,别假设字段一定存在;跨版本或跨 schema 回灌旧 JSON 时,type / mark 名在新 schema 里缺失会抛 RangeError,这是最常见的崩点;marks 字段必须是数组,且其顺序与集合由模型保证(check 会用 Mark.sameSet 校验),不要手工拼出非法 mark 集合。

进阶参考:NodeType 与 MarkType,运行时的类型对象

这不是 Schema 的前置课。读完最小 Schema 后,你只需知道它会为每种声明创建可复用的类型对象;需要直接构造、验证或按类型判断 node / mark 时再查本节。

前面几节讲的是文档数据(Node / Fragment / Slice 这些不可变的值)。这一节讲的是它们背后的类型层:Node 实例上那个 type 字段到底指向什么。

答案是:每个 Node 的 type 指向一个 NodeType,每个 Mark 的 type 指向一个 MarkType。这两类对象是 Schema 在创建期根据 NodeSpec / MarkSpec 编译、并缓存为单例的运行时类型对象。它们是”文档数据”与”schema 规则”之间的连接点:把”这种节点允许什么内容、什么 marks”和”如何安全地造出一个该类型的值”集中在一处。声明侧的细节(NodeSpec / MarkSpec / 内容表达式 / ContentMatch)属于 PM Schema,本节只讲编译出来的类型对象怎么用。

关键身份事实:type 对象是 per-schema 单例schema.nodes.paragraph 取到的永远是同一个 NodeType 引用,所以判类型可以直接 nodeA.type === schema.nodes.paragraph,不必比字符串。前提是同一个 Schema——跨不同 Schema 实例的同名类型彼此并不相等。

NodeType:节点的类型对象

每个 NodeType 都是单例,带三个回指字段把”类型”钉死在它所属的 schema 上:

  • name: string —— 它在该 schema 里的名字(paragraphheading……)。
  • schema: Schema —— 反向指回所属 Schema。
  • spec: NodeSpec —— 指向当初声明它的那份 NodeSpec(声明细节见 PM Schema)。

接下来是一组角色标志,它们刻画”这种节点是什么形状”。注意区分:有的是编译期定下的实例字段,有的只是从别处推导出来的 getter——不要试图给它们赋值,要改行为得改 spec 后重建 schema。

  • isBlock: boolean / isText: boolean —— 编译期定下的实例标志。
  • get isInline(): boolean —— 仅仅是 !this.isBlock 的取反 getter;没有第三种”既非块也非行内”的类型。
  • inlineContent: boolean —— 内容是否为行内内容(由 content 表达式推导);所谓 textblock 即 isBlock && inlineContent
  • get isLeaf(): boolean —— 当内容自动机就是空匹配(contentMatch == ContentMatch.empty,不允许任何子节点)时为 true。是从 contentMatch 推导的 getter,不是独立字段。
  • get isAtom(): boolean —— 原子节点(对编辑器而言是不可直接编辑其内容的整体):isLeaf || !!spec.atom。比 isLeaf 更宽。
  • get whitespace(): "pre" | "normal" —— 空白处理策略:spec.whitespace || (spec.code ? "pre" : "normal")。这就是 code 节点默认保留空白的来源。

还有两个跟”内容规则”挂钩的字段:

  • contentMatch: ContentMatch —— 由 spec.content 内容表达式编译成的内容自动机的起始匹配状态;下面所有创建/校验方法都以它为依据。
  • groups: readonly string[] —— 该类型在 spec.group 里声明的分组名数组;内容表达式里写 "block" / "inline" 这类分组名能匹配到本类型,靠的就是它。

isLeafisAtom 不是一回事。isLeaf 严格指”内容自动机为空、不允许子节点”;isAtom 更宽,等于 isLeaf || spec.atom。一个声明了 spec.atom 但语义上仍可有内容的节点,是 atom 但不是 leaf。

三个工厂方法:不校验 / 校验抛错 / 自动补齐

这是 NodeType 最值得分清的部分——三种”造节点”的方式,安全性强弱不同:

  • create(attrs?, content?, marks?) -> Node —— 属性会被检查并补默认值,但内容完全不做 content 表达式校验。便宜,但可能造出违反内容规则的非法文档而不报错。
  • createChecked(attrs?, content?, marks?) -> Node —— 同 create,但额外用本类型的内容规则校验给定内容,不匹配就抛错;用于在构造点立刻暴露非法结构。内部用 validContent 判断。
  • createAndFill(attrs?, content?, marks?) -> Node | null —— 尝试用 contentMatch 在给定内容首尾自动补齐必需子节点,使结果合法;补不出合适包裹时返回 null

三者的 content 都可为 Fragment / 单个 Node / Node 数组 / null,marks 省略则为空集。

const p = schema.nodes.paragraph
 
// 1) 不校验:能造出非法节点而不报错(危险但快)
const bad = p.create(null, schema.nodes.paragraph.create()) // 段落里塞段落,不报错
 
// 2) 校验抛错:构造点立刻发现问题
try {
  p.createChecked(null, schema.nodes.paragraph.create())
} catch (e) {
  // 这里会抛:内容不符合 paragraph 的 content 表达式
}
 
// 3) 自动补齐:补必需子节点;给定内容补不出就 null
const filled = schema.nodes.doc.createAndFill() // 这个 doc 类型可补出默认子节点
if (filled) {
  // filled 是一个结构合法的最小 doc(必需子节点已被自动创建)
}

辅助判定方法:

  • validContent(content: Fragment) -> boolean —— 给定 Fragment 是否是本类型的合法内容;createChecked 内部就用它。
  • compatibleContent(other: NodeType) -> boolean —— 本类型与另一 NodeType 是否允许部分相同的内容;替换 / 连接节点时用来判断两者内容是否兼容。
  • hasRequiredAttrs() -> boolean —— 本类型是否含有“无默认值、必须显式提供”的属性。为真时 createcreateAndFill 都不能替你凭空补出该属性;先提供 attrs,再讨论内容能否补齐。

createAndFill(null) 的关键不是“任何调用都必定成功”,而是在目标类型所需 attrs 已齐全、Schema 能生成其必需子节点时,它会一路补出最小合法结构。对带必填 attrs 的 node,先提供 attrs;对给定 fragment,仍要处理“找不到可行包裹”时返回的 null

NodeType 与 mark 的关系:这个节点允许哪些 mark

NodeType 还管”我这种节点上能放哪些 mark”(依据 spec.marks 编译出的 markSet,null 表示全允许):

  • allowsMarkType(markType: MarkType) -> boolean —— 某个 mark 类型是否允许出现在本节点。
  • allowsMarks(marks: readonly Mark[]) -> boolean —— 一组 marks 是否全部允许。
  • allowedMarks(marks: readonly Mark[]) -> readonly Mark[] —— 过滤掉不允许的 marks,返回新集合。
const code = schema.nodes.code_block
code.allowsMarkType(schema.marks.em) // 多数 schema 里 code_block 不允许 marks -> false
const kept = code.allowedMarks([schema.marks.em.create()]) // 过滤后可能为 []

MarkType:与 NodeType 对称的标记类型对象

MarkType 是 mark 那一侧的运行时单例,字段与 NodeType 对称:

  • name: string / schema: Schema / spec: MarkSpec —— 名字、回指 Schema、指向声明它的 MarkSpec。
  • excluded: readonly MarkType[] —— 由 spec.excludes 解析出的、被本类型排斥的 mark 类型数组;互斥逻辑读取它。

方法集合(注意:操作”一组 marks”时它返回的是新集合,不改原集合,延续不可变约定):

  • create(attrs?) -> Mark —— 创建本类型的 mark;attrs 可为 null 或只含部分属性,其余有默认值的会被补上。
  • isInSet(set: readonly Mark[]) -> Mark | undefined —— 在集合里查找属于本类型的 mark,找到返回该 Mark,否则返回 undefined
  • excludes(other: MarkType) -> boolean —— 本类型是否(按 spec.excludes)排斥另一类型;mark 加入集合时用它决定要不要顶掉冲突 mark。
  • removeFromSet(set: readonly Mark[]) -> readonly Mark[] —— 集合里若有本类型的 mark,返回去掉它后的新集合;否则原样返回输入。
const em = schema.marks.em
const m1 = em.create()
const m2 = em.create() // 无 attrs 时复用缓存单例,m1 === m2 为 true
 
const set = [schema.marks.strong.create(), m1]
em.isInSet(set)        // 返回那个 em mark(不是布尔!)
em.removeFromSet(set)  // 返回只剩 strong 的新数组,set 本身不变

默认实例单例:若某 mark 类型的所有属性都有默认值,Schema 会在构造期预建一个”默认属性的 Mark”缓存起来(源码里的内部字段 instance)。create() 在不传 attrs 时直接返回它,避免重复分配——这也是”同类型默认 mark 可以用 m1 === m2 比较”的来源。

Schema 上的两张字典

最后,这些单例从哪儿取?Schema 上有两张只读字典把”名字 → 类型对象”映射好:

  • nodes: { readonly [name: string]: NodeType }
  • marks: { readonly [name: string]: MarkType }

schema.nodes[name] / schema.marks[name] 拿到的就是那个被缓存的、唯一的 NodeType / MarkType 单例。正因为唯一,判类型才能用引用相等(node.type === schema.nodes.heading),命令、插件、协同各处的类型判断都建立在这之上(下游怎么用见 State 与 TransactionView 与 NodeView;协同对类型身份的依赖见 协同编辑)。

易错点小结

  • create() 不校验内容:它只检查并补默认属性,能造出违反 content 表达式的非法节点而不报错。要在构造点拦住非法结构用 createChecked();不确定能否补齐时用 createAndFill() 并处理 null
  • createAndFill() 对给定内容找不到可行包裹时返回 null,必须判空;目标 node 有必填 attrs 时也必须先提供 attrs,不能指望它自动生成。
  • MarkType.isInSet 返回 Mark | undefined(ref 文档常写成 null),判断时用真值或 != null,别严格比较 result === null
  • 把 mark 加进集合的 addToSet 不在 MarkType——它是 Mark 实例方法 mark.addToSet(set),负责按 excludes 规则插入并顶掉互斥 mark。MarkType 上只有 create / isInSet / excludes / removeFromSet
  • isLeaf / isAtom / isInline / whitespace 都是从 contentMatchspec 推导的 getter,不是可赋值字段;要改行为得改 spec 后重建 schema。
  • hasRequiredAttrs() 为真的节点类型无法被 createAndFill 自动补出(缺必填属性造不出默认节点)。
  • type 是 per-schema 单例:跨不同 Schema 实例的同名类型并不相等,用引用相等比类型身份的前提是同一个 schema。

进阶参考:不可变与结构共享的性能含义

到这里可以正式回答开头的问题:为什么用不可变树,这样设计带来了什么?

结构共享让”创建新树”变便宜

每次修改虽然产生新的 Node / Fragment,但未改变的子树会被新树原样引用(persistent data structure)。改第 3 个段落里的一个字,只有”那个段落 + 文档根”会被重建,前两个段落的 Node 对象引用保持不变。

const doc1 = schema.nodes.doc.create(null, [
  schema.nodes.paragraph.create(null, schema.text('alpha')),
  schema.nodes.paragraph.create(null, schema.text('beta')),
  schema.nodes.paragraph.create(null, schema.text('gamma')),
])
 
// 经过一次只改第 2 段的变换,得到 doc2(实际生产中由 Transform/Transaction 产生)
// 关键不变式:
doc1.child(0) === doc2.child(0) // true —— 第 1 段被共享
doc1.child(2) === doc2.child(2) // true —— 第 3 段被共享
doc1            !== doc2        // true —— 只有被改的段 + 根换了身份

于是增量成本与改动范围成正比,而不是与文档大小成正比——快照便宜,版本对比退化为引用比较。

不可变换来的三件事

  1. 廉价的版本对比 / 撤销重做基础:旧 doc 快照可以廉价保留;对比两版文档先比引用,引用相等即整棵相等。实际 history 还会保存可逆 Step 和选区映射,不只是保存一个 doc 引用。
  2. 可预测、可测试的变更:数据变换无副作用,推理和单测都简单。每一步变更被 Transform 记录为可序列化的 Step,并附带把旧坐标映射到新坐标的 Mapping——这条 Step/Mapping 链正是 undo 与协同 OT/CRDT 的基础(见 协同编辑)。
  3. 与 DOM 的本质区别:DOM 是命令式、可变、难追踪的;ProseMirror 是声明式的值。完整历史要由 Step、history 插件或协同层显式保留,而不是隐含在对象引用里;这也和 React / Redux 用不可变值驱动 UI 的哲学一致。

不可变靠约定,不靠冻结

ProseMirror 对每个 Node 调 Object.freeze——运行时冻结会严重拖累性能。不可变是约定(“不要改”),不是强制。这条约定也是最容易踩的坑:Node / Fragment 几乎总是被多个数据结构共享,就地改 attrs、mutate marks 数组、重新赋值属性,会静默破坏所有共享它的地方,且 JavaScript 不会报错。任何”修改”都必须经由工厂方法(Node.createFragment.fromschema.text)或 Transform / Transaction,产生新对象。

常见坑(Gotchas)

  • text 不能 NodeType.create():用 schema.text();否则抛 can't construct text nodes
  • 不要就地 mutate:节点共享 + 无冻结 = 静默数据腐坏。一律走工厂方法 / Transform。
  • 索引 ≠ 位置:子节点索引(0,1,2…)与绝对位置(0,1,7,8…)是两套数,用 resolve() 转换。
  • openStart / openEnd 是深度不是布尔:1 表示”被从某层父节点中间切开”,插入时需智能闭合。
  • Fragment.from 会自动合并相邻同 marks 文本:from([t1, t2])(同 marks)得到的是一个合并节点,不是两个。这是不变量,别依赖”它们仍是两个节点”。
  • marks 顺序由 Schema 规范化:同样的 marks 但顺序不同会被判为不相等。用 schema 提供的方法保证顺序。
  • content 永远是 Fragment:空容器也有空 Fragment;测空用 childCount === 0
  • start(depth)/end(depth) 的 depth 要 <= $pos.depth:越界行为未定义。

与其它内核笔记的关系

参考