陰影 DOM 301

進階概念與 DOM API

本文將進一步說明 Shadow DOM 的更多驚人功能!這門課程以「Shadow DOM 101」和「Shadow DOM 201」中討論的概念為基礎。

使用多個陰影根

如果你要舉辦派對,如果所有人都擠在同一個房間,會讓空間變得擁擠悶熱。您希望將成員群組分配至多個房間。代管 Shadow DOM 的元素也可以這樣做,也就是說,它們可以同時代管多個陰影根。

讓我們來看看如果嘗試將多個陰影根附加至主機,會發生什麼情況:

<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>

雖然我們已附加陰影樹狀結構,但實際顯示的內容是「Root 2 FTW」。這是因為在主機中新增的最後一個陰影樹狀結構會勝出。就算繪而言,這是 LIFO 堆疊。檢查開發人員工具會驗證這項行為。

那麼,如果只有最後一個受邀對象邀請轉譯方,使用多個陰影呢?輸入陰影插入點。

陰影插入點

「陰影插入點」(<shadow>) 與一般的插入點 (<content>) 相似,因此它們是預留位置。不過,這些元素並非主機的內容預留位置,而是其他陰影樹狀結構的主機。那就是 Shadow DOM Inception!

如您所知,越深入研究,情況就會越複雜。因此,規格非常清楚地指出有多個 <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> 會算繪較舊的陰影樹有助於解決問題。您可以透過 .olderShadowRoot 取得該樹狀結構的參照:

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

取得主機的陰影根

如果元素代管 Shadow DOM,您可以使用 .shadowRoot 存取其最年輕的陰影根

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,HTMLContentElementHTMLShadowElement 都有相關介面。

<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>

使用插入點

從主機元素選取並「分配」到陰影樹的節點稱為...擊鼓...分散式節點!插入點邀請時,可允許跨越陰影邊界。

插入點在概念上奇怪的地方,是它們不會實際移動 DOM。主機的節點會維持不變。插入點只會從主機重新專案節點到陰影樹狀結構中。這是呈現/算繪作業:「將這些節點移到這裡」「在這個位置算繪這些節點」。

例如:

<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 的子項。這會產生另一個 Tid 位元:

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 視覺化工具

要瞭解 Shadow DOM 的黑魔法很困難,我記得第一次嘗試瞭解這項技術。

為了讓您更清楚瞭解 Shadow DOM 算繪作業的運作方式,我使用 d3.js 建立了一個工具。左側的兩個標記方塊均可編輯。您可以貼上自己的標記,並試著看看這些標記如何運作,以及插入點如何將主機節點轉換至陰影樹狀結構。

Shadow DOM Visualizer
啟動 Shadow DOM Visualizer

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

事件模型

有些事件會跨越陰影邊界,有些則不會。在事件跨越邊界時,系統會調整事件目標,以維持陰影根的上限邊界提供的封裝。也就是說,事件會重新指定目標,讓事件看起來像是來自主機元素,而非 Shadow DOM 的內部元素

播放動作 1

  • 這個很有趣。您應該會看到從主機元素 (<div data-host>) 到藍色節點的 mouseout。雖然它是分散式節點,但仍位於主機中,而非 ShadowDOM。再將滑鼠游標移到黃色區域,藍色節點上就會出現 mouseout

Google Play 動作 2

  • 主機上會顯示一個 mouseout (位於最末端)。通常,您會看到 mouseout 事件觸發所有黃色區塊。不過,在這種情況下,這些元素是 Shadow DOM 的內部元素,且事件不會透過上限邊界傳遞。

Play Action 3

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

一律停止的事件

下列事件永遠不會跨越陰影邊界:

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

結論

希望你同意,Shadow DOM 非常強大。我們首次實現了正確的封裝,不必再使用 <iframe> 或其他舊技術。

Shadow DOM 確實是個複雜的怪物,但值得加入網頁平台。花點時間使用這項服務。學習。提出問題。

如要進一步瞭解,請參閱 Dominic 的簡介文章「Shadow DOM 101」,以及我的「Shadow DOM 201:CSS 與樣式」一文。