Shadow DOM v1 - 自封式網頁元件

Shadow DOM 可讓網頁開發人員為網頁元件建立分隔的 DOM 和 CSS

Shadow DOM 可消除建構網頁應用程式的脆弱性。脆弱性源自 HTML、CSS 和 JS 的全球性質。多年來,我們發明瞭大量工具來解決這些問題。舉例來說,當您使用新的 HTML ID/類別時,無法得知該 ID/類別是否會與網頁使用的現有名稱發生衝突。隱藏的錯誤會逐漸浮現,CSS 特徵會變成重大問題 (!important 所有內容!),樣式選取器會失控,效能可能會受到影響。類似的例子不勝枚舉。

Shadow DOM 會修正 CSS 和 DOM。這項功能會在網路平台中導入範圍樣式。無須使用工具或命名慣例,您就可以將 CSS 與標記合併、隱藏實作詳細資料,以及在純 JavaScript 中撰寫自給自足的元件

Shadow DOM 是三個網頁元件標準之一:HTML 範本Shadow DOM自訂元素HTML 匯入原本是清單的一部分,但現在已被視為已淘汰

您不必撰寫使用 shadow DOM 的網頁元件。不過,您可以利用 CSS 的優點 (CSS 範圍、DOM 封裝、組合) 建構可重複使用的自訂元素,這些元素具有彈性、可高度設定且非常可重複使用。如果您想透過 JS API 建立新的 HTML,可以使用自訂元素,而 shadow DOM 則是提供 HTML 和 CSS 的方式。這兩個 API 結合後,可製作出包含獨立 HTML、CSS 和 JavaScript 的元件。

Shadow DOM 是一種用來建構元件式應用程式的工具。因此,它為網頁開發中的常見問題提供解決方案:

  • 隔離的 DOM:元件的 DOM 是自給自足的 (例如 document.querySelector() 不會在元件的 shadow DOM 中傳回節點)。
  • 受限 CSS:在陰影 DOM 中定義的 CSS 會受限於該 DOM。樣式規則不會外洩,網頁樣式也不會外流。
  • 組合:為元件設計宣告式、以標記為基礎的 API。
  • 簡化 CSS:使用 DOM 範圍,您可以使用簡單的 CSS 選取器、更多通用 ID/類別名稱,且不必擔心命名衝突。
  • 生產力:請將應用程式視為 DOM 的片段,而非一個大型 (全域) 頁面。

fancy-tabs 示範

在本文中,我會參照示範元件 (<fancy-tabs>) 並參照其中的程式碼片段。如果您的瀏覽器支援這些 API,您應該會在下方看到即時示範。否則,請查看 GitHub 上的完整原始碼

在 GitHub 上查看來源

什麼是 Shadow DOM?

DOM 的背景

HTML 是網頁的動力,因為它很容易使用。只要宣告幾個標記,您就能在幾秒內撰寫包含呈現方式和結構的網頁。不過,HTML 本身並沒有太大用處。人類很容易理解以文字為基礎的語言,但機器需要更多資訊。進入文件物件模型 (DOM)。

瀏覽器載入網頁時,會執行許多有趣的操作。其中一個功能是將作者的 HTML 轉換為即時文件。基本上,為了瞭解網頁的結構,瀏覽器會將 HTML (文字的靜態字串) 解析為資料模型 (物件/節點)。瀏覽器會建立這些節點的樹狀結構 (DOM),以保留 HTML 的階層。DOM 的優點是,它是網頁的即時呈現方式。與我們撰寫的靜態 HTML 不同,瀏覽器產生的節點包含屬性和方法,而且最重要的是,可以由程式操控!因此,我們可以直接使用 JavaScript 建立 DOM 元素:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

產生下列 HTML 標記:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

一切都很順利。那麼,究竟什麼是 shadow DOM

DOM… 在陰影中

Shadow DOM 與一般 DOM 有兩個不同之處:1) 建立/使用方式,以及 2) 與網頁其他部分的行為。通常,您會建立 DOM 節點,並將其附加為其他元素的子項。使用 shadow DOM 時,您會建立範圍限定的 DOM 樹狀結構,該樹狀結構會附加至元素,但與實際子項分開。這個受限子樹狀結構稱為陰影樹狀結構。所附加的元素為其陰影主機。您在陰影中新增的任何內容都會成為代管元素的本機內容,包括 <style>。這就是陰影 DOM 實現 CSS 樣式範圍的方式。

建立影子 DOM

陰影根是附加至「主機」元素的文件片段。附加陰影根的動作,就是元素取得陰影 DOM 的方式。如要為元素建立 shadow DOM,請呼叫 element.attachShadow()

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

我使用 .innerHTML 填入陰影根,但您也可以使用其他 DOM API。這就是網路。我們有選擇。

規格定義無法代管陰影樹狀結構的元素清單。元素可能會出現在清單中的原因有幾種:

  • 瀏覽器已為元素代管其自身的內部 Shadow DOM (<textarea><input>)。
  • 元素不應代管 shadow DOM (<img>)。

舉例來說,以下做法無法運作:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

為自訂元素建立 shadow DOM

建立自訂元素時,Shadow DOM 特別實用。使用 shadow DOM 將元素的 HTML、CSS 和 JS 分隔開來,進而產生「網頁元件」。

範例:自訂元素將 shadow DOM 附加至自身,封裝其 DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

這裡發生了幾件有趣的事情。第一個原因是,在建立 <fancy-tabs> 例項時,自訂元素會建立自己的 shadow DOMconstructor() 會執行這項操作。其次,由於我們要建立陰影根,<style> 中的 CSS 規則會限制在 <fancy-tabs> 中。

組合和插槽

組合是 shadow DOM 中最不為人知的功能之一,但它可能是最重要的功能。

在網路開發的世界中,組合是指我們如何使用 HTML 宣告式建構應用程式。不同的構成元素 (<div><header><form><input>) 會組合成應用程式。其中有些標記甚至可以互相搭配使用。組合是 <select><details><form><video> 等原生元素如此靈活的原因。每個標記都會接受特定 HTML 做為子項,並對這些子項執行特殊動作。舉例來說,<select> 會將 <option><optgroup> 轉譯為下拉式選單和多重選取小工具。<details> 元素會將 <summary> 轉譯為可展開的箭頭。<video> 也知道如何處理特定子項:<source> 元素不會算繪,但會影響影片的行為。好神奇!

術語:light DOM 與 shadow DOM

Shadow DOM 組合會在網頁開發中引入許多新的基礎元素。在深入探討之前,我們先來統一一些術語,以便大家使用相同的用語。

Light DOM

元件使用者所撰寫的標記。這個 DOM 位於元件的 shadow DOM 之外。這是元素的實際子項。

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

元件作者所撰寫的 DOM。Shadow DOM 是元件中的本機,可定義其內部結構、範圍 CSS,並封裝實作詳細資料。它還能定義如何轉譯元件使用者所撰寫的標記。

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

壓縮的 DOM 樹狀結構

瀏覽器將使用者的 light DOM 分發至 shadow DOM 的結果,並算繪最終產品。扁平化樹狀結構是您在 DevTools 中最終看到的內容,也是在頁面上算繪的內容。

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

<slot> 元素

Shadow DOM 會使用 <slot> 元素將不同的 DOM 樹狀結構組合在一起。「Slot」是元件中的預留位置,使用者可以使用自己的標記填入。定義一或多個版位後,您就能在元件陰影 DOM 中算繪外部標記。基本上,您會說「在此處算繪使用者的標記」

<slot> 邀請元素時,元素可「跨越」shadow DOM 邊界。這些元素稱為「分散式節點」。從概念上來說,分散式節點可能有點奇怪。插槽不會實際移動 DOM,而是在 shadow DOM 中的其他位置算繪 DOM。

元件可以在其 shadow DOM 中定義零個或多個版位。空白的廣告位元組可以提供備用內容。如果使用者未提供 light DOM 內容,插槽會轉譯備用內容。

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

您也可以建立命名時段。命名版位是陰影 DOM 中的特定空白,可讓使用者以名稱參照。

範例<fancy-tabs> 的 shadow DOM 中的插槽:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

元件使用者宣告 <fancy-tabs> 的方式如下:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

如要瞭解平坦化樹狀結構的樣貌,請參考以下範例:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

請注意,我們的元件能夠處理不同的設定,但扁平化的 DOM 樹狀結構仍維持不變。我們也可以從 <button> 切換至 <h2>。這個元件是為了處理不同類型的子項而編寫的,就像 <select> 一樣!

樣式

您可以使用許多選項為網頁元件設定樣式。使用陰影 DOM 的元件可由主頁面設定樣式、定義自己的樣式,或提供鉤子 (以 CSS 自訂屬性的形式),供使用者覆寫預設值。

元件定義的樣式

shadow DOM 最實用的功能就是範圍 CSS

  • 外部網頁的 CSS 選取器不會套用至元件內部。
  • 內部定義的樣式不會溢出。範圍限定為主機元素。

在陰影 DOM 中使用的 CSS 選取器會在本機套用至元件。實際上,這表示我們可以再次使用常見的 ID/類別名稱,而不必擔心會與頁面上其他位置產生衝突。在 Shadow DOM 中,簡單的 CSS 選取器是最佳做法。也能提升效能。

範例:在陰影根目錄中定義的樣式為本機

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

樣式表的範圍也限定為陰影樹:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

您是否曾想過,當您新增 multiple 屬性時,<select> 元素如何算繪多重選項小工具 (而非下拉式選單):

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> 可根據您在其上宣告的屬性,為自身設定不同的樣式。網頁元件也可以使用 :host 選取器自行設定樣式。

範例:元件本身的樣式

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

:host 的一個陷阱是,父項頁面中的規則比元素中定義的 :host 規則更具特異性。也就是外部樣式勝出。這樣一來,使用者就能從外部覆寫頂層樣式。此外,:host 只能在陰影根的內容中運作,因此您無法在陰影 DOM 外使用。

:host(<selector>) 的函式形式可讓您指定主機,前提是該主機與 <selector> 相符。這是元件封裝行為的絕佳方法,可根據主機回應使用者互動、狀態或樣式內部節點。

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

根據內容設定樣式

如果元件或其任何祖系元件符合 <selector>:host-context(<selector>) 就會與該元件相符。這項功能的常見用途是根據元件的周遭環境進行主題設定。舉例來說,許多人會將類別套用至 <html><body> 來進行主題設定:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) 會在為 .darktheme 的子項時,為 <fancy-tabs> 設定樣式:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() 可用於設定主題,但使用 CSS 自訂屬性建立樣式鉤子會是更好的做法。

為分散式節點設定樣式

::slotted(<compound-selector>) 會比對分散到 <slot> 的節點。

假設我們已建立名稱徽章元件:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

元件的 shadow DOM 可為使用者的 <h2>.title 設定樣式:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

如您還記得,<slot> 不會移動使用者的輕量 DOM。當節點分散到 <slot> 時,<slot> 會轉譯其 DOM,但節點會實際保留在原處。發布前套用的樣式會在發布後繼續套用。不過,light DOM 在分發時,可以採用額外的樣式 (由 shadow DOM 定義的樣式)。

以下是 <fancy-tabs> 的另一個更深入的範例:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

在這個範例中,有兩個版位:一個是用於分頁標題的命名版位,另一個是用於分頁面板內容的版位。當使用者選取分頁時,我們會將選取項目加粗並顯示其面板。方法是選取具有 selected 屬性的分散式節點。自訂元素的 JS (未顯示於此) 會在正確的時間新增該屬性。

從外部設定元件的樣式

您可以透過幾種方式從外部設定元件的樣式。最簡單的方法是使用標記名稱做為選取器:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

外部樣式一律優先於 shadow DOM 中定義的樣式。舉例來說,如果使用者編寫了選取器 fancy-tabs { width: 500px; },就會取代元件的規則::host { width: 650px;}

為元件本身設定樣式,只能達到一定程度。不過,如果您想為元件的內部樣式設定樣式,會發生什麼情況?為此,我們需要 CSS 自訂屬性。

使用 CSS 自訂屬性建立樣式鉤子

如果元件作者使用 CSS 自訂屬性提供樣式鉤子,使用者就能調整內部樣式。概念上,這與 <slot> 類似。您可以建立「樣式預留位置」,供使用者覆寫。

範例<fancy-tabs> 可讓使用者覆寫背景顏色:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

在 shadow DOM 中:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

在這種情況下,元件會使用 black 做為背景值,因為使用者已提供該值。否則,預設值為 #9E9E9E

進階主題

建立封閉式陰影根目錄 (應避免)

還有另一種稱為「closed」模式的陰影 DOM。建立封閉的陰影樹時,外部 JavaScript 將無法存取元件的內部 DOM。這與 <video> 等原生元素的運作方式類似。JavaScript 無法存取 <video> 的 shadow DOM,因為瀏覽器使用封閉模式的 shadow root 實作此功能。

範例:建立封閉式陰影樹狀結構:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

其他 API 也會受到關閉模式的影響:

  • Element.assignedSlot / TextNode.assignedSlot 會傳回 null
  • Event.composedPath():與陰影 DOM 內元素相關聯的事件,傳回 []

以下是我總結的,為何不應使用 {mode: 'closed'} 建立 Web 元件:

  1. 人工安全感。攻擊者可以輕易劫持 Element.prototype.attachShadow

  2. 封閉模式可防止自訂元素程式碼存取自身的陰影 DOM。這會導致完全失敗。相反地,如果您想使用 querySelector() 之類的項目,就必須先儲存參照。這完全違背封閉模式的原意!

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. 在封閉模式下,使用者無法靈活運用元件。在建構網頁元件時,您可能會忘記新增某些功能。設定選項。使用者想要的用途。常見的例子是忘記為內部節點加入適當的樣式鉤子。在封閉模式下,使用者無法覆寫預設值和調整樣式。能夠存取元件的內部資料非常實用。最終,如果元件無法執行使用者想要的操作,他們會分支您的元件、尋找其他元件,或自行建立元件 :(

使用 JS 中的空格

shadow DOM API 提供可用於處理插槽和分散節點的實用工具。這些元素在編寫自訂元素時非常實用。

時間間隔變更事件

當一個分格的分散節點發生變更時,就會觸發 slotchange 事件。例如,如果使用者在輕量 DOM 中新增/移除子項。

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

如要監控 Light DOM 的其他類型變更,您可以在元素的建構函式中設定 MutationObserver

哪些元素會在一個版位中算繪?

有時您可能需要瞭解哪些元素與某個時段相關聯。呼叫 slot.assignedNodes() 即可找出單元格要算繪的元素。如果沒有任何節點在分發,{flatten: true} 選項也會傳回版位的備用內容。

舉例來說,假設您的 shadow DOM 如下所示:

<slot><b>fallback content</b></slot>
用量撥打電話結果
<my-component>元件文字</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

元素會指派至哪個版位?

您也可以回答相反的問題。element.assignedSlot 會指出元素指派至哪個元件位置。

Shadow DOM 事件模型

事件從 shadow DOM 向上冒出時,系統會調整事件目標,以維持 shadow DOM 提供的封裝。也就是說,事件會重新指定目標,讓事件看起來像是來自元件,而不是 shadow DOM 中的內部元素。有些事件甚至不會傳播至陰影 DOM。

跨越陰影邊界的事件包括:

  • 焦點事件:blurfocusfocusinfocusout
  • 滑鼠事件:clickdblclickmousedownmouseentermousemove 等。
  • 輪子事件:wheel
  • 輸入事件:beforeinputinput
  • 鍵盤事件:keydownkeyup
  • 組合事件:compositionstartcompositionupdatecompositionend
  • DragEvent:dragstartdragdragenddrop 等。

使用提示

如果陰影樹狀結構處於開啟狀態,呼叫 event.composedPath() 會傳回事件經過的節點陣列。

使用自訂事件

在陰影樹狀結構的內部節點上觸發的自訂 DOM 事件不會從陰影邊界冒出,除非事件是使用 composed: true 標記建立:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

如果為 composed: false (預設值),消費者就無法在陰影根目錄外監聽事件。

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

處理焦點

回想一下 Shadow DOM 的事件模型,在 Shadow DOM 內部觸發的事件會調整為看起來像是來自主控元素。舉例來說,假設您按一下陰影根目錄中的 <input>

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

focus 事件看起來會像是來自 <x-focus>,而非 <input>。同樣地,document.activeElement 會是 <x-focus>。如果陰影根目錄是使用 mode:'open' 建立的 (請參閱「關閉模式」),您也可以存取獲得焦點的內部節點:

document.activeElement.shadowRoot.activeElement // only works with open mode.

如果有多個 shadow DOM 層級 (例如另一個自訂元素中的自訂元素),您需要遞迴鑽入 shadow 根目錄,才能找到 activeElement

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

另一個焦點選項是 delegatesFocus: true 選項,可擴充陰影樹狀結構中元素的焦點行為:

  • 如果您按一下陰影 DOM 中的節點,而該節點不是可聚焦的區域,系統會將焦點移至第一個可聚焦的區域。
  • 當 shadow DOM 中的節點取得焦點時,:focus 會套用至主機,而非已取得焦點的元素。

示例delegatesFocus: true 如何變更焦點行為

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

結果

delegatesFocus:true 行為。

以上是 <x-focus> 聚焦 (使用者點選、選取標籤、focus() 等) 時的結果,點選「可點選的 Shadow DOM 文字」,或將焦點放在內部 <input> (包括 autofocus) 上。

如果您設定 delegatesFocus: false,則會看到以下畫面:

delegatesFocus: false,且內部輸入內容已獲得焦點。
delegatesFocus: false 和內部 <input> 已獲得焦點。
delegatesFocus: 為 false,且 x-focus 獲得焦點 (例如 tabindex=&#39;0&#39;)。
delegatesFocus: false<x-focus> 取得焦點 (例如,有 tabindex="0")。
delegatesFocus: false,且點選「可點擊的 Shadow DOM 文字」(或點選元素 Shadow DOM 中的其他空白區域)。
delegatesFocus: false 和「可點選的 Shadow DOM 文字」會被點選 (或點選元素 shadow DOM 中的其他空白區域)。

提示和秘訣

多年下來,我對撰寫網頁元件有了一些瞭解。我認為這些提示有些對您編寫元件和偵錯 shadow DOM 很有幫助。

使用 CSS 限制

一般來說,網頁元件的版面配置/樣式/繪製作業相當獨立。在 :host 中使用 CSS 容器可提升效能:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

重設可繼承的樣式

可繼承的樣式 (backgroundcolorfontline-height 等) 仍會在 Shadow DOM 中繼承。也就是說,這些元素會在預設情況下穿過 shadow DOM 邊界。如果您想從頭開始,請在可繼承樣式跨越陰影邊界時,使用 all: initial; 將其重設為初始值。

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

找出網頁使用的所有自訂元素

有時找出網頁上使用的自訂元素會很有幫助。為此,您需要遞迴地檢視網頁中所有元素的 shadow DOM。

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

使用 <template> 建立元素

我們可以使用宣告式 <template>,而非使用 .innerHTML 填入陰影根。範本是宣告網頁元件結構的理想預留位置。

請參閱「自訂元素:建構可重複使用的網頁元件」一文中的範例。

瀏覽記錄和瀏覽器支援

如果您過去幾年一直關注網頁元件,就會知道 Chrome 35 以上版本和 Opera 已推出舊版的陰影 DOM 一段時間。Blink 會持續一段時間並行支援這兩個版本。v0 規格提供不同的方法來建立陰影根目錄 (element.createShadowRoot 而非 v1 的 element.attachShadow)。呼叫較舊的方法會繼續使用 v0 語意建立陰影根目錄,因此現有的 v0 程式碼不會中斷。

如果您對舊版 v0 規格有興趣,請參閱 html5rocks 的文章:123。此外,Shadow DOM v0 與 v1 之間的差異也提供了很棒的比較。

瀏覽器支援

Shadow DOM 第 1 版已隨 Chrome 53 (狀態)、Opera 40、Safari 10 和 Firefox 63 一併發布。Edge 已開始開發

如要偵測 Shadow DOM,請檢查 attachShadow 是否存在:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

聚酯纖維

在瀏覽器廣泛支援之前,shadydomshadycss polyfill 可提供 v1 功能。Shady DOM 模擬 Shadow DOM 的 DOM 範圍,以及 shadycss polyfills CSS 自訂屬性和原生 API 提供的樣式範圍。

安裝 polyfill:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

使用 polyfill:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

如要瞭解如何使用 shim/scope 樣式,請參閱 https://github.com/webcomponents/shadycss#usage

結論

我們首次推出 API 原始元素,可進行適當的 CSS 範圍設定、DOM 範圍設定,並具備真正的組合功能。結合其他網頁元件 API (例如自訂元素),陰影 DOM 可讓您編寫真正封裝的元件,而無需使用駭客攻擊或使用 <iframe> 等舊式包裝。

請別誤會,Shadow DOM 確實是個複雜的怪物!但這項技術值得您學習。花點時間使用這項服務。歡迎學習並提問!

延伸閱讀

常見問題

我現在可以使用 Shadow DOM v1 嗎?

使用 polyfill 即可。請參閱「瀏覽器支援」。

shadow DOM 提供哪些安全防護功能?

Shadow DOM 並非安全功能,這是一個輕量工具,可用於設定 CSS 範圍,並隱藏元件中的 DOM 樹狀結構。如需真正的安全界線,請使用 <iframe>

網頁元件是否必須使用 shadow DOM?

不對!您不必建立使用 shadow DOM 的網頁元件。不過,撰寫使用 Shadow DOM 的自訂元素,可讓您利用 CSS 範圍、DOM 封裝和組合等功能。

開放式和封閉式陰影根有何差異?

請參閱「已關閉的陰影根」。