Shadow DOM 101

Dominic Cooney
Dominic Cooney

簡介

網頁元件是一組先進標準,可:

  1. 允許建構小工具
  2. …可穩定重複使用
  3. …而且如果元件的下一個版本變更內部實作細節,也不會破壞頁面。

這是否意味著您必須決定何時使用 HTML/JavaScript,以及何時使用網頁元件?才不是!HTML 和 JavaScript 可以製作互動式視覺內容。小工具是互動式視覺元素。開發小工具時,建議您運用 HTML 和 JavaScript 技能。網頁元件標準旨在協助您達成這個目標。

不過,HTML 和 JavaScript 建構的小工具,在使用上會遇到一個根本問題:小工具內部的 DOM 樹狀結構並未與網頁的其他部分封裝。缺乏封裝意味著文件樣式表可能會意外套用至小工具內部的部分;JavaScript 可能會意外修改小工具內部的部分;ID 可能會與小工具內部的 ID 重疊;等等。

Web Components 由三個部分組成:

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

Shadow DOM 可解決 DOM 樹狀結構封裝問題。Web Components 的四個部分旨在搭配運作,但您也可以選擇要使用 Web Components 的哪些部分。本教學課程說明如何使用 Shadow DOM。

Hello, Shadow World

有了 Shadow DOM,元素就能取得與其相關聯的新節點。這種新節點稱為「shadow root」。與 Shadow Root 相關聯的元素稱為「Shadow Host」。系統不會算繪 Shadow 主機的內容,而是算繪 Shadow 根目錄的內容。

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

<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 樹狀結構缺乏封裝,名稱標記的整個結構都會公開給文件。如果網頁上的其他元素不小心使用相同的類別名稱來設定樣式或指令碼,就會造成問題。

我們就能避免不愉快的體驗。

步驟 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 存取。現在,我們將執行這項操作,填入 Shadow Root:

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

設定 Shadow Root 後,名稱標記會再次算繪。如果對名稱標記按一下滑鼠右鍵並檢查元素,就會看到以下語意標記:

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

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

步驟 2:將內容與呈現方式分開

現在,名稱標記會隱藏頁面中的呈現細節,但實際上並未將呈現與內容分開,因為雖然內容 (名稱「Bob」) 位於頁面中,但呈現的名稱是我們複製到 Shadow Root 的名稱。如果想變更名稱標記上的名稱,就必須在兩個地方進行變更,而且可能無法同步。

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

您是元件作者,可以使用名為 <content> 的新元素,定義組合與小工具的搭配方式。這會在小工具的呈現中建立插入點,而插入點會從 Shadow 主機中挑選內容,並在該點呈現。

如果將 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>

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

現在,文件的結構更簡單,因為名稱只會出現在一個地方,也就是文件。如果網頁需要更新使用者名稱,只要編寫以下程式碼即可:

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

這樣就完成了。瀏覽器會自動更新名稱標記的轉譯,因為我們使用 <content> 將名稱標記的內容投影到適當位置。

<div id="ex2b">

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

步驟 3:獲利

將內容和呈現方式分開,可以簡化操作內容的程式碼。在名稱標記範例中,該程式碼只需要處理包含一個 <div> 的簡單結構,而不是多個。

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

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

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

Shadow Root 設定碼維持不變。只有放入 shadow root 的內容會改變:

<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! 「我的名字是」),但在日文中,名字會放在「と申します」之前。從更新顯示名稱的角度來看,這種區別在語意上沒有意義,因此名稱更新程式碼不需要知道這個細節。

額外課程內容:進階投影

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

舉例來說,如果文件包含以下內容:

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

以及使用 CSS 選取器選取特定內容的 Shadow Root:

<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">,就會看到整齊的彈出式日曆。但如果想讓使用者選擇甜點島度假的日期範圍 (你知道的…用 Red Vines 做的吊床),該怎麼辦?請按照下列方式設定文件:

<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 主機上使用多個 Shadow、使用巢狀 Shadow 進行封裝,或使用模型導向檢視 (MDV) 和 Shadow DOM 建構網頁。Web 元件不只是 Shadow DOM。

我們會在後續文章中說明。