前几篇分析了 model 层的存储和修改:Node 与 Fragment 如何存储文档,Mark 如何挂在文本上,Schema 如何约束结构,位置如何解析为路径,以及 Slice 如何切出和插回内容。本篇转向模型输出,分析文档如何转换为浏览器 DOM 和可复制的 HTML。范围是 src/to_dom.ts,包含 DOMSerializer 类与 renderSpec 函数。参考代码是 prosemirror-model 的 6264de0;第 6 篇暂未展开的 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 里抛。
这些约束来自内容插入方式:序列化时,内容持续 appendChild 到 contentDOM。洞若有兄弟元素,内容会追加到所有兄弟之后,洞不再代表确定的插入位置;两个洞也无法确定目标。叶节点没有内容,因此不能包含洞。由此每份规格最多有一个无歧义的内容插入点。规格没有洞时,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 的 toDOM,再经 DOMSerializer.renderSpec 创建 DOM,因为实时渲染需要增量更新并在元素上保存内部引用。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 约定,调试时打印文档看到的字符串和它一致。
DOMSerializer 提供文档到 DOM、HTML 和纯文本的输出路径:renderSpec 解析规格,serializeFragment 合并相邻节点的 mark 包裹;洞约束、XSS 检查和 document 注入处理相应边界。Schema 中的 toDOM 与 parseDOM 成对出现。下一篇分析 src/from_dom.ts:DOMParser 如何用 parseDOM 规则将外部 HTML 解析为文档,这是粘贴和外部输入的入口。
