上一篇看了 example-setup 怎么把一批插件装配成一个能用的编辑器。这篇不看编辑器本身,看支撑各包测试的基础设施。翻 prosemirror-transform 的测试目录,满眼是这种写法:
add(doc(p("hello <a>there<b>!")),
schema.mark("strong"),
doc(p("hello ", strong("there"), "!")))文档用函数调用拼出来,位置用尖括号标在字符串里,第三个参数给出期望文档。负责这套写法的是 prosemirror-test-builder,全部代码只有 src/build.ts 和 src/index.ts 两个文件,合计一百六十来行。这篇把这一百六十来行读完:builder 函数怎么从 schema 生成、尖括号怎么变成位置、mark builder 为什么返回一堆节点,以及 transform 的测试怎么把同一组标签用两次。参考代码是 prosemirror-test-builder 的 629d824;对照用法看的 prosemirror-transform 是 662b7a9;顺带引用的 prosemirror-model、prosemirror-schema-basic、prosemirror-schema-list、prosemirror-state、prosemirror-tables 分别是 6264de0、756726f、1501619、ffad5d9、eb522f2。
系列目录
这个包解决的两件事
写 transform、state 层的测试,每个用例需要三样东西:操作前的文档、操作的位置参数、操作后的期望文档。拿 schema.node(“doc”, …) 手写节点树,嵌套一深就没法读;位置参数更麻烦,要按位置约定自己数:块的开标签占一个位置,每个字符占一个位置。doc(p("foo")) 里段落末尾是 4,doc(blockquote(p("foo"))) 里同样的字符后面是 5,多套一层块全部数字加一,数错一位整个用例白跑。test-builder 把这两件事分别交给 builder 函数和字符串里的 <name> 标签,位置从内容里自动算出来,测试作者只需要关心文档长什么样。
builders:从 schema 生成一套函数
build.ts 的入口是 builders(schema, names)。它遍历 schema.nodes 和 schema.marks,给每个名字生成对应 builder,挂在返回对象上,对象上还带一个 schema 字段。第二个参数 names 可以加别名:值是一个 attrs 对象,里面用 nodeType 或 markType 指定底层类型,其余字段作为这个别名的默认 attrs。
节点 builder 由 block(type, attrs) 生成,签名允许第一个参数是 attrs、后面跟任意个子节点。区分靠 takeAttrs(src/build.ts):首参是字符串、Node 实例或带 flat 属性的对象时,认为调用方没传 attrs,直接用默认值;否则把首参从参数列表里 shift 出来,和默认 attrs 合并,调用方的键覆盖默认键。所以 p("x") 和 h1({level: 4}, "title") 都合法,后者临时盖住别名里预置的 level 1。首参传 null 或 undefined 同样拿到默认值:takeAttrs 只在首参为真值时才检查它的类型,空值虽然会走到 shift 那一步,但随即被 if (!a0) return attrs 挡回默认 attrs。
开头的 addMark 例子按这套规则拆开看:doc(p("hello <a>there<b>!")) 造出的文档 tag 是 {a: 7, b: 12},a 在 “hello ” 之后,b 在 “there” 之后,各自再加 p 开标签占的一位。addMark(tag(doc, “a”), tag(doc, “b”), mark) 实际执行的就是 addMark(7, 12, strong),只是这两个数字从头到尾没有出现在测试代码里。
别名解析有一个回退:类型名取 value.nodeType || value.markType || name。带 nodeType 或 markType 时是换类型加默认 attrs(h1 指定 heading 加 level 1);都不带时按别名自己的名字找类型,attrs 纯当默认值用。注意 value 对象是整体当默认 attrs 传下去的,nodeType 这个键也在里面;它不会混进造出来的节点,因为 model 的 computeAttrs 只按 schema 声明过的属性逐个取值,未声明的键直接忽略。build.ts 顶部还定义了 NodeBuilder、MarkBuilder 两个函数类型和 Builders 映射类型,后者按 schema 的 nodes、marks 键逐个生成签名,另加一个字符串索引签名兜底,builders(mySchema) 的返回值因此有类型信息可查。
子节点收齐后交给 flatten(下一节细说),最后用 type.create(myAttrs, nodes) 造节点。create 不做内容校验,校验在 createChecked 里(两者的分工见 prosemirror-model 的 src/schema.ts)。这意味着测试可以造出 schema 不允许的文档。对 transform 的用例这反而是必要的:很多输入本来就是合法操作序列中间才会出现的形态,校验卡住就写不了了。
flatten:尖括号怎么变成位置
flatten 是 build.ts 的核心,签名是 (schema, children, f),返回 {nodes, tag}。它做两件事:把 ChildSpec 列表摊平成节点数组,同时维护一本 tag 账,记录每个标签在已产出内容里的累计位置。第三个参数 f 是节点出站前的处理钩子:block 传的是恒等函数 id,节点原样透传;mark builder 传的是刷 mark 的闭包,下一节会看到。同一个 flatten 靠这个钩子同时服务两种 builder。
先说账放在哪。build.ts 顶部有一行不太起眼的代码:
const noTag = (Node.prototype as any).tag = Object.create(null)它往 Node.prototype 上挂了一个共享的空对象当默认 tag。带来两个效果:任何节点都有 .tag 属性,测试里 node.tag.a 取不到时是 undefined 而不会抛错;flatten 判断「这个子节点自己带没带标签」只要比较 child.tag != Node.prototype.tag,不需要额外标志位。共享的前提是没人往这个对象上写:flatten 里每次要记账前先判断 tag == noTag,是就先换一个新的空对象再写,共享对象从头到尾保持空。
字符串参数用 /<(\w+)>/g 扫一遍。pos 变量只累计可见字符数,每命中一对尖括号就把标签名和当前 pos 记进账里,尖括号本身不进文本、不占位置。扫完如果还有剩余文本,schema.text(out) 造成文本节点推入结果。这里有两个细节值得记住:
- 多个标签可以挤在同一位置,
<a><b>两个名字记同一个数。 - 字符串里只有标签没有文字时,out 是空串,什么都不推入。所以
doc(blockquote(p("x")), "<cursor>", p("y"))合法,标签落在两个块之间的位置,而这个位置本来不允许存在文本节点。
拿 p("one <a>two ") 走一遍扫描过程:正则命中 <a> 时 m.index 是 4,pos 从 0 加上这 4 个字符,账上记 a: 4,at 跳到尖括号之后继续扫,“two ” 四个字符进 out 也进 pos。扫完 out 是 “one two “,造成一个 8 字符的文本节点,标签本身在文本里不留痕迹。正则只认 \w+,标签名里不能带横杠或点,想标 range-start 这类名字只能写成 rangeStart。
子节点是 builder 产物时,账要平移。子节点自己的 tag 是在它内部坐标系里算的,从它的内容起点计 0;并入父节点时要加上它前面兄弟已经占掉的 pos。普通节点还要再加 1,跳过它自己的开标签 token;mark 的产物(带 flat)和文本节点没有包裹 token,加 0。build.ts 里就是一行:
tag[id] = child.tag[id] + (child.flat || child.isText ? 0 : 1) + pos平移完的 tag 由 block 挂到造好的节点上(tag 有内容时才覆盖原型上的共享空对象),跟着节点一路传到最外层的 doc。这套位置编号和文档绝对位置的约定一致,就是第 7 篇 ResolvedPos 讲过的那套:开标签占一位,字符各占一位。
图里是一个完整例子:doc(p("one <a>two ", em("three<b> four")))。p 内部坐标里 a 是 4、b 是 13;并入 doc 时整体加 1,doc.tag 是 {a: 5, b: 14}。em 是 mark,它的产物经 flat 展开,不引入包裹 token,所以 b 的位置和纯文本情形连续。
mark builder 返回一堆节点
mark(type, attrs) 生成的函数,返回值是 {flat: nodes, tag},属于 ChildSpec 的一种。原因在 model 层的设计里:mark 不进树,挂在 inline 节点的 marks 数组上(第 5 篇),单独一个 mark 没有对应的独立节点可造。所以 mark builder 递归 flatten 自己的子参数,在回调 f 里对每个节点做 mark.addToSet(n.marks),把标记刷到所有文本上。
addToSet 的返回值被顺手用来做去重:新集合长度没变,说明同类型同 attrs 的 mark 已经在,节点原样保留;真的加了新 mark 才 n.mark(newMarks) 换节点。效果在这个包自己的测试(test/test-marks.ts)里能看到:a({href: "/foo"}, a({href: "/foo"}, "click here")) 只剩一个 mark,href 不同则两个都留,测试用的 schema 里这个 mark 的 excludes 为空,允许同类型并存。
tag 账在 mark 的产物里同样有效。test-marks.ts 的去重用例实际写的是 a({href: "/foo"}, a({href: "/foo"}, "click <p>here")),内层 flatten 记下 p: 6(“click ” 六个字符),外层 mark 的 flatten 平移时 flat 产物加 0,标签一路传到 doc 上。用例末尾直接拿它做断言:ist(actual.nodeAt(actual.tag.p).marks.length, 1),nodeAt 解析到的正是 “click here” 这个文本节点。
mark builder 的首参也走 takeAttrs,所以 index.ts 预置了 href: “foo” 的 a 可以被临时覆盖:transform 测试里「用不同 attrs 覆盖 mark」的用例就是 doc(p("this is a ", a({href: "bar"}, "link")))。
flat 属性还带来嵌套能力。strong(em("x")) 里 em 的产物是个 flat 数组,strong 的 flatten 把它当普通子节点展开,逐个刷上 strong,marks 数组自然累积,标签账也照常平移。
叶节点 builder 有另一个技巧。block 生成函数时会试一次 result.flat = [type.create(attrs)],成功的话这个 builder 不调用也能直接当子节点用:p("foo", br, "bar") 里的 br 是函数对象本身,flatten 检测到 flat 属性就展开。try/catch 兜住的是带必填 attrs 的叶节点:比如 image 的 src 没有默认值时 create 会抛,这种 builder 没有 flat,必须显式调用并传参。index.ts 给 img 别名预置了 src: “img.png”,所以测试里 img 可以裸用。
index.ts:一套开箱即用的测试 schema
src/index.ts 不到五十行。它用 schema-basic 的节点加上 schema-list 的 addListNodes("paragraph block*", "block") 拼出测试 schema,然后预置一批别名:p 是 paragraph,pre 是 code_block,h1/h2/h3 是 heading 加 level,li/ul/ol 是列表三件套,br 是 hard_break,img 带默认 src,hr 是 horizontal_rule,a 是 href 为 “foo” 的 link mark。doc、em、strong 这些名字来自 schema 本身,别名来自 builders 的第二个参数。
一个容易看混的点:a 这个别名是 link mark 的 builder,而 <a> 在字符串里是位置标签,两者完全无关,只是恰好共用了字母。测试里 a("<a>link<b>") 这种写法两个都在用:外层 a(…) 加链接,尖括号记位置。index.ts 还导出一个 eq 辅助函数,本体是 a.eq(b),给断言库当深比较器用。各包测试 import 的就是这份导出清单:doc、p、pre、h1 到 h3、li、ul、ol、img、hr、br、blockquote 是 NodeBuilder,a、em、strong、code 是 MarkBuilder,类型上分开,调用方式一致。
transform 的测试把标签用两次
prosemirror-transform/test/test-trans.ts 开头定义了小函数 tag(node, name):读 node.tag[name],取不到就抛错,避免 undefined 位置让用例假通过。每个用例形如开头的 addMark 例子,add 的实现是 new Transform(doc).addMark(tag(doc, "a"), tag(doc, "b"), mark),然后交给 test/trans.ts 的 testTransform。标签在这里第一次被消费:作为操作的位置参数。
testTransform 做四件事。第一,ist(tr.doc, expect, eq),变换结果和期望文档逐节点相等。第二,invert:把所有 step 逆序取反施一遍,要求回到 tr.before,验证 step 的可逆性。第三,step 的 JSON round trip:每个 step toJSON 再 fromJSON 重放,结果仍要等于 expect。第四件事又用到标签:遍历 expect.tag 里的每个名字,要求 tr.mapping 把操作前文档里同名标签的位置,正好映射到期望文档里这个标签的位置;内部还会把 mapping 里的每个 StepMap 逐个 invert、逆序拼成一个新 Mapping,要求同一个位置映回原值,两个方向都校验。映射时 assoc 参数固定传 1,标签位置按与右侧内容关联处理。这是标签的第二次消费:作为位置映射的断言锚点。
第二次消费让位置断言也自动化了。操作前和操作后的文档是两次独立的 builder 调用,标签位置各自从各自的内容里算。变换实现错了,要么 eq 挂,要么映射断言挂,测试作者从头到尾没有数过一个位置。用例后来要改,比如在文档里加一段引言,所有标签位置跟着内容自动重算,不用回头修数字。
trans.ts 里还有一段工程化的细节。设置 EMIT_JSON 环境变量时,outputTransform 会把每个跑过的用例导出成 JSON:schema、起始文档、steps、期望结果,以及每个标签从操作前到操作后的位置对。这批数据可以脱离 JS 运行时重放,换语言实现同一套变换算法时,直接拿这份用例集做一致性校验。builder 表达式因此除了给人读,还充当可序列化测试资产的源头。
标签名本身没有任何约定,正则认 \w+ 就行,但各包测试里形成了一套习惯:a 和 b 标一个区间的两端,cursor、anchor、head 标选区相关位置。prosemirror-state 的选区测试就是靠 a、b 两个标签构造 TextSelection:TextSelection.between(d.resolve(d.tag.b), d.resolve(d.tag.a)),选区方向和内容直接写在 builder 表达式里。
自己项目里的用法
预置的那套 schema 只覆盖 schema-basic 加列表,自定义了 schema 的项目要走另一条路:调 builders(mySchema, {别名表}) 生成自己的 builder 集。prosemirror-tables 的测试就是这么做的,它的 test/build.ts 用 tableNodes 拼出自己的 schema,然后生成 p、tr、td、th 四个别名,还在此基础上封了一层带标签的快捷件:cCursor = td(p("x<cursor>")) 造一个光标在末尾的单元格,selectionFor 读 doc.tag.cursor 直接构造 TextSelection。
cCursor 只是快捷件里最常用的一个,同一文件还有 cAnchor、cHead,分别把 anchor 和 head 标签预置在单元格里;selectionFor 读不到 cursor 时会退回这两个标签,组合出一个 CellSelection。commands.test.ts 里的典型用例长这样:table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)),三行三列的表格、光标在正中间那一格,文档结构和选区位置一眼读完,c11 是 colspan、rowspan 都为 1 的普通单元格。表格那种位置数起来极容易错的结构,收益更明显。
回头看这个包的全部机制:builder 函数拼节点、正则扫标签、tag 账随嵌套平移,再加一个挂在原型上的空对象。一百多行代码换来整个测试套件里没有一个手写位置数字,这个交换很划算。下一篇看这些包本身是怎么构建和发布的。

