Shadow DOM 301

上級者向けコンセプトと DOM API

HTML5 Rocks

この記事では Shadow DOM で可能になる、さらに素晴らしいことについて述べていきます。Shadow DOM 101 および Shadow DOM 201 で述べた知識が前提となりますので、もしまだ読まれていない方はぜひそちらをご覧下さい。

Chrome であれば、about:flags ページにある "Enable experimental Web Platform features" フラグを有効にすることで、この記事に書かれているすべての機能を試すことができます。

複数の Shadow Root を使う

もしあなたがパーティを主催するとして、参加者を全員同じ部屋に押し込めてしまったら、とても窮屈ですよね? グループに分けて、複数の部屋に分散できたら、と思うことでしょう。Shadow DOM をホストする要素についても同様、要素はひとつ以上の Shadow Root をホストすることができます。

複数の Shadow Root をホストに追加するとどうなるのか見てみましょう:

<div id="example1">Light DOM</div>
<script>
var container = document.querySelector('#example1');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();
root1.innerHTML = '<div>Root 1 FTW</div>';
root2.innerHTML = '<div>Root 2 FTW</div>';
</script>
Attaching multiple shadow trees
Light DOM

ShadowRoot をインスペクト可能にするため、DevTools で "Show Shadow DOM" をオンにしてください。

すでに Shadow Tree が追加されているにも関わらず、"Root 2 FTW" と表示されましたか? これは最後にホストに追加された Shadow Tree が優先されるためです。少なくともレンダリングに関しては、LIFO (Last In First Out) スタックです。DevTools で調べてみればこの挙動が確認できるはずです。

ホストに追加された Shadow Tree は、追加された順に、最近追加されたものが最初になるように積み上げられ、最後に追加されたものが表示されます。

最も新しく追加された Tree は Younger Tree と呼ばれます。それより以前の Tree は Older Tree と呼ばれます。この例では root2 が Younger Tree で、 root1 が Older Tree ということになります。

それでは、複数の Shadow を使うことにどんな意義があるのでしょうか? Shadow 挿入ポイントを入れてみましょう。

Shadow 挿入ポイント

"Shadow 挿入ポイント" (<shadow>) は、通常の 挿入ポイント (<content>) とプレースホルダーという意味では同一ですが、 コンテンツ の代わりに Shadow Tree をホストします。まさに Shadow DOM インセプションというわけです!

ご想像の通り、うさぎの穴は掘れば掘るほど複雑になります。そのため、複数の <shadow> 要素を入れた場合の仕様は明確です:

Shadow Tree に複数の <shadow> 挿入ポイントを設けた場合、最初のものだけが認識され、他のものは無視される。

先ほどの例に戻ると、最初の Shadow である root1 のみがリストに残ります。<shadow> 挿入ポイントの追加は、それを取り戻します:

<div id="example2">Light DOM</div>
<script>
var container = document.querySelector('#example2');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();
root1.innerHTML = '<div>Root 1 FTW</div><content></content>';
root2.innerHTML = '<div>Root 2 FTW</div><shadow></shadow>';
</script>
Shadow insertion points
Light DOM

この例で、いくつか面白いことが分かります:

  1. "Root 2 FTW" は "Root 1 FTW" の上に表示されます。これは <shadow> 挿入ポイントを配置した場所だからです。逆にしたければ、挿入ポイントを root2.innerHTML = '<shadow></shadow><div>Root 2 FTW</div>'; 挿入ポイントに移動すればよいのです。
  2. 今度は root1 の位置に <content> があることに注意して下さい。これがテキストノード "Light DOM" が表示される理由です。

<shadow> に何が表示されるのか?

<shadow> の位置に Older Tree が表示されることを知っていると便利な場合があります。.olderShadowRoot を使ってその Tree を参照することができます:

root2.olderShadowRoot === root1 //true

ホストの Shadow Root を取得する

要素が Shadow DOM をホストしている場合、.shadowRoot を使って 最も若い Shadow Root (Younguest Shadow Root) にアクセスできます:

var root = host.createShadowRoot();
console.log(host.shadowRoot === root); // true
console.log(document.body.shadowRoot); // null

あなたの Shadow に第三者を入り込ませたくない場合は、.shadowRootnull にします:

Object.defineProperty(host, 'shadowRoot', {
  get: function() { return null; },
  set: function(value) { }
});

ちょっとしたハックですが、動きます。ただし、どんなに素晴らしくても、 Shadow DOM がセキュリティを重視したものではない ことは覚えておく必要があります。コンテンツ孤立化のソリューションという点で、完璧なものとして依存することは避けて下さい。

JavaScript で Shadow DOM を構築する

JavaScript で DOM を構築したければ、HTMLContentElementHTMLShadowElement が必要なインターフェースを提供しています。

<div id="example3">
  <span>Light DOM</span>
</div>
<script>
var container = document.querySelector('#example3');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();

var div = document.createElement('div');
div.textContent = 'Root 1 FTW';
root1.appendChild(div);

 // HTMLContentElement
var content = document.createElement('content');
content.select = 'span'; // selects any spans the host node contains
root1.appendChild(content);

var div = document.createElement('div');
div.textContent = 'Root 2 FTW';
root2.appendChild(div);

// HTMLShadowElement
var shadow = document.createElement('shadow');
root2.appendChild(shadow);
</script>

この例は、前の項 で示したものとほぼ同じですが、select を使って、新しく追加された <span> を引っ張っている点が異なります。

挿入ポイント

ホスト要素の中から選択され、Shadow Tree に "分散された" ノードは、分散ノードと呼ばれます。これらは、挿入ポイントが受け付ければ、Shadow Boundary を越えることが許されます。

挿入ポイントについて、コンセプト的につまづきやすいのは、物理的に DOM を動かさないという点です。ホストのノードは、実際には動きません。挿入ポイントは単なるホストから Shadow Tree にプロジェクションするためだけのものです。"ノードをここに移動しろ" "この位置にノードを表示しろ" という意思表示に他ならないのです。

DOM を走査して <content> 内に入り込むことはできません。

例えば:

<div><h2>Light DOM</h2></div>
<script>
var root = document.querySelector('div').createShadowRoot();
root.innerHTML = '<content select="h2"></content>';

var h2 = document.querySelector('h2');
console.log(root.querySelector('content[select="h2"] h2')); // null;
console.log(root.querySelector('content').contains(h2)); // false
</script>

この例で分かるように、h2 は Shadow DOM の子要素でありません。このことから、次のことも分かります:

挿入ポイントは信じられないほどパワフルです。Shadow DOM における "宣言的 API" を作る手段と考えて下さい。ホスト要素は、この世に存在するすべてのマークアップを取り込むことができますが、挿入ポイントを使って Shadow DOM を持ち込まない限り、意味はありません。

Element.getDistributedNodes()

<content> 内まで走査することはできないと述べましたが、.getDistributedNodes() API を使って、挿入ポイントにある分散ノードをクエリーすることができます:

<div id="example4">
  <h2>Eric</h2>
  <h2>Bidelman</h2>
  <div>Digital Jedi</div>
  <h4>footer text</h4>
</div>

<template id="sdom">
  <header>
    <content select="h2"></content>
  </header>
  <section>
    <content select="div"></content>
  </section>
  <footer>
    <content select="h4:first-of-type"></content>
  </footer>
</template>

<script>
var container = document.querySelector('#example4');

var root = container.createShadowRoot();

var t = document.querySelector('#sdom');
var clone = document.importNode(t.content, true);
root.appendChild(clone);

var html = [];
[].forEach.call(root.querySelectorAll('content'), function(el) {
  html.push(el.outerHTML + ': ');
  var nodes = el.getDistributedNodes();
  [].forEach.call(nodes, function(node) {
    html.push(node.outerHTML);
  });
  html.push('\n');
});
</script>

Element.getDestinationInsertionPoints()

.getDistributedNodes() 同様、.getDestinationInsertionPoints() を呼び出すことで、ノードが分散されている挿入ポイントを確認することができます:

<div id="host">
  <h2>Light DOM</h2>
</div>

<script>
  var container = document.querySelector('div');

  var root1 = container.createShadowRoot();
  var root2 = container.createShadowRoot();
  root1.innerHTML = '<content select="h2"></content>';
  root2.innerHTML = '<shadow></shadow>';

  var h2 = document.querySelector('#host h2');
  var insertionPoints = h2.getDestinationInsertionPoints();
  [].forEach.call(insertionPoints, function(contentEl) {
    console.log(contentEl);
  });
</script>

ツール:Shadow DOM Visualizer

Shadow DOM の黒魔術を理解するのは至難の業です。私自身、初めて見た時は混乱しました。

Shadow DOM のレンダリングがどのように行われているのかをビジュアライズするのに、d3.js を使ってツールを作りました。左側にあるマークアップの枠は、どちらも編集可能です。ご自身のマークアップをペーストして、Shadow Tree がどのようにホストノードを挿入ポイントに埋めていくのか、ぜひ試してみてください。

Shadow DOM Visualizer
Shadow DOM Visualizer を起動

ぜひ試して、感想を聞かせて下さい!

イベントモデル

Shadow DOM では、イベントによっては Shadow Boundary を越えますが、越えないものもあります。イベントが Boundary を越える場合、Shadow Root の Upper Boundary のカプセル化を維持するため、イベントターゲットは調整されます。別の言い方をするなら、 イベントは、Shadow DOM の内部の要素ではなく、ホスト要素から発生しているように見せるため、リターゲティングされます

event.path を確認すれば、調整されたイベントパスが見れます

あなたのブラウザが Shadow DOM をサポートしていれば、下にイベントのビジュアライゼーションが確認できるデモが表示されるはずです。黄色い 要素は Shadow DOM のマークアップの一部です。青い 要素は ホスト要素の一部です。"ホスト内の要素" を囲む 黄色い 枠線は、Shadow の <content> 挿入ポイントを通した分散ノードであることを示しています。

複数用意されている "Play Action" ボタンを押すことで、mouseoutfocusin イベントがどのようにメインページにバブルアップされるかを見ることができます。

ホスト内の要素



Play Action 1

  • ホスト要素 (<div data-host>) から 青い ノードに mouseout していくのが見て取れるはずです。分散ノードであるにも関わらず、Shadow DOM 内ではなく、ホスト内にあります。マウスをさらに下に動かし、黄色い ノードに移動することで、再度 青い ノードから mouseout します。

Play Action 2

  • ホストで、最後に一度だけ mouseout イベントが発生します。通常 mouseout はすべての 黄色い ノードで発生すると思われるかもしれませんが、Shadow DOM の内部要素であるため、イベントは Upper Boundary を越えてバブリングしません。

Play Action 3

  • input をクリックすると、focusin イベントが、input 要素ではなく、ホストノード上に現れることに注目して下さい。これがリターゲティングです。

常に停止されるイベント

下記は Shadow Boundary を越えないイベントです:

  • abort
  • error
  • select
  • change
  • load
  • reset
  • resize
  • scroll
  • selectstart

まとめ

Shadow DOM が信じられないほどパワフル の意味が分かったでしょうか? 我々はここに来て初めて、<iframe> やその他の古いテクニックを使わずに、まっとうなカプセル化の技術を手に入れたのです。

Shadow DOM は確かに複雑怪奇ではありますが、ウェブプラットフォームに加えるだけの価値があるものです。ゆっくり時間をかけて身につけて下さい。

さらに詳しく知りたい場合は、Dominic の Shadow DOM 101 や、私の Shadow DOM 201: CSS & Styling が参考になります。

このチュートリアルの内容をレビューしてくれた Dominic CooneyDimitri Glazkov に感謝します。

Comments

0