Shadow DOM 101

Dominic Cooney
Dominic Cooney

簡介

網頁元件是一組最新標準,可用於:

  1. 讓您建構小工具
  2. …可靠地重複使用
  3. …如果元件的下一個版本變更內部實作詳細資料,也不會導致頁面中斷。

這是否表示您必須決定何時使用 HTML/JavaScript,以及何時使用 Web 元件?不可以!HTML 和 JavaScript 可用於製作互動式視覺內容。小工具是互動式視覺元素,開發小工具時,建議您善用 HTML 和 JavaScript 技能。Web 元件標準就是為了協助您達成這項目標。

不過,有一個基本問題會導致以 HTML 和 JavaScript 建構的小工具難以使用:小工具內的 DOM 樹狀結構並未與網頁的其他部分隔離。缺乏封裝的情況代表您的文件樣式表可能會意外套用至小工具內的部分;JavaScript 可能會意外修改小工具內的部分;ID 可能會與小工具內的 ID 重疊,等等。

Web 元件由三個部分組成:

  1. 範本
  2. Shadow DOM
  3. 自訂元素

Shadow DOM 可解決 DOM 樹狀結構封裝問題。網頁元件的四個部分設計為可相互搭配運作,但您也可以選擇要使用的網頁元件部分。本教學課程會說明如何使用 Shadow DOM。

你好,Shadow World

透過 Shadow DOM,元素可以取得與其相關的新類型節點。這種新類型的節點稱為陰影根。具有相關聯陰影根的元素稱為陰影主機。系統不會算繪陰影主機的內容,而是會算繪陰影根的內容。

舉例來說,假設您有以下標記:

<button>Hello, world!</button>
<script>
var host = document.querySelector('button');
var root = host.createShadowRoot();
root.textContent = 'こんにちは、影の世界!';
</script>

而非

<button id="ex1a">Hello, world!</button>
<script>
function remove(selector) {
  Array.prototype.forEach.call(
      document.querySelectorAll(selector),
      function (node) { node.parentNode.removeChild(node); });
}

if (!HTMLElement.prototype.createShadowRoot) {
  remove('#ex1a');
  document.write('<img src="SS1.png" alt="Screenshot of a button with \'Hello, world!\' on it.">');
}
</script>

你的頁面如下所示:

<button id="ex1b">Hello, world!</button>
<script>
(function () {
  if (!HTMLElement.prototype.createShadowRoot) {
    remove('#ex1b');
    document.write('<img src="SS2.png" alt="Screenshot of a button with \'Hello, shadow world!\' in Japanese on it.">');
    return;
  }
  var host = document.querySelector('#ex1b');
  var root = host.createShadowRoot();
  root.textContent = 'こんにちは、影の世界!';
})();
</script>

不僅如此,如果網頁上的 JavaScript 詢問按鈕的 textContent 為何,它不會傳回「こんにちは、影の世界!»,而是「Hello, world!»,因為 shadow root 底下的 DOM 子樹已封裝。

將內容與簡報分開

接下來,我們將探討如何使用 Shadow DOM 將內容與呈現內容分開。假設有這個名稱標籤:

<style>
.ex2a.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.ex2a .boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.ex2a .name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="ex2a outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

以下是標記。這是您今天要寫的內容。不使用 Shadow DOM:

<style>
.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

由於 DOM 樹狀結構缺乏封裝,因此 name 標記的整個結構會公開給文件。如果網頁上的其他元素不小心使用相同的樣式或指令碼類別名稱,就會造成問題。

我們可以避免發生不愉快的情況。

步驟 1:隱藏簡報詳細資料

從語意上來說,我們可能只在乎:

  • 這是名牌。
  • 名稱為「Bob」。

首先,我們會編寫更接近所需實際語意的標記:

<div id="nameTag">Bob</div>

接著,我們將用於呈現的所有樣式和 div 放入 <template> 元素:

<div id="nameTag">Bob</div>
<template id="nameTagTemplate">
<span class="unchanged"><style>
.outer {
  border: 2px solid brown;

  … same as above …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div></span>
</template>

此時,系統只會算繪「Bob」。由於我們將呈現性 DOM 元素移至 <template> 元素中,因此這些元素不會轉譯,但可以透過 JavaScript 存取。我們現在就來填入陰影根目錄:

<script>
var shadow = document.querySelector('#nameTag').createShadowRoot();
var template = document.querySelector('#nameTagTemplate');
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

設定陰影根目錄後,系統會再次轉譯名稱標記。如果您在名稱標籤上按一下滑鼠右鍵並檢查元素,就會看到精美的語意標記:

<div id="nameTag">Bob</div>

這表示我們使用 Shadow DOM 時,已隱藏文件中名稱標記的呈現詳細資料。呈現詳細資料會封裝在 Shadow DOM 中。

步驟 2:將內容與簡報分開

我們的名稱標記現在會隱藏頁面中的呈現詳細資料,但實際上並不會將呈現內容與內容分開,因為雖然內容 (名稱「Bob」) 位於頁面中,但算繪的名稱是我們複製到陰影根目錄中的名稱。如果想變更名稱標籤上的名稱,我們需要在兩個地方進行,且可能會出現不同步的情況。

HTML 元素是組合式元素,例如您可以將按鈕放在資料表中。這裡需要的是組合:名稱標籤必須由紅色背景、文字「Hi!」和名稱標籤上的內容組合而成。

您 (元件作者) 會使用名為 <content> 的新元素,定義組合與小工具的運作方式。這會在小工具的呈現方式中建立插入點,而插入點會從陰影主機精選內容,並在該點呈現。

如果我們將 Shadow DOM 中的標記變更為以下內容:

<span class="unchanged"><template id="nameTagTemplate">
<style>
  …
</style></span>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    <content></content>
  </div>
</div>
<span class="unchanged"></template></span>

算繪名稱標記時,陰影主機的內容會投射到 <content> 元素顯示的位置。

由於名稱只出現在文件中,因此文件結構變得更簡單。如果頁面需要更新使用者名稱,只要寫下以下內容即可:

document.querySelector('#nameTag').textContent = 'Shellie';

就是這樣。瀏覽器會自動更新名稱標記的顯示效果,因為我們會使用 <content> 將名稱標記的內容投射到適當位置。

<div id="ex2b">

我們現在已將內容和呈現方式分開。內容位於文件中,而呈現方式則位於 Shadow DOM 中。在算繪時,瀏覽器會自動保持同步。

步驟 3:獲利

透過分隔內容和呈現方式,我們可以簡化用於操作內容的程式碼。在名稱標記範例中,程式碼只需要處理包含一個 <div> 的簡單結構,而非多個。

現在,如果我們變更呈現方式,就不需要變更任何程式碼!

舉例來說,假設我們想將名稱標籤本地化。它仍是名稱標記,因此文件中的語意內容不會變更:

<div id="nameTag">Bob</div>

陰影根設定程式碼則維持不變。只有放入陰影根的內容會有所變更:

<template id="nameTagTemplate">
<style>
.outer {
  border: 2px solid pink;
  border-radius: 1em;
  background: url(sakura.jpg);
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
  font-family: sans-serif;
  font-weight: bold;
}
.name {
  font-size: 45pt;
  font-weight: normal;
  margin-top: 0.8em;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="name">
    <content></content>
  </div>
  と申します。
</div>
</template>

這項功能相較於目前的網頁情況,可說是大幅改善,因為您的名稱更新程式碼可依賴元件的結構,而這類結構簡單且一致。名稱更新程式碼不需要知道用於轉譯的結構。若考慮到實際顯示的內容,英文名稱會在「Hi! My name is”),但先以日文表示 (在「と申します」之前)。從更新顯示名稱的角度來看,這兩種說法在語意上沒有意義,因此名稱更新程式碼不需要知道這項細節。

額外學分:進階投影

在上述範例中,<content> 元素會從陰影主機中挑選所有內容。您可以使用 select 屬性控管內容元素的內容。您也可以使用多個內容元素。

舉例來說,如果您有一份包含以下內容的文件:

<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>

以及使用 CSS 選取器選取特定內容的陰影根:

<div style="background: purple; padding: 1em;">
  <div style="color: red;">
    <content **select=".first"**></content>
  </div>
  <div style="color: yellow;">
    <content **select="div"**></content>
  </div>
  <div style="color: blue;">
    <content **select=".email">**</content>
  </div>
</div>

<div class="email"> 元素會與 <content select="div"><content select=".email"> 元素相符。Bob 的電子郵件地址出現幾次?顏色為何?

答案是 Bob 的電子郵件地址出現一次,且為黃色。

原因是,如同對 Shadow DOM 進行駭客攻擊的使用者所知,建構螢幕上實際轉譯內容的樹狀結構,就像是舉辦大型派對一樣。內容元素是邀請函,可讓文件中的內容進入幕後 Shadow DOM 轉譯方。這些邀請會依序傳送;誰會收到邀請,取決於邀請函的收件者 (即 select 屬性)。內容一經邀請,就會一律接受邀請 (誰會拒絕呢?),然後就會開始播放。如果後續邀請再次傳送到該地址,那麼沒有人在家,邀請函也不會送達。

在上述範例中,<div class="email"> 會同時比對 div 選取器和 .email 選取器,但由於含有 div 選取器的內容元素在文件中出現得較早,<div class="email"> 會進入黃色群組,而沒有人會進入藍色群組。(這也許是為何這麼藍的原因,雖然「患難見真情」,但你永遠不會知道。)

如果某個項目邀請到「無」群組,則不會產生任何算繪。這是最早的範例中「Hello, world」文字發生的情況。這項做法可用於產生截然不同的轉譯結果:在文件中編寫語意模型,讓網頁中的指令碼可以存取,但為了轉譯目的而隱藏該模型,並使用 JavaScript 將其連結至 Shadow DOM 中截然不同的轉譯模型。

舉例來說,HTML 有一個不錯的日期選擇器。如果您寫入 <input type="date">,系統就會彈出簡潔的日曆。不過,如果您想讓使用者為沙漠島度假挑選日期範圍 (您知道的,有紅葡萄藤製成的吊床),您可以按照下列步驟設定文件:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

但建立 Shadow DOM,使用表格建立精美的日曆,以便醒目顯示日期範圍等。當使用者點選日曆中的日期時,元件會更新 startDate 和 endDate 輸入內容中的狀態;當使用者提交表單時,這些輸入元素的值就會提交。

如果標籤不會顯示,為何要在文件中加入標籤?原因是,如果使用者使用不支援 Shadow DOM 的瀏覽器查看表單,表單仍可正常使用,只是外觀不如預期。使用者會看到類似以下的畫面:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

您通過了 Shadow DOM 101

以上就是 Shadow DOM 的基本概念,您已通過 Shadow DOM 101 課程!您可以使用 Shadow DOM 執行更多操作,例如在單一陰影主機上使用多個陰影、為封裝使用巢狀陰影,或使用模型導向檢視畫面 (MDV) 和 Shadow DOM 建構網頁。網頁元件不僅僅是 Shadow DOM。

我們會在後續文章中說明這些內容。