DOMParser:parseDOM 规则与 HTML 解析

📅
2 分钟阅读
·

上一篇分析了 toDOM 如何将文档序列化为 DOM 和 HTML。本篇分析反方向的 src/from_dom.ts:外部 DOM,例如剪贴板 HTML、服务端渲染的初始内容或其他编辑器导出的片段,如何按 schema 的 parseDOM 规则解析回文档。DOMParser 负责规则的存储与匹配,ParseContext 管理解析过程的状态。参考代码是 prosemirror-model 的 6264de0。src/dom.ts 仅导出 DOMNode 类型别名,from_dom.ts 中的 DOM 参数均使用该类型。

系列目录

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,而是从 mark 集合中移除已激活的 em,用于处理 <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 不再解析该元素。它不通过 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]。遇到同级或外层块标签时,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 也通过它读回内容,ruleFromNodetopOpen 是为此保留的内部选项。model 层接收 DOM 和 schema,产出符合约束的文档结构。规则决定元素对应的节点或 mark,状态机将不匹配的结构修正为合法结构,context 和空白策略处理外部输入中的非规范内容。


912 字 · 37 段落
ximing

Follow onGitHub

相关文章