Shadow DOM 101

Dominic Cooney
Dominic Cooney

簡介

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

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

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

不過,有一個基本問題會導致以 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 執行更多操作,例如在一個 shadow host 上使用多個 shadow、為封裝使用巢狀 shadow,或是使用模型導向檢視畫面 (MDV) 和 Shadow DOM 架構網頁。網頁元件不僅僅是 Shadow DOM。

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