ProseMirror model(下):Schema 与 content expression,文档的类型系统

5 分钟阅读
·

前两篇把文档树的存储看完了:Node 记类型、属性和内容,Fragment 缓存子节点和尺寸,Mark 挂在节点上不进树。这些对象都带着 type 字段指向 NodeType 或 MarkType,但类型对象从哪来、「这个节点里允许放什么」由谁回答,还没展开。这篇读 src/schema.tssrc/content.ts 两个文件:前者定义 Schema、NodeType、MarkType 三个类和一组 spec 接口,后者把 content 表达式编译成一台有限自动机。参考代码是 prosemirror-model 的 6264de0。

系列目录

日期 标题
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,文档的类型系统(本篇)

NodeSpec 的字段

NodeSpec 是纯数据接口,描述一种节点允许什么、表现为什么。逐字段过一遍。

content 是内容表达式,声明子节点允许的类型和顺序,写法类似正则,不给则节点不允许任何内容。这是本篇后半的主题。group 是空格分隔的组名列表,供其他节点的 content 表达式引用:paragraph 声明 group: "block" 之后,别的节点写 "block+" 就涵盖了它。组是表达式里唯一的复用机制,类型之间没有继承。

marks 声明节点内容里允许哪些 mark,有四种写法:"_" 显式允许全部;空格分隔的 mark 名或 mark 组名;"" 显式禁止;不写时有 inline 内容的节点默认允许全部,其余节点默认禁止。inline 为 true 表示 inline 节点(text 类型隐含),NodeType 上 isBlock 取它的反,isTextblock = isBlock && inlineContent,这组 getter 把节点分成块、行内、文本块三类。atom 表示节点没有可直接编辑的内容,view 层把它当成整体对待,isAtom = isLeaf || !!spec.atom,叶节点天然是 atom。

code 标记代码类节点,一些命令对它区别对待;whitespace 控制 DOM 解析时的空白处理,"pre" 保留空白,缺省情况下 code 为 true 时自动按 "pre" 处理。selectable 和 draggable 控制节点能否被 NodeSelection 选中、能否不选中直接拖动。

defining 是三个关联的开关。definingAsContext 表示替换操作(比如粘贴)中这个节点作为上下文要保留:普通父节点在内容被整个替换时会被丢弃,defining 的会留下来包住新内容。definingForContent 表示插入内容时尽量保留内容外侧的这类父节点。defining 等于同时打开前两个。消费方在 transform 的 replace 算法里,典型的定义对象是列表项和非默认段落的文本块。isolating 表示节点两侧成为编辑操作的边界,退格、lift、join 这类操作不越过它,表格单元格是典型例子;model 的 replace.ts 计算 Slice 打开深度时同样不进 isolating 节点。

toDOM 和 parseDOM 是序列化与解析规则,第 9、10 篇展开。leafText 给叶节点提供抽取纯文本时的替代字符串,toDebugString 定制调试输出,linebreakReplacement 把某个 inline 叶节点标为换行替代节点,全 schema 只能有一个,构造器里会检查。接口最后带一个 [key: string]: any,业务可以在 spec 上挂自定义字段,通过 NodeType.spec 读回,扩展包用这条路传递自己的元数据。

NodeType 构造器把这份纯数据变成可查询的类型对象:group 字符串 split 成 groups 数组,attrs 包成 Attribute,isBlock 由 spec.inline 和名字是否为 text 推出,contentMatch 和 inlineContent 先占位 null,等 Schema 构造器的主循环回填。一个 schema 里每种类型只有一个 NodeType 实例,类型比较一律用引用相等,这与 Node、Mark 上的约定一致。

MarkSpec 的字段

MarkSpec 比 NodeSpec 小一圈,但有几个字段直接决定格式的行为。

attrs 与节点同套机制。inclusive 控制光标停在 mark 边界上时这个 mark 算不算激活,默认 true,链接是反例的典型:光标停在链接末尾继续打字,多数人期望新文字不进链接,把 inclusive 设为 false 就能做到。excludes 声明互斥关系,缺省时只排除自己(同一类型不能叠两个),设成 "" 则允许同类型不同 attrs 的多个实例共存,设成 "_" 排除所有 mark,也可以写其他 mark 的名字或组名。spanning 控制序列化成 DOM 时能否跨相邻节点合并,默认 true。code 把这段内容标为代码,一些命令和扩展据此区别对待,code mark 通常同时配 excludes: "_",让代码段里容不下其他格式。group 让 mark 也能被按组引用,gatherMarks 和 marks 表达式都认。toDOM、parseDOM 是序列化与解析规则,同样留给第 9、10 篇。

MarkType 实例上有几个派生成员:rank 是 compile 时按声明顺序发的序号,mark 集合的排序用它;instance 是全默认值时的共享 Mark 单例;excluded 是 Schema 构造器里算好的互斥列表。removeFromSet、isInSet、excludes 三个方法上一篇讲集合操作时已经见过,这里知道它们挂在类型对象上就够了。

attrs:声明、默认值与校验

attrs 里每个属性由 AttributeSpec 声明,两个字段:default 和 validate。Schema 构建时 initAttrs 把每个 spec 包成 Attribute 实例:

  • hasDefault 用 hasOwnProperty 判断,显式写 default: undefined 也算有默认值;isRequired 就是 !hasDefault
  • validate 可以是函数,也可以是 "number" 这样的类型名字符串(| 分隔多个候选),字符串形式由 validateType 转成函数,typeof 结果不在列表里就抛 RangeError。

填充由 computeAttrs 完成:每个声明过的属性取传入值,没传且有默认填默认,没传且没默认抛 RangeError(“No value supplied for attribute …“)。传入对象里出现未声明的属性由 checkAttrs 拦截,JSON 反序列化和 Node.check 走这条路。另一个方向也有处理:所有属性都有默认值时,defaultAttrs 预先算出一份共享的默认值对象,create 不传 attrs 时整类节点共享这一份,不逐个新建。MarkType 更进一步,默认值存在时直接缓存一个 Mark 单例(instance 字段),create(null) 每次返回同一个对象。

content expression 的写法与解析

content 是一个字符串,语法和正则对齐:类型名或组名是原子,空格是顺序连接,| 是选择,后缀 + * ?{n,m} 是重复,括号分组。几个例子:

  • doc: "block+",一个或多个块。
  • paragraph: "inline*",任意多个 inline,可以为空。
  • list_item: "paragraph block*",先一个段落,后面跟任意多个块。

解析在 ContentMatch.parse(src/content.ts)。TokenStream 先把表达式切成 token 数组,类型名、|、后缀符号各自成 token,空白被吃掉,然后三层递归下降:parseExpr 处理 |,parseExprSeq 处理序列,parseExprSubscript 处理后缀。原子由 parseExprAtom 处理,resolveName 先按类型名查,查不到再按组名扫一遍 isInGroup,组也查不到抛 SyntaxError。组名在表达式位置就地展开成 choice:写一个 "block",AST 里变成组内每个类型各一个 name 分支。

解析时顺带做一项静态检查:TokenStream.inline 记录见到的第一个类型的 inline 属性,后续类型与它不一致就报 “Mixing inline and block content”。一个表达式要么全 inline 要么全 block,混写在 schema 构建期就抛错,不用等文档校验才发现。

从 NFA 到 DFA

parse 完得到 Expr 树,接着两步编译。文件里有注释说明思路:把这套类正则语言编成确定有限自动机,并附了 rsc 那篇正则实现文章的链接。

第一步 nfa() 把 Expr 树编成 NFA。NFA 表示成状态数组,每个状态是一个边数组,边是 {term, to}:term 是 NodeType(空边的 term 为空),to 是目标状态下标。compile 按表达式类型递归构造:choice 把各分支的出边并到同一个出发状态;seq 把前一段的出边接到新状态再继续编下一段;star 建一个回环状态;plus 把表达式编两遍形成至少经过一次的环;opt 直接加一条跳过的空边;range 展开成 min 段必接加 max-min 段可选,{n,} 的末段自环。注释里特意说明:和典型 NFA 不同,这里的边序有意义,顺序来自表达式的书写顺序,后面生成补全节点时按这个顺序优先尝试。

第二步 dfa() 做子集构造。nullFrom 算一个状态经空边可达的状态集,跳过只有一条空出边的中间状态以减少冗余;explore 从初始集合出发,收集集合里所有带 term 的边,按 term 分组,目标集合经 nullFrom 闭包后成为一个 DFA 状态,递归展开。labeled 表以 states.join(",") 为键去重,相同的状态集复用同一个 ContentMatch,block* 的自环也因此收敛成一个状态。validEnd 的判定很直接:状态集里包含 NFA 的最后一个状态(成功状态)就是合法结束点。最后 checkForDeadEnds 走一遍所有状态,发现「不是合法结束、且出边全是 text 或带必填属性的类型」的位置就抛错,因为这种位置永远无法用可生成的节点填满,schema 写出来就废了。

"paragraph block*" 的编译管线与自动机

ContentMatch 就是 DFA 的一个状态:validEnd 标记结束合法性,next 是 {type, next} 边数组,matchType(type) 沿边走一步,matchFragment 对整个 Fragment 连续走。dfa 去重加上 Schema 构造器里的 contentExprCache(按表达式字符串缓存起始 ContentMatch),相同表达式的节点类型共享同一台自动机,全 schema 只编译一次。

ContentMatch 的运行时消费

匹配之外,ContentMatch 还回答三个编辑期的问题。

「这里默认能生成什么」:defaultType 返回第一条不是 text 且没有必填属性的边上的类型。边序保持表达式的书写顺序,所以 defaultType 取的就是写在前面且可生成的类型,"paragraph block*" 在空位置上默认生成 paragraph。

「塞不进去能不能补」:fillBefore(after, toEnd) 深度优先搜索,尝试在 after 前面插入可生成的节点(同样排除 text 和必填属性类型),让整体匹配成功,成功时返回要插入的 Fragment;seen 数组防环,toEnd 为 true 时要求补完必须走到合法结束。createAndFill 靠它在前后各补一轮:先 fillBefore(content) 补前面,matchFragment 走完再 fillBefore(Fragment.empty, true) 补后面,所以 list_item.createAndFill() 传空内容也能得到一个合法的 list_item(paragraph)

「目标类型要包几层才能放进来」:findWrapping 广度优先,从当前匹配位置出发,沿途把非叶、无必填属性的类型压入路径,直到某个状态能吃下目标类型,返回经过的类型序列,结果存进 wrapCache。给选区套 blockquote、把段落包进列表,走的都是这个查询。

ContentMatch 上还有一组调试接口。edgeCount 和 edge(n) 把出边当下标数组暴露出来,toString 把整台自动机打印成「状态下标 + 合法结束标记 + 各条边指向哪个状态」的多行文本,compatible 检查两台自动机有没有共同的出边类型(NodeType.compatibleContent 的底层),排查 schema 问题时这几个方法比断点单步快。

createChecked 的校验路径

回到 schema.ts。NodeType 上三个创建方法构成一个校验强度梯度:create 只填 attrs 不查内容;createChecked 先 checkContent 再建节点;createAndFill 尝试自动补全。checkContent 调 validContent,不通过就抛 RangeError 并附上内容字符串的前 50 个字符。validContent 两道工序:

let result = this.contentMatch.matchFragment(content)
if (!result || !result.validEnd) return false
for (let i = 0; i < content.childCount; i++)
  if (!this.allowsMarks(content.child(i).marks)) return false
return true

先在自动机上走完整段内容并要求停在合法结束,再逐个子节点过 allowsMarks。markSet 是 Schema 构造时按 marks 字段算好的允许列表,null 表示全允许。内容形状和 mark 归属是两条独立的校验,都过才算合法。

Schema.node 这个便捷方法内部走的就是 createChecked,所以用 schema.node("heading", null, [schema.text("x")]) 拼文档时每个节点都被验过一遍。直接 type.create 则可以造出非法中间态,transform 内部的算法路径用后者,由步骤的整体正确性保证最终结果合法,省掉中间每一步的校验开销。

Schema 构造器:compile 阶段做了什么

Schema 构造器把 spec 变成可运行的类型系统,按顺序做这些事:

  1. spec.nodes、spec.marks 一律转成 OrderedMap,定义顺序被保留:nodes 的顺序决定 parseDOM 规则的默认优先级和同组类型的排列,marks 的顺序决定 mark 集合的排序。上一篇 addToSet 按 rank 排序,rank 就是 MarkType.compile 按声明顺序发的序号。
  2. NodeType.compile 为每个名字建 NodeType,同时做硬性检查:topNode(默认 “doc”)必须存在,text 类型必须存在且不能声明 attrs。
  3. MarkType.compile 同样建 MarkType,顺带发 rank。
  4. 主循环处理每个节点类型:名字不能同时是 mark;按 content 表达式字符串查 contentExprCache,没命中才 ContentMatch.parse;inlineContent 从起始匹配回填;linebreakReplacement 全 schema 只能有一个且必须是 inline 叶节点;markSet 按 marks 字段的四种写法算出允许列表。
  5. 再循环每个 mark 类型算 excluded:excludes 缺省时只排除自己,"" 表示不排除任何 mark(允许同类型不同 attrs 的多个实例共存),否则 gatherMarks 按名字或组收集,"_" 在 gatherMarks 里匹配全部 mark。
  6. 绑定 nodeFromJSON、markFromJSON 两个便捷方法,记下 topNodeType,cached 上挂 wrappings 缓存。

gatherMarks 的查找逻辑值得单看:先按名字直查 schema.marks,查不到就把所有 mark 扫一遍,组名命中 spec.group 的全部收进来,最后什么都没找到抛 SyntaxError。这和内容表达式里 resolveName 对组的处理是同一条思路:schema 里类型名和组名构成一个两级的命名空间。

这套 compile 全是构建期一次性工作,产物是不可变的类型对象和共享的自动机。之后所有创建、校验、补全操作都只是查表和走图,不再碰表达式字符串。

小结

Schema 是文档的类型系统:NodeSpec 声明每种节点的形状,content 表达式编译成 DFA 回答「这里能放什么」,attrs 声明加 validate 管住属性,markSet 和 excluded 管住格式归属。校验集中在 createChecked 和 Node.check 两个入口,编辑算法走不校验的快路径,由更上层的正确性兜底。到这里,model 层的数据结构(Node、Fragment、Mark)和类型系统都看完了,下一篇换一个角度:文档里的整数位置怎么解析成带上下文的路径,ResolvedPos。


908 字 · 55 段落
xi ming

Written by xi ming You should follow him on Github