前几篇把 model 层的存储和修改读完了:Node 与 Fragment 怎么存文档,Mark 怎么挂在文本上,Schema 怎么约束结构,位置怎么解析成路径,Slice 怎么切下来再塞回去。还有一个方向没碰:文档怎么离开模型,变成浏览器里的 DOM 和可以复制的 HTML。这篇读 src/to_dom.ts,文件不大,一个 DOMSerializer 类加一个 renderSpec 函数就是全部。参考代码是 prosemirror-model 的 6264de0。第 6 篇讲 NodeSpec 和 MarkSpec 时把 toDOM、parseDOM 两个字段留到了后面,这篇兑现 toDOM 这一半。
系列目录
| 日期 | 标题 |
|---|---|
| 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:一个数字位置怎么变成路径 |
| 07-12 | Slice 与 replace:切一块文档出来再塞回去 |
| 07-19 | DOMSerializer:文档怎么变成 DOM 和 HTML(本篇) |
DOMOutputSpec:描述 DOM 的三种写法
NodeSpec 和 MarkSpec 上的 toDOM 字段返回一个 DOMOutputSpec,类型定义在 src/to_dom.ts 顶部,三种合法形态:
- 一个 DOM 节点。
{dom, contentDOM}对象,dom 是这个节点对应的元素,contentDOM 显式指出内容往哪个后代元素里插。- 数组。这是最常用的形态,schema-basic 里的规格全是数组。
类型上写了三种,实际写 toDOM 时基本只用数组。renderSpec 入口对前两种形态只放行了文本节点:structure 本身是文本节点(nodeType 为 3),或者 {dom} 对象的 dom 是文本节点时,才原样返回;手建的元素或者 {dom: 元素} 对象都会落到数组解析那一段,因为 0 号位取不出字符串标签名而抛 “Invalid array passed to renderSpec”。下一节看 renderSpec 的代码时能对上这两个分支。
数组的约定写在类型注释里:0 号位是标签名,可以加「命名空间 URL + 空格」前缀,需要 createElementNS 的元素(比如 SVG)靠这个前缀。第二个元素如果是纯对象(不是数组、没有 nodeType),当成属性表;否则从第二位起全是子元素。子元素可以是嵌套的规格数组、字符串(变成文本节点),或者数字 0。
0 读作 hole,洞,标记「这个节点的内容插到洞所在的元素里」。洞有三条约束:它必须是父元素的唯一子元素;一份规格里最多出现一个;叶节点的规格里不允许有洞。三条各对应一个 RangeError,前两个在 renderSpec 里抛,最后一个在 serializeNodeInner 里抛。
约束背后的原因可以想明白。序列化内容就是往 contentDOM 里反复 appendChild,洞旁边如果允许有兄弟元素,内容永远插在所有兄弟之后,洞的位置语义就乱了,索性禁止。一份规格里如果出现两个洞,内容往哪个插没有答案,也禁止。叶节点没有内容,洞没有去处,同样禁止。三条约束把「内容插入点」收敛成每份规格里唯一、无歧义的一个元素。反方向也成立:规格里没有洞时 renderSpec 返回的 contentDOM 是 undefined,serializeNodeInner 只有拿到 contentDOM 才会递归子内容,所以有内容的节点规格必须带洞,漏了洞子内容就静默丢掉。
看几个实际规格。参考代码是 prosemirror-schema-basic 的 756726f,src/schema-basic.ts 里:
const pDOM = ["p", 0], preDOM = ["pre", ["code", 0]]
// heading
toDOM(node) { return ["h" + node.attrs.level, 0] }
// image,叶节点
toDOM(node) { let {src, alt, title} = node.attrs; return ["img", {src, alt, title}] }
// link,mark
toDOM(mark) { let {href, title} = mark.attrs; return ["a", {href, title}, 0] }code_block 的 preDOM 是嵌套的例子:外层 pre 没有洞,洞在内层 code 里,renderSpec 递归时把内层的 contentDOM 透传到上层,最终返回 {dom: pre元素, contentDOM: code元素}。image 是叶节点,attrs 铺成属性,没有洞。link 是 mark,洞表示被标记的内容包在 a 元素里面。
renderSpec:规格到 DOM 的一次递归
renderSpec(doc, structure, xmlNS, blockArraysIn) 负责把规格变成真实 DOM。入口先处理两个特例:structure 本身是文本节点(nodeType 为 3)就原样返回;{dom} 形态且 dom 是文本节点也原样返回。之后才按数组解析,0 号位取不出字符串标签名时抛 “Invalid array passed to renderSpec”,畸形输入在这一步就被拦下。
属性表的处理有几个细节:值为 null 的键跳过;键名是 style 时走 dom.style.cssText;键名带空格的按「命名空间 空格 属性名」拆开走 setAttributeNS。子元素逐个递归 renderSpec 再 appendChild,命名空间沿递归传下去,带前缀的元素的子元素默认继承同一个命名空间。遇到 0 时先检查它是父元素的唯一子元素,然后直接返回 {dom, contentDOM: dom},洞所在的元素自己当 contentDOM。递归返回时子规格如果带回了 contentDOM,上层还没记过洞就接住,记过了就抛 “Multiple content holes”。
第四个参数 blockArraysIn 是一个安全装置。调用方 serializeNodeInner 和 serializeMark 会把 node.attrs、mark.attrs 原样传进来。suspiciousAttributes 扫描这份 attrs,找出所有「第一个元素是字符串」的数组,也就是所有长得像 DOM 规格的数组,结果用 WeakMap 按 attrs 对象缓存。renderSpec 每次解析数组规格之前先查一次:手上这个数组如果是 attrs 里扫出来的某一个,抛 RangeError,错误信息里明说这是可能的 XSS 攻击。场景是这样的:attrs 可以来自不可信的 JSON,而 toDOM 的习惯写法是把 attrs 的值直接铺进规格,比如 ["a", {href: mark.attrs.href}, 0]。如果攻击者把某个 attr 伪造成 ["img", {src: "x", onerror: "..."}] 这样的数组,toDOM 再把它当子元素塞进去,renderSpec 就会把它解析成真实元素。这个检查把「attrs 里的数组永远不允许被当成规格」立成硬规则,代价是序列化时对 attrs 做一次扫描,用缓存摊薄。
类上另有一个公开的静态方法 DOMSerializer.renderSpec,比内部函数多一个分支:传入字符串时创建文本节点。源码注释自称是兼容旧行为的 kludge,照实记录。
serializeFragment:Mark 的包裹与合并
serializeFragment 把一个 Fragment 序列化进 target(缺省新建一个 DocumentFragment),主要的工作量是处理 mark。第 5 篇说过 mark 不进树,挂在每个 inline 节点的 marks 数组上,相邻文本节点各自背一份。序列化时不能让每个文本节点都被自己的 em 单独包一层,否则一段被拆成多个文本节点存储的斜体会变成一串紧挨着的 em 元素。期望的行为是:相同 mark 覆盖的相邻节点合并进同一个包装元素。
实现靠一个 active 栈,栈元素是 [mark, 它的父 DOM] 对。每处理一个节点,先算当前节点的 marks 和 active 栈的公共前缀:
while (keep < active.length && rendered < node.marks.length) {
let next = node.marks[rendered]
if (!this.marks[next.type.name]) { rendered++; continue }
if (!next.eq(active[keep][0]) || next.type.spec.spanning === false) break
keep++; rendered++
}
while (keep < active.length) top = active.pop()![1]第一个循环从栈底往上比,mark 完全相等(eq 连 attrs 一起比)就保留一层;比不上的、或者类型上 spanning 为 false 的,循环终止。第二个循环把前缀之外的活跃 mark 全部弹栈,top 退回对应层级。接下来的第三个循环把当前节点多出来的 mark 逐个处理:serializeMark 得到 markDOM(序列化器为 null 的 mark 在这里被跳过),把 [mark, 当前 top] 压栈,markDOM.dom appendChild 进 top,然后 top 更新成 markDOM.contentDOM || markDOM.dom,钻进最深一层的插入点。mark 规格没有洞时插入点就是包装元素本身,MarkSpec.toDOM 的注释写了这层回落:规格里没有洞,内容就 append 到顶层节点。最后把节点本身的 DOM 插进 top。
沿用第 5 篇的例子,“ab”[em]、“c”[em, strong]、“de”[strong] 三个文本节点:
- 处理 “ab”:栈空,压入 em,文本进 em。
- 处理 “c”:栈底 em 与节点首个 mark 相等,保留;strong 是新 mark,压进 em 的内层,文本进 strong。
- 处理 “de”:节点首个 mark 是 strong,与栈底 em 不等,公共前缀为空,strong 和 em 依次弹掉,重新压一个 strong,文本进去。
产出的 DOM 是 <em>ab<strong>c</strong></em><strong>de</strong>:em 跨两个节点只出现一次,strong 在 em 的边界上断开,重新开了一个元素。MarkSpec 上 spanning 的语义就落在这里:默认 true 参与合并,设成 false 之后这个 mark 相邻相同也各自包一层,前缀比较时直接 break。
几个边界。序列化表里查不到的 mark 会被跳过(continue 那一行),内容照常输出,只是少了那层包装;构造函数注释说明,mark 的序列化器允许是 null,表示这种 mark 不序列化。包裹的嵌套顺序由 marks 数组的顺序决定,第 5 篇讲过这个数组按类型的 rank(schema 声明顺序)排序,所以同一段内容的 DOM 结构是确定的,不依赖遍历顺序的偶然。另外还有一个 serializeNode 处理单个节点:先序列化节点本身,再从后往前遍历 marks 数组,逐层把已得到的 DOM 塞进 mark 规格的插入点(contentDOM || dom)。从后往前包,第一个 mark 落在最外层,和 serializeFragment 产出的嵌套结构一致,适用于只序列化文档一部分的场景。
DocumentFragment 与非浏览器环境
serializeFragment 的产物默认是 DocumentFragment,不传 target 时开头就 createDocumentFragment 建一个。节点内容的递归也走这条路:serializeNodeInner 拿到 contentDOM 之后,把 node.content 序列化进 contentDOM,contentDOM 充当下一层的 target。叶节点配 contentDOM 在这里抛错,叶节点没有内容,洞没有去处。
序列化整篇文档的做法是对 doc.content 调 serializeFragment,doc 节点自己不进产物。这是刻意的:doc 是文档的根容器,HTML 里没有对应物,导出时拿到的应该是一段可以嵌进任何页面的片段。serializeNode 的注释里也写明了这个分工:序列化文档的一部分用 serializeNode,整篇用 serializeFragment 包它的 content。
document 对象由 doc() 这个三行函数回答:options.document || window.document。浏览器里不用管;在浏览器外跑序列化(服务端渲染、测试)时把外部 document 从 options 传进来。整个文件只有这一处碰 window。
文本节点有快捷路径:serializeNodeInner 开头判断 node.isText,直接 createTextNode,不经过规格。nodesFromSchema 会补一个默认的 text 序列化器(node => node.text,返回字符串规格),这是给自定义序列化器留的口子。NodeSpec.toDOM 的注释同时写明:编辑器内部不支持给文本节点定制渲染,自己的 schema 里不要去覆盖 text 的 toDOM。
fromSchema 与 schema 缓存
大多数情况下不用手写 nodes 和 marks 两张表。DOMSerializer.fromSchema(schema) 内部调 nodesFromSchema 和 marksFromSchema,两者都走 gatherToDOM:遍历 schema 的类型表,把每个 spec 上的 toDOM 收集成 {名字: 函数}。建好的序列化器缓存在 schema.cached.domSerializer 上,一个 schema 只建一次。nodesFromSchema 和 marksFromSchema 是公开的静态方法,想定制序列化时(比如导出静态 HTML 要给某类节点换个标签)通常以它们返回的表为基础改几个键,再 new 一个 DOMSerializer,不用从头写。
marks 表里的函数签名比 nodes 多一个参数:(mark, inline),inline 表示被标记的内容是不是 inline 内容。构造函数注释里说典型用法下它总是 true,留出这个参数是给「mark 出现在块内容上」的自定义场景。
谁在消费这套序列化
值得分清的是,编辑器的实时渲染不走 serializeFragment。view 层的 ViewDesc 树(prosemirror-view/src/viewdesc.ts)是直接调节点和 mark 各自 spec 上的 toDOM,再经 DOMSerializer.renderSpec 落成 DOM 的,因为实时渲染要增量更新,要在元素上挂内部引用,一次性序列化整个 Fragment 用不上。serializeFragment 的消费方是一次性导出场景:剪贴板序列化(src/clipboard.ts 的 serializeForClipboard,可用 clipboardSerializer 属性整体替换)算一个,自己做静态 HTML 导出算另一个。这也解释了 renderSpec 为什么要做成公开的静态方法:view 和 model 两边都要用,它实际是整个库里「规格变 DOM」的唯一入口。
纯文本那条路:leafText 的约定
serializeFragment 解决的是 text/html。复制操作还需要一份 text/plain,这份不走 DOMSerializer。view 层的 serializeForClipboard(参考代码是 prosemirror-view 的 ca4c78e,src/clipboard.ts)里,纯文本缺省由 slice.content.textBetween(0, slice.content.size, "\n\n") 生成,配置了 clipboardTextSerializer 时才换路。模型这一层对应的约定在 src/fragment.ts 的 textBetween:
- 文本节点贡献区间内的文字。
- 非文本的叶节点贡献 leafText 参数;参数没给就用节点类型的 spec.leafText;都没有就是空串,这个叶节点在纯文本里消失。
- blockSeparator 插在每个文本块(以及带文本的叶块)前面,第一份不加,所以块与块之间隔 “\n\n”,首尾不会多出分隔符。
也就是说一个叶节点有两种相互独立的对外表示:HTML 里的样子由 toDOM 决定,纯文本里的样子由 leafText 决定。leafText 拿到节点本身,可以读 attrs 再决定返回什么。图片可以 toDOM 成 img 元素、leafText 返回 alt 文本;自定义的嵌入节点想被复制后留个占位符,给 leafText 一个字符串就行;不定义 leafText 的叶节点在纯文本里不产生任何字符,schema-basic 的 hard_break 就没有定义 leafText,段落里的手动换行复制成纯文本后留不下任何字符,纯文本里的换行只来自块与块之间的 blockSeparator。Node.textContent 走的也是同一份 leafText 约定,调试时打印文档看到的字符串和它一致。
这篇把文档到 DOM、HTML、纯文本三个方向的出口看完了。整个文件读下来的印象是:核心机制只有「规格解析」和「mark 前缀合并」两件事,剩下的行都在处理边界:洞的三条约束、XSS 检查、非浏览器环境的 document 注入。toDOM 和 parseDOM 在 schema 里是成对出现的,下一篇读反方向的 src/from_dom.ts:DOMParser 怎么用 parseDOM 规则把外部 HTML 解析回文档,那是粘贴和外部输入的入口。

