Subterranean Flower

Shadow DOM v1でHTMLの内容と構造を分離する

Author
古都こと
ふよんとえんよぷよぐやま!!!

近年ではウェブに対する要求が肥大化しており、それに対応してHTMLも複雑化してきています。しかし、もともとHTMLはウェブアプリを記述するための言語ではありません。大規模なウェブアプリを作成するとなると、様々な点で不都合が出てきてしまいます。特に、まとまった部品をコンポーネント化して扱う機能に欠けていました。このことが私たちを悩ませ、今まで多くの苦労を引き起こしていました。

でも、もう悩まなくてもよくなるかもしれません。HTMLをコンポーネント化する規格が生まれました。それがWeb Componentsです。

Web ComponentsとShadow DOM

Web Componentsは次の4つの部分からなる規格です。

  • Templates
  • Shadow DOM
  • Custom Elements
  • HTML Imports

今回は、このうちのShadow DOMに焦点を当てていきたいと思います。

コンテンツと構造を分離するShadow DOM

例えば以下のようなTwitterプロフィールカードを作りたいとします。

twitter-card

このときのHTMLは、以下のようになるでしょう。

<div class="twitter-card">
    <div class="twitter-icon">
        <img alt="Twitter Icon" src="icon.png">
    </div>
    <div class="twitter-profile">
        <div class="twitter-info">
            <div class="twitter-name">古都こと</div>
            <div class="twitter-id">@kfurumiya</div>
        </div>
        <a href="https://twitter.com/kfurumiya"><button class="follow-button">Twitterでフォローする</button></a>
    </div>
</div>

今までの常識で考えれば、これでおおむね問題ないように見えます。適切に構造化され、HTMLとしての体をなしています。

でも、これで満足でしょうか。複雑すぎはしませんか?読みにくくないですか?書きにくくないですか?自分に正直になってください!本当はもっと簡単に、以下のように書きたいはずです。

<div class="twitter-card">
    <img slot="icon" alt="Twitter Icon" src="icon.png">
    <span slot="name">古都こと</span>
    <span slot="id">kfurumiya</span>
</div>

これは簡単に書け、読みやすく、なおかつ再利用性も高そうです。

上の複雑な方の例では、デザインに必要な複雑な構造と、本質となるコンテンツとがごちゃまぜに書かれていました。しかし下の簡単な方の例では、デザイン上の構造を排除したコンテンツのみを記述しているため、たいへん扱いやすくなっています。

こんな書き方ができれば良いのにと何度夢見たことでしょう。ですが、もうただの夢ではありません。Web Componentsによって、これを現実にすることができます。Shadow DOMを使えば、このHTMLを実際に動かすことができるのです。

Shadow DOMの使い方

要素にShadow DOMを追加して表示する

Shadow DOMは、HTML要素に、隠されたDOMを追加する機能です。Shadow DOMを使うことで、見かけ上のDOMの裏に、別のDOMを隠すことができます。

言葉だけではよくわからないので、簡単な例を見てみましょう。

<div class="host">Hello, world!</div>

これは「Hello, world!」と表示するだけの簡単なHTMLです。実行するともちろん「Hello, world!」と表示されます。

次に、このdiv要素にShadow DOMを加えてみます。

const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'open'});

root.textContent = 'Hello, shadow world!';

attachShadowはHTML要素にShadow DOMを追加するメソッドです。ここで、Shadow DOMが追加される側(ここではdiv要素)のことをホスト(Host)、追加したShadow DOMのルートのことをシャドウルート(Shadow Root)と言います。

そしてこのコードでは、div要素に追加したShadow Rootの内容を「Hello, shadow world!」に書き換えています。すると一体何が起こるのでしょうか。これを実行してみます。

hello-shadow-world

なんと、元のdiv要素の内容を一切無視して、Shadow Rootに書き込んだ内容が表示されました。しかし、ブラウザのインスペクタなどで見てみると、div要素の内容は「Hello, world!」のままです。

shadow-inspector

これは面白い動作です。表から見える要素はそのままに、実際に表示されるのは裏に隠れたShadow DOMになります。Shadow DOMを使うことで、表の要素を綺麗に保ったまま、裏の要素に複雑な要素を埋め込むことができるのです。

Shadow DOMに表の要素の内容を埋め込むslot要素

Shadow DOMを使うことで、表の要素の内容を無視して、Shadow DOMの内容を表示することができるというのがわかりました。

しかしこれだけでは全く利点がわかりません。表の要素の内容が全く見えなくなってしまうので、結局裏側に要素を隠しただけで、実質的に今までと何も変わっていないからです。

そこで、Shadow DOMでは表の要素から内容を取ってきて表示する、slot要素というものが用意されています。slot要素を利用することで、表の要素と裏の要素のハイブリッドが作成できます。

どういうことか、簡単な例を見てみましょう。

<div class="host">
  <span slot="user-name">古都こと</span>
</div>

まず表のホストに、Shadow DOM側に取り込みたい要素を追加します。そして追加した要素のslot属性に適当な名前をつけます。

次にslot要素を作成し、Shadow Rootに追加します。

// Shadow Rootを作成する
const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'open'});

// slot要素を作成する
const slot = document.createElement('slot');
slot.name = 'user-name';

// slot要素をShadow Rootに追加する
root.textContent = 'My name is ';
root.appendChild(slot);

このときslot要素のname属性を、先ほど追加した要素のslot属性と同じにします。

そしてこれを実行してみます。すると……

my-name-is

表の要素とShadow DOMの内容の両方が表示されました!Shadow DOMに追加したslot要素が、表の要素に置き換えられたのです。このようにしてslot要素を用いることで、表の要素の内容をShadow DOMの任意の場所に埋め込むことができます。

Shadow DOMにスタイルを適用する

Shadow DOMは外部からは隔離されています。そのため、Shadow DOM内部の要素をセレクタで指定することは不可能です。

どういうことでしょうか。簡単に言うと、次の例は上手く動かないということです。

<style>
 .shadow-span { color: red; }
</style>

<div class="host"></div>
// Shadow Rootを作成する
const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'open'});

const span = document.createElement('span');
span.classList.add('shadow-span');
span.textContent = 'Hello, shadow world!';

// Shadow Rootに追加する
root.appendChild(span);

この例では、表側からShadow DOM内のスタイルを書き換えて赤く表示しようとしています。これを表示してみます。

hello-shadow-world

赤くなりません。Shadow DOMの外側から、内部のスタイルを直接指定することは不可能なのです。Shadow DOM内部の要素にスタイルを適用する場合は、以下のようにstyle要素をShadow DOM内に書き込みます。

// Shadow Rootを作成する
const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'open'});

// span要素を作成する
const span = document.createElement('span');
span.classList.add('shadow-span');
span.textContent = 'Hello, shadow world!';

// style要素を作成する
const style = document.createElement('style');
style.textContent = '.shadow-span { color: red; }';

// Shadow Rootに追加する
root.appendChild(style);
root.appendChild(span);

これでスタイルが反映されます。

shadow-style

なおShadow DOM内のlink要素は無視されます。必ずstyle要素を使用してください。

Shadow DOM内部からホストのスタイルを指定する

外部からShadow DOMのスタイルを指定することはできませんが、逆にShadow DOMからホストのスタイルを指定することは可能です。

Shadow DOM内部で:host擬似クラスを使用することで、ホストのスタイルを指定できます。

<style>
  :host {
    border: 1px solid black;
  }
</style>

また、:host(selector)を使用することで特定のホストのみにスタイルを適用できます。

<style>
  :host(.card) {
    border: 1px solid black;
  }
</style>

slot要素と置き換えられた要素にスタイルを適用する

Shadow DOM内のslot要素は、実際には他の要素に置き換えられます。slot要素にスタイルを適用しても、置き換え後の要素にはスタイルが適用されません。

この置き換えられた要素にスタイルを適用するには、::slotted(selector)擬似要素を使用します。

<style>
  ::slotted(.user-name) {
    color: red;
  }
</style>

Shadow DOMを利用してTwitterプロフィールカードを作る

Shadow DOMを利用することで、表の要素とは別の、裏の要素を作って表示できることがわかりました。また、Shadow DOM内に表の要素から取り込んだ要素も表示できることもわかりました。次はこれを実際に活用してみましょう。

最初に挙げたTwitterプロフィールカードの例を、Shadow DOMを使って実装してみましょう!目標となるHTMLは以下でした。

<div class="twitter-card">
    <img slot="icon" alt="Twitter Icon" src="icon.png">
    <span slot="name">古都こと</span>
    <span slot="id">kfurumiya</span>
</div>

この要素にShadow Rootを作り、要素を追加していくだけでいいのですが、全てJavaScriptで書くと、物量が多くて面倒です。template要素を使ってShadow DOMの中身を書いて、JavaScriptからコピーする手法を取りましょう。

template要素を知らない方のために簡単に説明しておくと、template要素の中身は画面上に表示されません。なのでコピーしてテンプレートとして使う方法がやりやすくなります。

つまりHTMLはこうなります。

<template id="twitter-card-template">
    <div class="twitter-icon">
        <slot name="icon"></slot>
    </div>
    <div class="twitter-profile">
        <div class="twitter-info">
            <div><slot name="name"></slot></div>
            <div>@<slot name="id"></slot></div>
        </div>
        <a class="twitter-link"><button class="follow-button">Twitterでフォローする</button></a>
    </div>
</template>
        
<div class="twitter-card">
    <img slot="icon" alt="Twitter Icon" src="icon.png">
    <span slot="name">古都こと</span>
    <span slot="id">kfurumiya</span>
</div>

template要素内に、Shadow Rootに追加するための要素を書きました。アイコン、名前、IDの部分をslot要素にしており、表の要素と置き換えられるようにしています。

また、Shadow DOMの外部からはスタイルを適用できないため、ここにstyle要素を追加します。すると以下のようになります。

<template id="twitter-card-template">
    <style>
        :host(.twitter-card) {
            display: flex;
            border: 1px solid rgb(200, 200, 200);
            color: rgb(80, 80, 80);
        }

        .twitter-icon {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 10em;
            height: 10em;
            padding: 1em;
        }

        .twitter-icon ::slotted(img) {
            width: 100%;
            height: 100%;
            border-radius: 100%;
            border: 3px solid rgb(200, 200, 200);
        }

        .twitter-profile {
            flex: 1;
            display: flex;
            flex-direction: column;
            padding: 1em;
        }

        .twitter-info {
            flex: 1;
        }

        .twitter-name::slotted(*) {
            font-size: 2em;
        }

        .follow-button {
            background-color: rgb(85, 172, 238);
            border: none;
            color: white;
            font-size: 1em;
            padding: 0.5em 1em;
            width: 100%;
        }
    </style>
    <div class="twitter-icon">
        <slot name="icon"></slot>
    </div>
    <div class="twitter-profile">
        <div class="twitter-info">
            <div><slot name="name"></slot></div>
            <div>@<slot name="id"></slot></div>
        </div>
        <a class="twitter-link"><button class="follow-button">Twitterでフォローする</button></a>
    </div>
</template>
        
<div class="twitter-card">
    <img slot="icon" alt="Twitter Icon" src="icon.png">
    <span slot="name">古都こと</span>
    <span slot="id">kfurumiya</span>
</div>

このテンプレートを、div要素のShadow Rootに追加します。

// Shadow Rootを作成する
const host = document.querySelector('.twitter-card');
const root = host.attachShadow({mode: 'open'});

// テンプレートからカードを作成する
const template = document.querySelector('#twitter-card-template');
const card = document.importNode(template.content, true);

// カードをShadow Rootへ追加する
root.appendChild(card);

// a要素のhrefを指定する
const a = root.querySelector('.twitter-link');
const idSlot = root.querySelector('.twitter-id');
const id = idSlot.assignedNodes()[0];
a.href = `https://twitter.com/${id.textContent}`;

これで完成です。実際に動かしてみましょう。以下のように表示されます。

twitter-card

思い通りに表示されました!複雑な構造は裏のShadow DOMに閉じ込め、表にはコンテンツだけを簡潔に記述した要素のみを残すことができました。Shadow DOMを利用することで、構造とコンテンツを分離して記述することができるのです。

その他細かいこと

Shadow Rootのopenとclosed

気になった方も多いと思いますが、Shadow DOMにはopenモードclosedモードがあります。違いは、後からShadow Rootにアクセスできるか否かです。

const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'open'});

console.log(host.shadowRoot); // okay
const host = document.querySelector('.host');
const root = host.attachShadow({mode: 'closed'});

console.log(host.shadowRoot); // null

closedモードを使うことで、外部からは変更できない、より強力なShadow DOMを作ることができます。

v0とv1

この記事で解説しているShadow DOMはShadow DOM v1で、過去のブラウザに実装されていたものはShadow DOM v0です。v0とv1では、各APIや挙動などが、多くの部分で大きく異なります。v0のAPIもしばらくは使えるとは思いますが、できれば新しいv1のほうで書きましょう。