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

これで Shadow ルートが設定され、name タグが再びレンダリングされます。名前タグを右クリックして要素を検証すると、セマンティックなマークアップであることがわかります。

<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 セレクタを使用して特定のコンテンツを選択する Shadow ルートは次のとおりです。

<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 だけではありません。

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