有时我们需要获取某个网页HTML的本地副本,例如作为测试的输入。
但复制网页或元素的HTML并不总是直截了当的。现代网站往往由自定义元素构建。自定义元素通常是影子宿主。影子宿主的 innerHTML
或 outerHTML
属性只返回直接子元素的HTML,而忽略了包含的影子DOM的HTML。
同样,开发者工具中Elements面板的"复制outerHTML"操作目前还无法生成带有声明式影子根的HTML。例如,面向开发者的流行网站https://chromestatus.com/
是由嵌套的自定义元素构建的。body 的第一个子元素chromedash-app
托管了一个包含多个嵌套元素的影子DOM:
尝试复制 https://chromestatus.com/feature/5084403030818816页面body的HTML:
将复制的HTML粘贴到文本编辑器后,我看到chromedash-app是空的,它的影子DOM没有被复制:
所以开发者工具还不支持复制影子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,其中自定义元素内包含声明式影子根:
生成的HTML长度为139 kB。如何知道这是一个精确的副本,它是否被准确复制了?有两个简单的选项。
可以保存HTML,在浏览器中打开,并目视比较源页面的内容与副本。不过,样式可能会不同。样式可能直接在HTML中指定,或在自定义元素的构造函数中指定。如果脚本被包含在本地副本中,那些没有失败的脚本可能会修改页面内容。要重现原始样式,需要一些编码工作。
复制的body的HTML(不包括head中声明的脚本和样式)很好地反映了原始页面的文本内容:
如果将生成的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)
将结果打印到控制台:
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 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。