宣告式陰影 DOM

Mason Freed
Mason Freed

宣告式 Shadow DOM 是標準網路平台功能,Chrome 從第 90 版開始支援這項功能。請注意,這項功能的規格已於 2023 年變更 (包括將 shadowroot 重新命名為 shadowrootmode)。此外,這項功能也在 Chrome 124 版中推出的最新標準化版本。

瀏覽器支援

  • Chrome:111.
  • Edge:111。
  • Firefox:123。
  • Safari:16.4。

資料來源

Shadow DOM 是三個 Web Components 標準之一,其他兩個標準是 HTML 範本自訂元素。Shadow DOM 可讓您將 CSS 樣式範圍限制在特定 DOM 子樹,並將該子樹與文件的其他部分隔離。<slot> 元素可讓我們控制自訂元素的子項應在其陰影樹狀結構中插入的位置。有了這些功能相結合,系統就能建立可重複使用的獨立元件,就像內建的 HTML 元素一樣,能流暢地整合至現有應用程式。

截至目前為止,使用 Shadow DOM 的唯一方法是使用 JavaScript 建構陰影根目錄:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

這類命令式 API 適用於用戶端轉譯,因為定義我們自訂元素的 JavaScript 模組也會建立其陰影根,並設定內容。不過,許多網路應用程式都需要在建構期間,將內容算繪至伺服器端或靜態 HTML。訪客可能無法執行 JavaScript 並獲得合理體驗時,這可能會很重要。

伺服器端算繪 (SSR) 的使用理由因專案而異。為了符合無障礙規範,有些網站必須提供完整的伺服器轉譯 HTML,才能符合無障礙指南。有些網站則選擇提供無 JavaScript 基本體驗,藉此確保在連線速度緩慢或裝置上維持良好效能。

以往,使用 Shadow DOM 搭配「伺服器端轉譯」作業相當困難,因為系統沒有內建在伺服器產生的 HTML 中表示陰影根。將陰影根附加到已顯示 DOM 元素 (且未使用陰影根) 時,也會影響效能。這可能會導致網頁載入後版面配置移位,或是在載入陰影根目錄的樣式表時,暫時顯示未設定樣式的內容閃爍畫面 (「FOUC」)。

宣告式 Shadow DOM (DSD) 有移除這項限制,將 Shadow DOM 導入伺服器。

如何建構宣告式陰影 Root 權限

宣告式陰影根是含有 shadowrootmode 屬性的 <template> 元素:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

HTML 剖析器會偵測具有 shadowrootmode 屬性的範本元素,並立即套用為其父項元素的陰影根。從上述範例載入純 HTML 標記會產生下列 DOM 樹狀結構:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

這個程式碼範例遵循 Chrome 開發人員工具元素面板顯示 Shadow DOM 內容的慣例。舉例來說, 字元代表插槽的 Light DOM 內容。

這樣我們就有 Shadow DOM 在靜態 HTML 中封裝和運算單元投影的好處。不需要 JavaScript 即可產生整個樹狀結構,包括陰影根。

元件飲水量

宣告式 Shadow DOM 可單獨用於封裝樣式或自訂子項刊登位置,但搭配自訂元素使用時最強大。使用自訂元素建構的元件會自動從靜態 HTML 升級。導入「宣告式陰影 DOM」後,自訂元素現在可以在升級之前具備陰影根層級。

從 HTML 升級的自訂元素 (其中包含宣告式陰影根層級) 已附加該陰影根層級。這表示當元素進行例項化時,元素將具備可用的 shadowRoot 屬性,無需明確建立程式碼。建議您檢查元素建構函式中是否有任何現有陰影根 this.shadowRoot。如果該元件已設有值,這個元件的 HTML 中會包含宣告式陰影根目錄。如果值為空值,表示 HTML 中沒有宣告式陰影根,或是瀏覽器不支援宣告式陰影 DOM。

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

自訂元素已有一段時間,直到目前沒有必要檢查現有陰影根層級,才能使用 attachShadow() 建立。宣告式 Shadow DOM 包含一項小幅變更,可讓現有元件在以下情況下運作:在具有現有 宣告式 Shadow Root 的元素上呼叫 attachShadow() 方法,不會擲回錯誤。而是清空並傳回宣告式陰影根。這樣一來,舊元件 (並非為宣告式 Shadow DOM 建構) 就能繼續運作,因為宣告式根會保留至建立命令式替代項目為止。

對於新建立的自訂元素,新的 ElementInternals.shadowRoot 屬性提供明確的方式,可取得元素現有的宣告式陰影根目錄參照,包括已開啟和已關閉的陰影根目錄。這可用來檢查及使用任何宣告式陰影 Root 權限,但在未提供宣告的情況下,仍可改回使用 attachShadow()

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

每個根一個陰影

宣告式陰影 Root 僅與父項元素相關聯。也就是說,影子根一律與相關聯的元素會放在同一位置。這項設計決策可確保影子根可像其他 HTML 文件一樣串流。此外,編寫及產生也很方便,因為新增元素的陰影根目錄不需要維護現有影子根的註冊資料庫。

將陰影根與其父項元素建立關聯的缺點是,無法從同一個宣告式陰影根 <template> 初始化多個元素。不過,在大多數使用宣告式 Shadow DOM 的情況下,這項限制不太可能發生,因為每個 shadow root 的內容很少會相同。雖然伺服器算繪的 HTML 通常包含重複的元素結構,但其內容通常有所不同,例如文字或屬性的細微差異。由於序列化的宣告式陰影根目錄內容完全是靜態的,因此只有在元素剛好相同的情況下,從單一宣告式陰影根目錄升級多個元素才會有效。最後,由於壓縮的效果,重複的類似陰影根對網路傳輸大小的影響相對較小。

日後,我們可能會重新審視共用陰影根目錄。如果 DOM 獲得內建範本的支援,宣告式陰影根層級可視為範本,並將其例項化,藉此為特定元素建構陰影根。目前的宣告式 Shadow DOM 設計會將陰影根關聯限制為單一元素,因此未來可能會出現這種情況。

串流播放很酷

將宣告式陰影根直接與其父項元素建立關聯,可簡化升級程序,並將其附加至該元素。系統會在 HTML 剖析期間偵測宣告式陰影根目錄,並在遇到其「開啟」<template> 標記時立即附加。<template> 內部剖析的 HTML 會直接剖析至陰影根目錄,因此可「串流」:在收到時算繪。

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

僅限剖析器

宣告式 Shadow DOM 是 HTML 剖析器的一項功能。這表示系統只會針對含有 shadowrootmode 屬性的 <template> 標記進行剖析及附加在 HTML 剖析期間出現的宣告式陰影根層級。換句話說,您可以在初始 HTML 剖析期間建構宣告式陰影根:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

設定 <template> 元素的 shadowrootmode 屬性不會產生任何作用,範本仍會是一般範本元素:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

為避免一些重要的安全性考量,您也無法使用片段剖析 API (例如 innerHTMLinsertAdjacentHTML()) 建立宣告式陰影根目錄。套用宣告式陰影 Root 權限來剖析 HTML 的唯一方法,就是使用 setHTMLUnsafe()parseHTMLUnsafe()

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

使用樣式進行伺服器轉譯

宣告式陰影根層級可以透過標準 <style><link> 標記完整支援內嵌和外部樣式表:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

以這種方式指定的樣式也經過高度最佳化:如果多個宣告式陰影根目錄中都出現相同的樣式表單,系統只會載入及剖析一次。瀏覽器會使用所有陰影根層級共用的單一支援 CSSStyleSheet,進而消除重複的記憶體負擔。

宣告式 Shadow DOM 不支援可建構的樣式表。這是因為目前您無法在 HTML 中序列化可建構的樣式表,且在填入 adoptedStyleSheets 時也無法參照這些工作表。

如何避免未設定樣式的內容閃爍

瀏覽器尚未支援宣告式 Shadow DOM 的潛在問題之一,就是避免「閃爍內容」(FOUC) 這個尚未升級的自訂元素原始內容。在宣告式 Shadow DOM 推出前,常見避免 FOUC 的常見技術,是將 display:none 樣式規則套用至尚未載入的自訂元素 (因為這些項目尚未附加及填入陰影根層級)。採用這種方式,直到內容「就緒」後才會顯示:

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

透過宣告式 Shadow DOM,您可以使用 HTML 轉譯或撰寫自訂元素,讓其陰影內容在載入用戶端元件實作前就就地就緒:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

在這種情況下,display:none「FOUC」規則會防止宣告式陰影根目錄的內容顯示。不過,移除該規則會導致不支援宣告式 Shadow DOM 的瀏覽器顯示不正確或未設定樣式的內容,直到宣告式 Shadow DOM polyfill 載入並將陰影根範本轉換為實際的陰影根為止。

幸好,只要修改 FOUC 樣式規則,就能在 CSS 中解決這個問題。在支援宣告式 Shadow DOM 的瀏覽器中,<template shadowrootmode> 元素會立即轉換為陰影根目錄,進而讓 DOM 樹狀結構中沒有 <template> 元素。如果瀏覽器不支援宣告式陰影 DOM,就會保留 <template> 元素,可用於防範 FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

修改後的「FOUC」規則會在遵守 <template shadowrootmode> 元素時隱藏子項,而不是隱藏尚未定義的自訂元素。定義自訂元素後,規則就不會再相符。在支援宣告式 Shadow DOM 的瀏覽器中,系統會忽略此規則,因為 <template shadowrootmode> 子項會在 HTML 剖析期間移除。

功能偵測和瀏覽器支援

自 Chrome 90 和 Edge 91 起,宣告式 Shadow DOM 就已可供使用,但它使用的是較舊的非標準屬性 shadowroot,而非標準化的 shadowrootmode 屬性。Chrome 111 和 Edge 111 提供較新的 shadowrootmode 屬性和串流行為。

「宣告式 Shadow DOM」為新的網路平台 API,尚未對所有瀏覽器提供廣泛的支援。您可以檢查 HTMLTemplateElement 原型的 shadowRootMode 屬性是否存在,藉此偵測瀏覽器支援情形:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

聚酯纖維

為宣告式 Shadow DOM 建構簡化的 polyfill 相對簡單,因為 polyfill 不需要完全複製瀏覽器實作本身認為的時間語意或僅有剖析器的特徵。如要為宣告式 Shadow DOM 提供 polyfill,我們可以掃描 DOM 來找出所有 <template shadowrootmode> 元素,然後將這些元素轉換為父項元素上附加的 Shadow Root。這項程序可以在文件準備就緒後完成,或是由自訂元素生命週期等更具體的事件觸發。

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

延伸閱讀