Shadow DOM 101

はじめに

Web Components は、次のような最先端の標準仕様のセットです。

  1. ウィジェットを作成できるようにする
  2. …信頼性の高い再利用が可能
  3. コンポーネントの次のバージョンで内部実装の詳細が変更されても、ページが破損することはありません。

つまり、HTML/JavaScript を使用するタイミングと、ウェブ コンポーネントを使用するタイミングを判断する必要があるということですか?いいえ。HTML と JavaScript を使用して、インタラクティブなビジュアル コンテンツを作成できます。ウィジェットはインタラクティブな視覚的なものです。ウィジェットを開発する際に、HTML と JavaScript のスキルを活用するのは理にかなっています。Web Components 標準は、そのために設計されています。

ただし、HTML と JavaScript で作成されたウィジェットを使いにくい根本的な問題があります。ウィジェット内の DOM ツリーは、ページの他の部分からカプセル化されていません。カプセル化がないと、ドキュメント スタイルシートがウィジェット内の部分に誤って適用されたり、JavaScript がウィジェット内の部分を誤って変更したり、ID がウィジェット内の ID と重複したりする可能性があります。

ウェブ コンポーネントは次の 3 つの部分で構成されています。

  1. テンプレート
  2. Shadow DOM
  3. カスタム要素

Shadow DOM は、DOM ツリーのカプセル化の問題に対処します。ウェブ コンポーネントの 4 つの部分は連携するように設計されていますが、使用するウェブ コンポーネントの部分を選択することもできます。このチュートリアルでは、Shadow DOM の使用方法について説明します。

Hello, Shadow World

Shadow DOM を使用すると、要素に新しい種類のノードが関連付けられます。この新しい種類のノードはシャドールートと呼ばれます。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 ルートの下の 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 からアクセスできます。シャドールートを設定します。

<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」)はページ内にあるものの、レンダリングされる名前はシャドウルートにコピーされた名前です。名札の名前を変更するには、2 か所で変更する必要があり、同期がずれる可能性があります。

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: 利益

コンテンツと表示を分離することで、コンテンツを操作するコードを簡素化できます。名前タグの例では、コードは複数ではなく 1 つの <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>

これは、名前変更コードがシンプルで一貫性のあるコンポーネントの構造に依存できるため、現在のウェブの状況に比べて大幅な改善です。名前の更新コードは、レンダリングに使用される構造を認識する必要はありません。レンダリングされる内容を見ると、英語では名前が 2 番目に表示されます(「Hi! と申します」)ではなく、最初に日本語で(「と申します」の前に)表示されます。この区別は、表示される名前を更新する観点から意味的に無意味であるため、名前の更新コードでその詳細を認識する必要はありません。

追加演習: 高度な投影

上記の例では、<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"> 要素の両方によって照合されます。ボブのメールアドレスはいくつ表示され、どのような色で表示されますか?

答えは、ボブのメールアドレスが 1 回表示され、黄色になっていることです。

その理由は、Shadow DOM をハッキングしたことがある人ならご存じのとおり、実際に画面にレンダリングされるツリーを構築するのは大仕事だからです。content 要素は、ドキュメントのコンテンツをバックステージの 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 では、1 つのシャドーホストで複数のシャドーを使用したり、カプセル化のためにネストされたシャドーを使用したり、モデル駆動ビュー(MDV)と Shadow DOM を使用してページを設計したりできます。ウェブ コンポーネントは Shadow DOM だけではありません。

詳しくは、今後の投稿で説明します。