如何複製由自定義元素組成的網頁的 HTML 程式碼

王大冶發表於2024-11-04
  • React Hook 深入淺出
  • CSS技巧與案例詳解
  • vue2與vue3技巧合集
  • VueUse原始碼解讀

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 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章