網頁程式開發人員可透過 Shadow DOM,為網頁元件建立區隔的 DOM 和 CSS
摘要
Shadow DOM 可消除建構網頁應用程式時的脆弱性。脆弱性來自 HTML、CSS 和 JS 的全域性質。多年來,我們發明瞭大量工具來規避這些問題。舉例來說,使用新的 HTML ID/類別時,無法確定是否會與網頁使用的現有名稱衝突。細微的錯誤會悄悄出現,CSS 特異性會成為重大問題 (!important 所有項目!),樣式選取器會失控,效能可能會受到影響。還有更多功能。
Shadow DOM 可修正 CSS 和 DOM。它為網頁平台引入了限定範圍樣式。不需使用工具或命名慣例,您就能將 CSS 與標記綁定、隱藏實作細節,並在原生 JavaScript 中編寫獨立元件。
簡介
Shadow DOM 是三種網頁元件標準之一:HTML 範本、Shadow DOM 和自訂元素。HTML 匯入曾是清單的一部分,但現在已淘汰。
您不一定要編寫使用 Shadow DOM 的網頁元件。但只要使用,就能享有其優點 (CSS 範圍、DOM 封裝、組合),並建構可重複使用的自訂元素,這些元素具有彈性、高度可設定,且極具重複使用性。如果自訂元素是建立新 HTML (使用 JS API) 的方式,shadow DOM 就是提供 HTML 和 CSS 的方式。這兩個 API 結合後,就能建立包含 HTML、CSS 和 JavaScript 的元件。
Shadow DOM 是建構元件式應用程式的工具,因此,它能解決網頁開發中的常見問題:
- 隔離 DOM:元件的 DOM 是獨立的 (例如
document.querySelector()不會傳回元件 shadow DOM 中的節點)。 - 限定範圍的 CSS:在 Shadow DOM 中定義的 CSS 僅限於該 DOM 範圍內。樣式規則不會外洩,頁面樣式也不會滲入。
- 組合結構:為元件設計宣告式、以標記為基礎的 API。
- 簡化 CSS:限定範圍的 DOM 代表您可以使用簡單的 CSS 選取器、更通用的 ID/類別名稱,不必擔心命名衝突。
- 工作效率:將應用程式視為 DOM 區塊,而非單一大型 (全域) 頁面。
fancy-tabs 示範
在本文中,我會提到示範元件 (<fancy-tabs>),並參考其中的程式碼片段。如果瀏覽器支援 API,下方應會顯示即時示範。否則,請查看 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… in the shadows
Shadow DOM 只是正常的 DOM,但有兩項差異:1) 建立/使用方式,以及 2) 與網頁其他部分的行為關係。一般來說,您會建立 DOM 節點,並將其附加為另一個元素的子項。使用 Shadow DOM 時,您會建立附加至元素,但與實際子項分開的限定範圍 DOM 樹狀結構。這個限定範圍的子樹稱為「shadow tree」。附加的元素是其「shadow host」。您在陰影中新增的任何內容都會成為託管元素的本機內容,包括 <style>。這就是 Shadow DOM 實現 CSS 樣式範圍的方式。
建立 Shadow DOM
Shadow root 是附加至「主體」元素的文件片段。附加 shadow root 的動作,就是元素取得 shadow 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 填入 Shadow Root,但您也可以使用其他 DOM API。這就是網路。我們有選擇。
規格定義了無法託管陰影樹狀結構的元素清單。元素可能因以下幾個原因而列入清單:
- 瀏覽器已為元素 (
<textarea>、<input>) 代管自己的內部 shadow DOM。 - 元素不適合託管 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 DOM。這項操作會在 constructor() 中完成。其次,由於我們正在建立 Shadow Root,<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 是元件的本機 DOM,可定義元件的內部結構、限定範圍的 CSS,並封裝實作詳細資料。也可以定義如何轉譯元件使用者編寫的標記。
#shadow-root
<style>...</style>
<slot name="icon"></slot>
<span id="wrapper">
<slot>Button</slot>
</span>
扁平化 DOM 樹狀結構
瀏覽器將使用者的 light DOM 分配到 shadow DOM 的結果,會轉譯最終產品。扁平化樹狀結構是您最終在開發人員工具中看到的內容,也是頁面上呈現的內容。
<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 樹狀結構組合在一起。版位是元件內的預留位置,使用者可以填入自己的標記。定義一或多個版位,即可邀請外部標記在元件的 Shadow DOM 中算繪。基本上,您是在說「在這裡轉譯使用者的標記」。
當 <slot> 邀請元素進入時,元素可以「跨越」shadow DOM 邊界。這些元素稱為「分散式節點」。從概念上來說,分散式節點可能有點奇怪。版位不會實際移動 DOM,而是在 Shadow 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>
您也可以建立具名版位。具名版位是 Shadow 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> 一樣!
樣式
設定網頁元件樣式的方法有很多種。使用 Shadow DOM 的元件可由主頁面設定樣式、定義自己的樣式,或提供掛鉤 (以 CSS 自訂屬性的形式),供使用者覆寫預設值。
元件定義的樣式
Shadow DOM 最實用的功能絕對是限定範圍的 CSS:
- 外部網頁的 CSS 選取器不會套用至元件內部。
- 內部定義的樣式不會溢出。範圍限定在主機元素。
在 Shadow DOM 中使用的 CSS 選取器會在本機套用至元件。在實務上,這表示我們可以再次使用常見的 ID/類別名稱,不必擔心與頁面其他部分發生衝突。在 Shadow DOM 中,最佳做法是使用較簡單的 CSS 選取器。此外,這類廣告也有助於提升成效。
範例:在 Shadow Root 中定義的樣式為本機樣式
#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 Tree 範圍內:
#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 僅適用於 Shadow Root 的內容,因此無法在 Shadow 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) 會在 <fancy-tabs> 是 .darktheme 的子系時設定樣式:
: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> 不會移動使用者的 Light 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。
進階主題
建立封閉式 Shadow Root (應避免)
Shadow DOM 還有另一種「封閉」模式。建立封閉式 Shadow Tree 時,外部 JavaScript 無法存取元件的內部 DOM。這與 <video> 等原生元素的運作方式類似。JavaScript 無法存取 <video> 的 Shadow DOM,因為瀏覽器使用封閉模式 Shadow Root 實作。
範例 - 建立封閉式 Shadow Tree:
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傳回nullEvent.composedPath():針對與 Shadow DOM 內元素相關聯的事件,傳回 []
以下是我對為何不應使用 {mode: 'closed'} 建立網頁元件的摘要:
虛假的安全感。攻擊者可以輕易劫持
Element.prototype.attachShadow。封閉模式會禁止自訂元素程式碼存取自己的 Shadow 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'); } ... });封閉模式會降低元件對使用者的彈性。建構網頁元件時,您可能會忘記新增功能。設定選項。使用者想要的用途。常見的例子是忘記為內部節點加入適當的樣式掛鉤。在封閉模式下,使用者無法覆寫預設值及調整樣式。能夠存取元件內部非常實用。如果元件無法滿足需求,使用者最終會分支元件、尋找其他元件,或自行建立元件 :(
在 JS 中使用版位
Shadow DOM API 提供用於處理版位和分散式節點的公用程式。在編寫自訂元素時,這些方法非常實用。
slotchange 事件
當版位的分散節點變更時,系統會觸發 slotchange 事件。例如,如果使用者從 Light 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>component text</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 內部的元素。部分事件甚至不會傳播到 Shadow DOM 之外。
會跨越陰影邊界的事件包括:
- 焦點事件:
blur、focus、focusin、focusout - 滑鼠事件:
click、dblclick、mousedown、mouseenter、mousemove等。 - 滾輪事件:
wheel - 輸入事件:
beforeinput、input - 鍵盤事件:
keydown、keyup - 組合事件:
compositionstart、compositionupdate、compositionend - DragEvent:
dragstart、drag、dragend、drop等。
使用提示
如果 Shadow Tree 處於開啟狀態,呼叫 event.composedPath() 會傳回事件經過的節點陣列。
使用自訂事件
在 Shadow Tree 內部節點上觸發的自訂 DOM 事件不會在 Shadow 邊界外浮動,除非事件是使用 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 (預設),消費者將無法在 Shadow Root 外部監聽事件。
<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 內觸發的事件會經過調整,看起來像是來自代管元素。舉例來說,假設您點選 Shadow Root 內的 <input>:
<x-focus>
#shadow-root
<input type="text" placeholder="Input inside shadow dom">
focus 事件看起來會像是來自 <x-focus>,而不是 <input>。同樣地,document.activeElement 將會是 <x-focus>。如果 Shadow Root 是使用 mode:'open' 建立 (請參閱封閉模式),您也可以存取取得焦點的內部節點:
document.activeElement.shadowRoot.activeElement // only works with open mode.
如果有多層 Shadow DOM (例如自訂元素內有另一個自訂元素),則需要遞迴深入 Shadow Root,才能找到 activeElement:
function deepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement;
}
return a;
}
另一個焦點選項是 delegatesFocus: true 選項,可擴充 Shadow Tree 中元素的焦點行為:
- 如果點選 Shadow 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>
結果
上圖是 <x-focus> 獲得焦點 (使用者點選、透過 Tab 鍵切換、focus() 等)、「Clickable Shadow DOM text」獲得點選,或內部 <input> 獲得焦點 (包括 autofocus) 時的結果。
如果設定 delegatesFocus: false,則會看到以下內容:
delegatesFocus: false 和內部 <input> 處於焦點狀態。
delegatesFocus: false 和 <x-focus> 取得焦點 (例如具有 tabindex="0")。
delegatesFocus: false 和「Clickable Shadow DOM text」遭到點選 (或元素 shadow DOM 內的其他空白區域遭到點選)。
提示和祕訣
多年來,我對編寫網頁元件略知一二。相信您會發現其中一些提示對於編寫元件和偵錯 Shadow DOM 很有幫助。
使用 CSS 限制
一般來說,網頁元件的版面配置/樣式/繪製相當獨立。在 :host 中使用 CSS 限制,可提升效能:
<style>
:host {
display: block;
contain: content; /* Boom. CSS containment FTW. */
}
</style>
重設可沿用的樣式
可繼承的樣式 (background、color、font、line-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 填入 Shadow Root。範本是宣告網頁元件結構的理想預留位置。
請參閱「自訂元素:建構可重複使用的網頁元件」中的範例。
記錄和瀏覽器支援
如果您在過去幾年一直關注網頁元件,就會知道 Chrome 35+/Opera 已推出舊版 Shadow DOM 一段時間。Blink 會繼續同時支援這兩個版本一段時間。v0 規格提供不同的方法來建立陰影根 (element.createShadowRoot,而非 v1 的 element.attachShadow)。呼叫舊方法會繼續建立具有 v0 語意的陰影根,因此現有的 v0 程式碼不會中斷。
如果您對舊版 v0 規格感興趣,請參閱 html5rocks 文章:1、2、3。此外,您也可以參閱這篇文章,進一步瞭解 Shadow DOM v0 和 v1 之間的差異。
瀏覽器支援
Shadow DOM v1 隨附於 Chrome 53 (狀態)、Opera 40、Safari 10 和 Firefox 63。Edge 已開始開發。
如要偵測 Shadow DOM,請檢查 attachShadow 是否存在:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
Polyfill
在瀏覽器廣泛支援之前,shadydom 和 shadycss polyfill 可提供 v1 功能。Shady DOM 會模擬 Shadow DOM 的 DOM 範圍,而 shadycss polyfill 則會模擬 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!
}
如要瞭解如何填充/限定樣式範圍,請參閱 https://github.com/webcomponents/shadycss#usage。
結論
我們首次推出 API 原始物件,可執行適當的 CSS 範圍設定、DOM 範圍設定,並具備真正的組合功能。Shadow DOM 結合自訂元素等其他網頁元件 API,可讓您編寫真正封裝的元件,不必使用駭客手法或 <iframe> 等舊版元件。
別誤會,Shadow DOM 確實是個複雜的怪獸!但這項技術值得學習。花點時間熟悉這項功能。歡迎瞭解並提出問題!
延伸閱讀
- Shadow DOM v1 和 v0 的差異
- WebKit 網誌的「Introducing Slot-Based Shadow DOM API」。
- 網頁元件和模組化 CSS 的未來 作者:Philip Walton
- Google WebFundamentals 的「Custom elements: building reusable web components」(自訂元素:建構可重複使用的網頁元件)。
- Shadow DOM v1 規格
- 自訂元素 v1 規格
常見問題
現在可以使用 Shadow DOM 第 1 版嗎?
如果使用 polyfill,答案是肯定的。請參閱「瀏覽器支援」。
Shadow DOM 提供哪些安全防護功能?
Shadow DOM 並非安全防護功能。這項輕量工具可設定 CSS 範圍,並隱藏元件中的 DOM 樹狀結構。如要建立真正的安全邊界,請使用 <iframe>。
網頁元件是否必須使用 shadow DOM?
不對!您不一定要建立使用 shadow DOM 的網頁元件。不過,編寫使用 Shadow DOM 的自訂元素,代表您可以使用 CSS 範圍、DOM 封裝和組合等功能。
開放式和封閉式 Shadow Root 有何差異?
請參閱「封閉式 Shadow Root」。