陰影 DOM 301

進階概念和 DOM API

本文將進一步探討 Shadow DOM 的各種精彩功能!本課程是以「Shadow DOM 101」和「Shadow DOM 201」中討論的概念為基礎。

使用多個影子根

舉辦派對時,如果所有人都擠在同一個房間,就會感到悶熱。您希望可以將參與者分組,並分配到多個討論室。代管 Shadow DOM 的元素也可以這麼做,也就是說,它們可以一次代管多個 Shadow Root。

如果嘗試將多個 Shadow Root 附加至主機,會發生什麼情況:

<div id="example1">Light DOM</div>
<script>
  var container = document.querySelector('#example1');
  var root1 = container.createShadowRoot();
  var root2 = container.createShadowRoot();
  root1.innerHTML = '<div>Root 1 FTW</div>';
  root2.innerHTML = '<div>Root 2 FTW</div>';
</script>

雖然我們已附加 Shadow Tree,但轉譯結果仍是「Root 2 FTW」。這是因為最後新增至主機的影子樹會勝出。就轉譯而言,這是一個 LIFO 堆疊。檢查開發人員工具即可驗證這項行為。

如果只有最後一個陰影會顯示,那為什麼要使用多個陰影呢?輸入陰影插入點。

影子插入點

「影子插入點」(<shadow>) 與一般插入點 (<content>) 類似,都是預留位置。不過,它們並非主機內容的預留位置,而是其他陰影樹狀結構的主機。這就是 Shadow DOM 的全面啟動!

如您所想,越深入探究,事情就越複雜。因此,規格明確說明瞭多個 <shadow> 元素同時作用時會發生的情況:

回顧原始範例,第一個陰影 root1 未列入邀請清單。新增 <shadow> 插入點即可恢復:

<div id="example2">Light DOM</div>
<script>
var container = document.querySelector('#example2');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();
root1.innerHTML = '<div>Root 1 FTW</div><content></content>';
**root2.innerHTML = '<div>Root 2 FTW</div><shadow></shadow>';**
</script>

這個範例有幾個有趣之處:

  1. 「Root 2 FTW」仍會顯示在「Root 1 FTW」上方。這是因為我們放置 <shadow> 插入點的位置。如要反向移動,請移動插入點:root2.innerHTML = '<shadow></shadow><div>Root 2 FTW</div>';。
  2. 請注意,root1 中現在有 <content> 插入點。這會使文字節點「Light DOM」一併顯示。

<shadow> 轉譯的內容為何?

有時,瞭解在 <shadow> 轉譯的舊 Shadow Tree 很有用。您可以透過 .olderShadowRoot 取得該樹狀結構的參照:

**root2.olderShadowRoot** === root1 //true

取得主機的影子根

如果元素代管 Shadow DOM,您可以使用 .shadowRoot 存取其最新 Shadow Root:

var root = host.createShadowRoot();
console.log(host.shadowRoot === root); // true
console.log(document.body.shadowRoot); // null

如果擔心有人跨入陰影,請將 .shadowRoot 重新定義為空值:

Object.defineProperty(host, 'shadowRoot', {
  get: function() { return null; },
  set: function(value) { }
});

雖然有點取巧,但確實有效。最後,請務必記住,雖然 Shadow DOM 非常出色,但並非設計為安全防護功能。請勿依賴這項功能完全隔離內容。

在 JS 中建構 Shadow DOM

如果您偏好在 JS 中建構 DOM,HTMLContentElement 和 HTMLShadowElement 都有提供相關介面。

<div id="example3">
  <span>Light DOM</span>
</div>
<script>
var container = document.querySelector('#example3');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();

var div = document.createElement('div');
div.textContent = 'Root 1 FTW';
root1.appendChild(div);

 // HTMLContentElement
var content = document.createElement('content');
content.select = 'span'; // selects any spans the host node contains
root1.appendChild(content);

var div = document.createElement('div');
div.textContent = 'Root 2 FTW';
root2.appendChild(div);

// HTMLShadowElement
var shadow = document.createElement('shadow');
root2.appendChild(shadow);
</script>

這個範例與上一節的範例幾乎相同。唯一的差別在於,現在我使用 select 提取新加入的 <span>。

使用插入點

從主體元素中選取並「分布」到 Shadow 樹狀結構中的節點,稱為…(請擊鼓)…分布節點!當插入點邀請他們進入時,他們可以跨越陰影邊界。

插入點在概念上很奇怪,因為它們不會實際移動 DOM。主機的節點會保持不變。插入點只會將節點從主機重新投影到 Shadow Tree。這與呈現/轉譯有關:「將這些節點移到這裡」「在這個位置轉譯這些節點」。

例如:

<div><h2>Light DOM</h2></div>
<script>
var root = document.querySelector('div').createShadowRoot();
root.innerHTML = '<content select="h2"></content>';

var h2 = document.querySelector('h2');
console.log(root.querySelector('content[select="h2"] h2')); // null;
console.log(root.querySelector('content').contains(h2)); // false
</script>

大功告成!h2 不是 Shadow DOM 的子項。這帶出另一個重點:

Element.getDistributedNodes()

我們無法周遊 <content>,但 .getDistributedNodes() API 可讓我們在插入點查詢分散式節點:

<div id="example4">
  <h2>Eric</h2>
  <h2>Bidelman</h2>
  <div>Digital Jedi</div>
  <h4>footer text</h4>
</div>

<template id="sdom">
  <header>
    <content select="h2"></content>
  </header>
  <section>
    <content select="div"></content>
  </section>
  <footer>
    <content select="h4:first-of-type"></content>
  </footer>
</template>

<script>
var container = document.querySelector('#example4');

var root = container.createShadowRoot();

var t = document.querySelector('#sdom');
var clone = document.importNode(t.content, true);
root.appendChild(clone);

var html = [];
[].forEach.call(root.querySelectorAll('content'), function(el) {
  html.push(el.outerHTML + ': ');
  var nodes = el.getDistributedNodes();
  [].forEach.call(nodes, function(node) {
    html.push(node.outerHTML);
  });
  html.push('\n');
});
</script>

Element.getDestinationInsertionPoints()

與 .getDistributedNodes() 類似,您可以呼叫節點的 .getDestinationInsertionPoints(),查看節點分配到哪些插入點:

<div id="host">
  <h2>Light DOM
</div>

<script>
  var container = document.querySelector('div');

  var root1 = container.createShadowRoot();
  var root2 = container.createShadowRoot();
  root1.innerHTML = '<content select="h2"></content>';
  root2.innerHTML = '<shadow></shadow>';

  var h2 = document.querySelector('#host h2');
  var insertionPoints = h2.getDestinationInsertionPoints();
  [].forEach.call(insertionPoints, function(contentEl) {
    console.log(contentEl);
  });
</script>

工具:Shadow DOM Visualizer

Shadow DOM 就像黑魔法一樣難以理解。我還記得第一次試著理解這項技術時,

為了協助您瞭解 Shadow DOM 轉譯的運作方式,我使用 d3.js 建構了一項工具。左側的兩個標記方塊皆可編輯。你可以貼上自己的標記,並試著瞭解運作方式,以及插入點如何將主機節點切換至 Shadow Tree。

Shadow DOM 視覺化工具
啟動 Shadow DOM Visualizer

歡迎試用,並與我們分享看法!

事件模型

有些事件會跨越 Shadow DOM 邊界,有些則不會。如果事件跨越邊界,系統會調整事件目標,以維持 Shadow Root 上邊界提供的封裝。也就是說,事件會重新指定目標,看起來像是來自代管元素,而不是 Shadow DOM 的內部元素。

播放動作 1

  • 這點很有意思。您應該會看到從主機元素 (<div data-host>) 到藍色節點的 mouseout。雖然是分散式節點,但仍在主機中,而非 ShadowDOM。將滑鼠游標進一步向下移至黃色區域,會再次在藍色節點上觸發 mouseout。

播放動作 2

  • 主機上會顯示一個 mouseout (在最尾端)。一般來說,所有黃色區塊都會觸發 mouseout 事件。不過,在這種情況下,這些元素位於 Shadow DOM 內部,事件不會向上傳遞至其上邊界。

播放動作 3

  • 請注意,當您點選輸入時,focusin 不會顯示在輸入上,而是顯示在主機節點本身。已重新指定目標!

一律停止的事件

下列事件一律不會跨越 Shadow DOM 邊界:

  • abort
  • 錯誤
  • 選取
  • 變更
  • load
  • 重設
  • resize
  • scroll
  • selectstart

結論

希望您也同意 Shadow DOM 非常強大。我們首次採用適當的封裝技術,不需額外使用 <iframe> 或其他舊技術。

Shadow DOM 確實是複雜的野獸,但值得加入網路平台。花點時間熟悉這項工具。輕鬆掌握。提出問題。

如要瞭解詳情,請參閱 Dominic 的簡介文章「Shadow DOM 101」和我的「Shadow DOM 201:CSS 和樣式」一文。