image.png

有时我们需要获取某个网页HTML的本地副本,例如作为测试的输入。

但复制网页或元素的HTML并不总是直截了当的。现代网站往往由自定义元素构建。自定义元素通常是影子宿主。影子宿主的 innerHTMLouterHTML 属性只返回直接子元素的HTML,而忽略了包含的影子DOM的HTML。

同样,开发者工具中Elements面板的"复制outerHTML"操作目前还无法生成带有声明式影子根的HTML。例如,面向开发者的流行网站https://chromestatus.com/是由嵌套的自定义元素构建的。body 的第一个子元素chromedash-app托管了一个包含多个嵌套元素的影子DOM:

image.png

尝试复制 https://chromestatus.com/feature/5084403030818816页面body的HTML:

image.png

将复制的HTML粘贴到文本编辑器后,我看到chromedash-app是空的,它的影子DOM没有被复制:

image.png

所以开发者工具还不支持复制影子DOM。

如何复制包含影子根的页面DOM的HTML

可以使用一个小型辅助脚本,利用新的getHTML()方法来实现。该方法需要引用元素中嵌套的所有影子根才能正常工作。

为了获取影子根,我使用了基于el.shadowRoot的函数childRoots(root)。但是,如果DOM包含封闭的影子根,只能通过Chrome扩展API提供的方法来获取它们。

用于获取元素的inner或outer HTML的函数很简单。outerHTML()将仅定义父元素的HTML添加到innerHTML()的结果中:

// html.js

import { childRoots } from "./dom.js";

export function innerHTML(parent) {
    return parent.getHTML({ shadowRoots: childRoots(parent) });
}

export function outerHTML(parent) {
    return parent.cloneNode(false).outerHTML.replace('><', `>${innerHTML(parent)}<`);
}

要查看函数的结果,从一个额外的main.js中调用它们:

// main.js

import { outerHTML } from "./html.js";
 
console.log(outerHTML(document.body)); 

JavaScript模块可以方便地在开发者工具控制台中执行。main.js生成一个完整的HTML,其中自定义元素内包含声明式影子根:

image.png

生成的HTML长度为139 kB。如何知道这是一个精确的副本,它是否被准确复制了?有两个简单的选项。

可以保存HTML,在浏览器中打开,并目视比较源页面的内容与副本。不过,样式可能会不同。样式可能直接在HTML中指定,或在自定义元素的构造函数中指定。如果脚本被包含在本地副本中,那些没有失败的脚本可能会修改页面内容。要重现原始样式,需要一些编码工作。

复制的body的HTML(不包括head中声明的脚本和样式)很好地反映了原始页面的文本内容:

image.png

如果将生成的HTML转换为DOM,并与源DOM逐节点比较,结论会更有说服力。

如何测试两个带有影子根和插槽的DOM是否是克隆

innerHTML不能正确解析带有新的shadowrootmode属性的template元素。但它的现代替代品setHTMLUnsafe()允许将带有声明式影子根的HTML插入到元素中。

main2.js使用setHTMLUnsafe()创建源body元素的副本,然后调用函数compare(source, copy)比较源DOM和副本DOM中的所有元素:

// main2.js

import { innerHTML } from "./html.js";
import { compare } from "./compare.js";

let source = document.body;
const copy = document.createElement('body');
copy.setHTMLUnsafe(innerHTML(source));
compare(source, copy); 

compare(source, copy)将结果打印到控制台:

image.png

compare(source, copy)获取源和副本DOM的所有元素,并通过将它们连接成一个字符串来比较它们的顺序和文本内容。没有差异,所以不需要更详细的比较:

// compare.js

import { allElements } from "./distributed.js";

function joinNames(els) {
    return els.map(el => el.localName).join('');
}

function joinText(els) {
    return els.map(el => el.textContent).join('');
}
 
export function compare(source, copy) {
    const els1 = allElements(source);
    const els2 = allElements(copy);
    console.log(copy, source);
    console.log('元素数量', els1.length, els2.length);
    console.log('元素顺序相同', joinNames(els1) === joinNames(els2));
    console.log('元素文本相同', joinText(els1) === joinText(els2));
 }

allElements(el)从allChildNodes(parent)返回的所有类型的节点中选择元素,它复制了浏览器在DOM渲染期间的行为 — 它返回分配的元素、影子根的子元素和普通子节点。忽略影子根的兄弟节点或具有分配元素的插槽的子节点:

// distributed.js

export function allChildNodes(parent) {
    let children = [];

    if (parent.assignedNodes && parent.assignedNodes().length)
        children.push(...parent.assignedNodes());
    else {
        if (parent.shadowRoot) {
            parent = parent.shadowRoot;
        }

        children.push(...parent.childNodes);
    }
 
    return [...children, ...children.flatMap(allChildNodes)];
}

export function allElements(parent) {
    return allChildNodes(parent).filter(n => Node.ELEMENT_NODE === n.nodeType);
}

只比较了文本节点。似乎在通过getHTML()将DOM转换为HTML或通过setHTMLUnsafe()将HTML转换为新DOM的过程中,多个换行和空格被删除,新的被添加。因此,源元素和副本元素中的文本节点数量不同。但如果去掉它们内容中的换行和空格,它们是相同的。

完整的示例代码可从https://github.com/marianc000/compareShadowDOM下载。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

王大冶
68.1k 声望105k 粉丝