DOMParser:parseDOM 规则与 HTML 解析

5 分钟阅读
·

上一篇读了 toDOM 这一半,文档怎么序列化成 DOM 和 HTML。这篇读反方向的 src/from_dom.ts:拿到一段外部 DOM,比如剪贴板里的 HTML、服务端渲染的初始内容、别的编辑器导出的片段,怎么按 schema 的 parseDOM 规则解析回文档。这个文件比 to_dom.ts 大不少,DOMParser 类管规则的存储与匹配,ParseContext 类管解析过程的全部状态。参考代码是 prosemirror-model 的 6264de0。顺带交代同目录的 src/dom.ts:它只有一行,导出 DOMNode 类型别名,from_dom.ts 里的 DOM 参数全是这个类型。

系列目录

日期 标题
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
08-02 DOMParser:parseDOM 规则与 HTML 解析(本篇)

parseDOM 规则长什么样

NodeSpec 和 MarkSpec 上的 parseDOM 字段是一个规则数组,规则分两种:tag 规则(TagParseRule)用 CSS 选择器匹配元素,style 规则(StyleParseRule)匹配内联样式。两种规则共享的字段定义在 GenericParseRule 里:priority 调整匹配顺序,consuming 控制命中后是否放行后面的规则,context 限定上下文,mark 指定生成的 mark 类型,ignore 丢弃匹配到的内容,closeParent 命中时关掉当前节点,skip 跳过元素本身但解析它的内容,attrs 给一份静态属性。tag 规则在此之上多出 node、namespace、getAttrs、contentElement、getContent、preserveWhitespace 几个字段;style 规则多出 clearMark,并且只能产 mark,不能产节点。

拿 schema-basic 当例子,参考代码是 prosemirror-schema-basic 的 756726f。段落是 {tag: "p"},heading 是六条规则各带一个 level attr,image 用 getAttrs 从元素上读 src、title、alt。em 的规则有四条:

parseDOM: [
  {tag: "i"}, {tag: "em"},
  {style: "font-style=italic"},
  {style: "font-style=normal", clearMark: m => m.type.name == "em"}
]

最后一条不产 mark,反而把已经激活的 em 从 mark 集里滤掉,处理的是 <i> 里面套一个 font-style: normal 的「斜体容器中特意放正」片段。strong 的规则里有一条 {tag: "b", getAttrs: node => node.style.fontWeight != "normal" && null}:getAttrs 返回 false 时这条规则不成立,这里用短路表达式把 fontWeight 为 normal 的 b 元素挡在外面,源码注释写明这是绕 Google Docs 粘贴时乱套 b 标签的行为。code_block 的规则是 {tag: "pre", preserveWhitespace: "full"},空白字段的作用后面单独说。

规则的收集、排序与匹配

多数情况下解析器由 DOMParser.fromSchema(schema) 建立,DOMParser.schemaRules 负责收集规则:先 marks 后 nodes,把每个 spec 的 parseDOM 逐项拷贝出来(规则对象后面会被改写,不能直接用 spec 上的原对象),补上隐含的目标字段,mark spec 里的规则缺省生成自己这个 mark,node spec 同理,然后按 priority 插入结果数组。priority 缺省按 50 算,插入位置在第一个 priority 严格更小的规则前面,同 priority 保持声明先后,官方 schema 基本不写这个字段,它是给「扩展 schema 时需要插到内置规则前面」的场景留的。建好的解析器缓存在 schema.cached.domParser 上,一个 schema 只建一次。

匹配时标签和样式分两张表,构造函数里就把规则拆进 tags 和 styles,顺带去重出 matchedStyles 属性名列表。matchTag 按数组顺序扫 tag 规则:选择器用元素自己的 matches() 方法比对,带 ms、webkit、moz 前缀回退;namespace 不空时比对元素的 namespaceURI;规则带 context 时问 ParseContext 当前的节点栈是否符合;最后 getAttrs 返回 false 就跳过这条继续往下找。getAttrs 的返回值会回写到规则对象的 attrs 字段上,addElementByRule 建节点时直接读 rule.attrs,不再调第二次。matchStyle 的逻辑类似:规则的 style 字符串必须以查到的属性名开头,后面要么结束,要么是等号加一个完全相等的值;getAttrs 收到的是属性值字符串。

consuming 默认是 true:一条规则命中后,同一个元素不再问后面的规则。设成 false 时,addElementByRule 走完自己的逻辑之后会以 matchAfter 参数重新进 addElement,从这条规则的下一条继续匹配。style 规则同样支持,readStyles 里用一个 after 游标循环。这解决的是「一个元素要先后交给两条规则处理」的场景,比如外层 div 命中一个自定义节点规则,内层结构还要让别的规则接手。

readStyles 可以否决整个元素:任何命中的 style 规则带 ignore,整个函数返回 null,addElement 收到 null 就不解析这个元素。readStyles 的实现还有个细节值得记:它不按 style.item 正着枚举,改用 matchedStyles 里的属性名逐个 getPropertyValue 反查。注释解释了原因,text-decoration 这类属性在浏览器里会被拆成 text-decoration-line 等规范化形式,正着枚举会漏掉规则关心的属性。

Parser 的状态机:节点栈与 open 指针

解析过程的状态全在 ParseContext 上,核心是 nodes 数组加 open 指针。nodes 是 NodeContext 的栈,每个 NodeContext 对应一个正在构建的节点:type、attrs、marks、已收集的 content 数组、当前的 ContentMatch、solid 标记和一组位标记 options。open 指向当前写入点,top 就是 nodes[open]。open 之上的栈位不是垃圾:遇到同级块或更外层的块标签时,context 只是用 sync 把 open 退回上层,上面那些 NodeContext 还留在数组里,等下次写入前由 closeExtra 统一 finish、并入父层 content,再把栈截到 open + 1。

ParseContext 的节点栈与 open 指针

DOM 节点的分派从 addDOM 开始,文本节点进 addTextNode,元素进 addElement。addElement 先查规则,内部选项 ruleFromNode 优先,否则 matchTag,然后按结果分四条路:

  • 规则带 ignore,或元素在 ignoreTags 表里(head、noscript、object、script、style、title):整个子树不进文档。有一个例外处理,被忽略的 BR 且当前层放不下内联内容时,会用 findPlace 放一个文本探针开路,把上下文顶到能放内联的位置,探针本身不入文档。
  • 没命中规则,或规则带 skip、closeParent:元素本身跳过,内容继续解析。closeParent 先把 open 退一层。命中 blockTags 表里的块级标签(p、div、blockquote、li、ul 等三十来个)时,如果当前层已收的是内联内容就先关一层,再 sync 回块级层;顶层没有类型时顺手把 needsBlock 置上,这个标记后面有用。没命中规则又没有子节点的元素走 leafFallback,BR 在这里变成一个 “\n” 文本,前提是当前层接受内联内容。
  • 命中普通规则:addElementByRule。非叶节点调 enter 开新层,叶节点调 insertNode 直接插入,mark 规则把 mark 并进当前 marks。内容缺省解析元素的子节点,规则可以用 contentElement 指定内容所在的子孙元素,或者用 getContent 直接给出 Fragment。

插入节点时真正干活的是 insertNode 和 findPlace。findPlace 从 open 层一路向下找能容纳新节点的位置:每层调 NodeContext.findWrapping,后者基于第 6 篇讲的 content expression 匹配,ContentMatch.findWrapping 给出「要补上哪几层中间节点才能合法容纳」;当前层的 match 为空(切片左边缘打开着)时,先用 fillBefore 试着把开头补全,补不上再从 contentMatch 的起点找包裹。跨层有代价:每穿过一个 solid 节点罚 2 分,cautious 模式直接不许穿,最后选包裹层数加罚分最短的方案。找到之后 sync 到目标层,再为包裹节点逐层 enterInner。这套机制让列表项里突然出现一段裸文本时,解析器能自己决定在 li 下面补一个段落。

enterInner 开新层时还要给 mark 做一次分配。传进来的 marks 被分成两半:新层类型允许的(allowsMarkType;父层类型未定时用 markMayApply,它在 schema 里扫一遍,看是否存在「允许这个 mark 且内容表达式能到达目标节点类型」的父类型)变成新节点自身的 marks,剩下的继续往内传,留给更深的内联内容。insertNode 插入前也过同一套过滤,保证挂到节点上的 mark 一定合法。

收尾分两级。NodeContext.finish 把 content 数组变成 Fragment,右边缘没打开就用 match.fillBefore 把内容补全到符合表达式,然后 type.create 出节点;顶层 type 为空时(parseSlice 的开口顶层)直接返回 Fragment。ParseContext.finish 把 open 压到 0,closeExtra 逐级收掉,返回栈底的结果。DOMParser.parse 拿到的是整个节点,parseSlice 拿到 Fragment 后包一层 Slice.maxOpen,两端打开,第 8 篇讲的 openStart、openEnd 就是从这里来的。

context:规则只在某些结构里生效

规则上的 context 字段是一个路径字符串,节点名或组名用斜杠连起来:“paragraph/” 要求父节点是 paragraph,“blockquote/paragraph/” 要求 paragraph 在 blockquote 里面;双斜杠匹配任意层数的祖先,“section//” 就是在 section 内部任意深处;多个候选用竖线分隔,比如 “blockquote/|list_item/“。

matchesContext 的实现从当前 open 层向下逐段比对,空段(双斜杠)递归地扫 depth。匹配范围不限于 nodes 栈:ParseOptions.context 可以传一个 ResolvedPos,表示这段 DOM 实际要插回文档的哪个位置,比对越过栈顶之后继续用这个位置的上层节点往下对。还有一个 useRoot 分支控制栈顶层算不算进路径:整文档解析、且 context 的父节点类型与顶层类型一致(或者没给 context)时算,否则路径比对从栈顶下一层开始。context 选项的实际用处是粘贴:往 list_item 里粘一段 HTML 时,解析器知道自己身处列表项,带 context 限定的规则才能正确命中,比如「只在列表里才把某种 div 解析成列表项」。

context 还服务另一个场景。parseSlice 的顶层没有类型,遇到块级标签时 addElement 把 needsBlock 置上,之后 insertNode 发现要插的是内联节点而顶层无类型,就调 textblockFromContext:沿 context 的各层问 contentMatchAt 的 defaultType,取第一个带默认属性的 textblock;context 没给就退而求其次,取 schema 里第一个符合条件的 textblock。这样粘进文档的纯文本切片知道该用什么块来包。

空白符什么时候被吃掉

HTML 的空白是语义模糊的:源码里的换行和缩进大多不该进文档,pre 里的又必须原样保留。规则层没有一个独立的「忽略空白」开关,策略由几处共同决定,最后都汇总到 NodeContext.options 的两个位上(OPT_PRESERVE_WS、OPT_PRESERVE_WS_FULL),wsOptionsFor 负责归拢:

  • ParseOptions.preserveWhitespace 是全局开关:true 保留空白但把换行规范成空格,“full” 全保留,只做 \r\n 到 \n 的归一。
  • 节点类型的 whitespace 为 “pre” 时该层自动置上两个位。NodeSpec.whitespace 缺省在 code: true 时就是 “pre”,所以代码块不用额外配置。
  • tag 规则自己的 preserveWhitespace 字段优先级最高,schema-basic 的 code_block 写的就是这个。
  • 规则没写也不漏:addElement 遇到 PRE 标签、或 white-space 含 pre 的内联样式,会把 localPreserveWS 置上,整个子树生效;sync 回退时沿途的层也会补置保留位,防止收尾把 pre 里的行尾空白剥掉。

执行侧在 addTextNode。不保留时,连续空白折叠成一个空格;文本以空白开头,且前面没有节点、前一个兄弟是 BR、或前一个文本以空白结尾时,开头那个空格剥掉。当前上下文不是内联且整段全是空白时,这个文本节点直接丢弃,块与块之间的缩进换行就是这么没的;内联与否由 inlineContext 判定:类型本身是 inlineContent、已收内容是内联、或父元素不在 blockTags 表里。保留但非 full 的一档遇到换行还有条岔路:schema 定义了 linebreakReplacement 且当前位置放得下这个节点时,把文本按行拆开,行间插一个换行节点。linebreakReplacement 是 Schema 构造时从带这个标记的 spec 收集的,全 schema 只允许一个。收尾时 NodeContext.finish 会剥掉内容末尾的空白串,本层保留的除外。

入口与消费方

两个公开入口。parse(dom, options) 解析成完整节点,顶层类型缺省是 schema.topNodeType,options.topNode 和 topMatch 可以换成别的容器与起始 match。parseSlice 产出两端打开的切片。options.from、to 限定只解析子节点的某个区间。options.findPositions 是一组 {node, offset},解析过程中 findAtPoint、findInside、findAround、findInText 四个函数把对应的文档位置写回每个对象的 pos 字段;位置的算法是 currentPos,沿栈把各层已收内容的 nodeSize 加总,每层再加 1,正是第 7 篇的位置编号约定,每层结构占一个开位置。不在解析结果覆盖范围内的 DOM 位置不写回。

构造函数里还有一个 normalizeLists 判定:schema 里 ul、ol 规则命中的节点类型如果能直接包含自己,就不做归一化;否则 addElement 遇到 ul、ol 先跑一遍 normalizeList,把直接嵌在列表元素下的子列表挪进前一个 li 里。源码注释说明这是绕某些工具和浏览器默许的嵌套写法,那种写法的实际语义是「子列表属于上面那个列表项」。

消费方在 view 层,后面读到再展开:粘贴时 parseFromClipboard 用 parseSlice 把剪贴板 HTML 变成切片;浏览器直接改了 DOM 之后,readDOMChange 读回时走的也是这套解析器,ruleFromNode 和 topOpen 两个内部选项就是给它留的。model 层自己只管一件事:给定 DOM 和 schema,产出符合约束的文档结构。规则决定每个元素变成什么,状态机把不匹配的结构修正成合法的,context 和空白策略负责消化外部输入的脏数据。粘贴内容千奇百怪,这个文件里几乎每条分支都对应一类真实输入。


949 字 · 37 段落
xi ming

Written by xi ming You should follow him on Github