LoginSignup
7
5

ShadowDOMをなんとなくお勉強するための走り書き(+ちょっとCustomElement)

Last updated at Posted at 2019-10-25

これはShadow DOMとは何かを、Custom Elementsのさわりまでを含めて説明するときに書いたメモ書きをまとめたものです。
Shadow DOMはウィジェットコンポーネントや、ブックマークレットツール作るときとかに便利だったりします。

第1章 要素を作ってページに追加する

まずはShadow DOMを使わない例です。
1つのp要素を持ったdiv要素を、ページの最後に追加してみましょう。

const { body } = document; // documentオブジェクトに生えているbody要素を変数化する
const div = document.createElement('div'); // div要素を作る

div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>'); // div要素に新しくHTMLを書き込む
body.append(div); // body要素の最後にdiv要素を追加する

第2章 Shadow rootを作る

Shadow DOM では、「Shadow host」と「Shadow root」というキーワードが出てきます。

div要素を Shadow host に変換

Shadow host とは、Shadow root を持つ要素のことです。
さっそくattachShadow()メソッドを利用して、追加するdiv要素を Shadow root に変換してみましょう。

const { body } = document;
const div = document.createElement('div');

div.attachShadow({ mode: 'open' }); // div要素が Shadow host に変換される

div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');

body.append(div);

Shadow host に変換すると、div要素の中にあったp要素が見えなくなります。
とはいえ、p要素が削除されたわけではないことが開発者ツールで確認できます。

Shadow root を取得

Shadow root は、Shadow DOM を扱う領域です。

通常のDOMツリーと異なり、Shadow root の中にあるDOMツリーを Shadow tree と言います。
Shadow root はattachShadow()の返り値、または Shadow host のshadowRootプロパティから参照できます。

const { body } = document;
const div = document.createElement('div');

const shadowRoot = div.attachShadow({ mode: 'open' }); // 返り値を取得

div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');

body.append(div);


// attachShadow() の返り値で取得したもの
console.log(shadowRoot); // > #shadow-root (open)
// shadowRoot プロパティからの参照
console.log(div.shadowRoot); // > #shadow-root (open)
// どっちも同じもの
console.log(shadowRoot === div.shadowRoot); // > true

第3章 Shadow root を closed モードで作る

Closed Shadow DOMは、あとからshadowRootを参照することができなくするための方法です。

attachShadow()に渡すオプションで、modeプロパティに文字列'closed'を渡します。
この方法で生成された Shadow root は、最初のattachShadow()の返り値以外の手段では取得できなくなります1

const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'closed' });

div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');

body.append(div);


// attachShadow() の返り値で取得したもの
console.log(shadowRoot); // > #shadow-root (closed)
// shadowRoot プロパティからの参照
console.log(div.shadowRoot); // > null
// 不一致
console.log(shadowRoot === div.shadowRoot); // > false

第4章 【slot編】Shadow root の中にコンテンツを表示してみる

Shadow host になっている要素は自信のコンテンツの中身の代わりに、Shadow root の中身を表示します。
slot要素を使うことで、Shadow hostdiv要素)の中身を Shadow root の中(Shadow DOM)に出力できます。

const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' });

const slot = document.createElement('slot'); // slot要素を作る

div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');

shadowRoot.append(slot); // Shadow root に追加する

body.append(div);

slot要素を作成し、Shadow root に追加すると、画面に「Hello Shadow DOM!」というコンテンツを持ったp要素が表示されるようになります。

開発者ツールを使うと、Shadow root の中にp要素が疑似的にコピーされて表示されているのが確認できます。

第5章 【slot編】Shadow rootの中で、狙ったコンテンツを狙って位置に表示してみる

ただslot要素を埋め込むだけだと、Shadow host の中身が区別なくすべてコピーされていました。
特定の個所に、特定のコンテンツを出力したいような場合には、slot要素とslot属性を利用します。

slot要素とslot属性はそれぞれ役割が異なる点に注意が必要です。

name属性を持つslot要素は、「同じ値をslot属性に持つ要素を表示する」という機能を持つ要素です。

次のコードを実行してみると、slot属性を持っていない要素は表示されません。
一方でslot属性を持っている要素は、name属性が対応しているslot要素の中身にコピーされています。

const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'closed' });

// slot 要素を作り name属性に name を指定
const slotForName = document.createElement('slot');
slotForName.name = 'name';

// slot 要素を作り name属性に age を指定
const slotForAge = document.createElement('slot');
slotForAge.name = 'age';

// slot 属性にそれぞれ対応させたい slot 要素の name 属性値を指定する
div.insertAdjacentHTML('afterbegin', `
  <p>slot属性を持たないテキスト</p>
  <p slot="name">山田太郎</p>
  <p slot="age">17歳</p>
`);

// slot 要素を Shadow root に追加
shadowRoot.append(slotForName);
shadowRoot.append(slotForAge);

body.append(div);

【イメージ】Shadow host化したdiv要素と Shadow root の中身

実際はshadow-root要素のようなものは存在しませんが、イメージするならこういう構造になります。
本来のDOMツリー上には存在しない、影のような存在であるところが Shadow DOM の特徴です。

<div>
  <shadow-root>
    <slot name="name"></slot>
    <slot name="age"></slot>
  </shadow-root>
  <p>slot属性を持たないテキスト</p>
  <p slot="name">山田太郎</p>
  <p slot="age">17歳</p>
</div>

【イメージ】表示上はこんな感じ

slot要素の中に、name属性値と対応するslot属性値を持つ要素が埋め込まれます。

<div>
  <!-- #shadow-root -->
    <slot name="name"><p slot="name">山田太郎</p></slot>
    <slot name="age"><p slot="age">17歳</p></slot>
  <!-- /#shadow-root -->

  <!-- <p>slot属性を持たないテキスト</p> -->
  <!-- <p slot="name">山田太郎</p>   -->
  <!-- <p slot="age">17歳</p>      -->
</div>

第6章 Shadow rootの中にコンテンツを表示してみる(template編)

Shadow DOMの良さがグッとわかるタイミングは、template要素+slot要素やCustom Elementと組み合わせたときだと思います。
まずは、template要素とslot要素を活用する方法を試してみましょう。

記事一覧、製品一覧など、同じ構成のものを複数動的出力(JavaScriptで読み込み、出力)する作るとき特に便利です。

  1. template#template要素の中に出力するときの構造のみのHTMLを記述する
  2. 表示させたい「情報」だけを実際に使う出力したい情報をdiv#card要素に記述する
  3. div#card要素にShadow rootを作る(Shadow host化)(A)
  4. tempalte#tempate要素の中身をcloseNode(true)で複製し、Shadow rootに追加する(B)

以上のステップを踏むと、div#card要素の中に、「情報」を持ったtable要素が表示されます。

<template id="template">
  <table>
    <tbody>
      <tr>
        <th>ID</th>
        <td><slot name="user-id"></slot></td>
      </tr>

      <tr>
        <th>名前</th>
        <td><slot name="user-name"></slot></td>
      </tr>

      <tr>
        <th>本文</th>
        <td><slot name="user-description"></slot></td>
      </tr>

      <tr>
        <th>投稿日</th>
        <td><slot name="user-date"></slot></td>
      </tr>
    </tbody>
  </table>
</template>

<div id="card">
  <span slot="user-id">@kawarabeEcma</span>
  <span slot="user-name">河童エクマ</span>
  <span slot="user-description">人間の姿にがんばって化けている働く河童。</span>
  <span slot="user-date">1月1日</span>
</div>

<script>
const template = document.querySelector('#template');
const div = document.querySelector('#card');
// (A)
const shadow = div.attachShadow({
  mode: 'closed',
});

// (B)
shadow.append(template.content.cloneNode(true));
</script>

第7章 複数のShadow rootを作ってみる

6章では1つのtemplate要素と、1つのShadow host(「情報」だけが書かれたマークアップ)の組み合わせを行いました。

ここでは1つのtemplate要素と、複数のShadow hostを組み合わせてみます。
class属性とfor...ofを使って、ループ文で処理してみた例がこちら!

<template id="template">
  <table>
    <tbody>
      <tr>
        <th>ID</th>
        <td><slot name="user-id"></slot></td>
      </tr>

      <tr>
        <th>名前</th>
        <td><slot name="user-name"></slot></td>
      </tr>

      <tr>
        <th>本文</th>
        <td><slot name="user-description"></slot></td>
      </tr>

      <tr>
        <th>投稿日</th>
        <td><slot name="user-date"></slot></td>
      </tr>
    </tbody>
  </table>
</template>


<div class="card">
  <span slot="user-id">@kawarabeEcma</span>
  <span slot="user-name">河童エクマ</span>
  <span slot="user-description">こんエクマ~!</span>
  <span slot="user-date">1月1日</span>
</div>


<div class="card">
  <span slot="user-id">@hogehoge</span>
  <span slot="user-name">田中太郎</span>
  <span slot="user-description">おはようおはよう</span>
  <span slot="user-date">1月2日</span>
</div>


<div class="card">
  <span slot="user-id">@piyopiyo</span>
  <span slot="user-name">山田太郎</span>
  <span slot="user-description">おはこんばんにちは</span>
  <span slot="user-date">1月3日</span>
</div>


<script>
const template = document.querySelector('#template');
const cards = document.querySelectorAll('.card');

for (const div of cards) {
  const shadow = div.attachShadow({
    mode: 'closed',
  });

  shadow.append(template.content.cloneNode(true));
}
</script>

第8章 オリジナルの要素を使ってみる(Custom Elements編)

続いて、自分だけの新しい要素が作れるCustom Elementを試してみしょう。
先ほどはclass属性を使って自主的にDOM探索をしてShadow rootを作っていましたが、Custom Elementを使うことでDOM探索をする必要もなくなります。

※ オリジナルの要素名は、必ずハイフン(-)を1つ含める必要があります。

<hoge-piyo>
  <span slot="user-id">@kawarabeEcma</span>
  <span slot="user-name">河童エクマ</span>
  <span slot="user-description">こんエクマ~!</span>
  <span slot="user-date">1月1日</span>
</hoge-piyo>


<hoge-piyo>
  <span slot="user-id">@hogehoge</span>
  <span slot="user-name">田中太郎</span>
  <span slot="user-description">おはようおはよう</span>
  <span slot="user-date">1月2日</span>
</hoge-piyo>


<hoge-piyo>
  <span slot="user-id">@piyopiyo</span>
  <span slot="user-name">山田太郎</span>
  <span slot="user-description">おはこんばんにちは</span>
  <span slot="user-date">1月3日</span>
</hoge-piyo>


<template id="template">
  <table>
    <tbody>
      <tr>
        <th>ID</th>
        <td><slot name="user-id"></slot></td>
      </tr>

      <tr>
        <th>名前</th>
        <td><slot name="user-name"></slot></td>
      </tr>

      <tr>
        <th>本文</th>
        <td><slot name="user-description"></slot></td>
      </tr>

      <tr>
        <th>投稿日</th>
        <td><slot name="user-date"></slot></td>
      </tr>
    </tbody>
  </table>
</template>


<script>
/** hoge-piyo要素の振る舞いを定義するclassです */
class HogePiyoHTMLElement extends HTMLElement {
  /** 要素が生まれたときに何をするかを書くところ */
  constructor() {
    super(); // おまじない。詳しくはclassの継承。

    const template = document.querySelector('#template');
    const shadow = this.attachShadow({
      mode: 'closed',
    });

    shadow.append(template.content.cloneNode(true));
  }
}

// 作ったオリジナルの要素をブラウザに覚えてもらう
customElements.define('hoge-piyo', HogePiyoHTMLElement);
</script>

classを使ってhoge-piyo要素が生み出されたとき、自動的にtemplate#template要素を使って画面に出力する、という動きが定義されました。
これは元々HTMLに書かれていたhoge-piyo要素のみならず、JavaScriptで作ったものにも、しっかり自動的に反応してくれます。

const newHogePiyo = document.createElement('hoge-piyo');

newHogePiyo.insertAdjacentHTML('afterbegin',  = `
  <span slot="user-id">@ponyoponyo</span>
  <span slot="user-name">佐藤太郎</span>
  <span slot="user-description">あたらしいやつ</span>
  <span slot="user-date">1月4日</span>
`;
document.body.append(newHogePiyo); // ちゃんとテンプレにはまって表示される

第9章 Shadow DOMの中身をスタイリングしたい

通常のCSSはShadow rootの中に影響を及ぼさず、Shadow rootの中にあるCSSはその外に影響しません。
これはShadow DOMの最も大きな特徴だと思います。
これまでiframe要素で実現してきた埋め込み系ウィジェットや、ブックマークレットなどで操作パネルなどを画面上に表示させたい場合に、既存スタイルに影響されず安心してスタイリングができます。

Shadow rootの中をスタイリングするには、Shadow rootの中でstyle要素を埋め込むか、link要素で外部CSSを読み込むしかありません。
Shadow rootの中のlink要素はレンダリングをブロックしないため、外部CSSを読み込もうとするとFOUC現象が発生する可能性があります。同様のtextContentを持つstyle要素が複数個所にいた場合でも、ブラウザがうまく最適化してくれるので、どちらの実装方法でもパフォーマンスは同様です2

なお、Shadow hostになっている要素をShadow rootの中からスタイリングしたい場合は、:hostというセレクタが利用できます。

:host {
  background: red;
}

💡 2023年現在はstyle要素を設置せずに、ShadowDOM自体に適応できます

const sheet = new CSSStyleSheet();
const shadow = node.attachShadow({ mode: "closed" });

sheet.replaceSync(`
  :host {
    background: red;
  }
`);

shadow.adoptedStyleSheets = [sheet];

See also: Document: adoptedStyleSheets property - Web APIs | MDN

これでShadow hostになっている要素の背景が赤くなります。

ただし、すでに通常のCSSでShadow host化したにスタイルが当たっている場合はそちらのほうが優先度が高くなることに注意が必要です。

第4章のコードでスタイリングしてみよう。

次の例では、うまく文字の色が赤くなりません。
少し難しい部分ですが、これは実際にp要素がShadow rootの中にいるわけではないためです。

あくまでもこの例で登場するstyle要素は、Shadow rootの中にいるものです。そのため、「Shadow hostそのもの」と、そのShadow rootの中身しか装飾できません。

<div>
  <!-- #shadow-root -->
    <slot><p>こんえくま!🥒</p></slot>   <!-- このp要素はここにいるように見えるけれど、実際にはいないのでCSSが当たらない -->
    <style>
      /* shadow-rootの中しかCSSを当てられず、`slot`要素の中身にはCSSが当たらない */

      :host {
        /* ルートの`div`要素を装飾できる */
      }
    </style>
  <!-- /#shadow-root -->

  <!-- <p>こんえくま!🥒</p> -->
</div>

template要素との組み合わせのタイミングでstyle要素を作成するか、template要素の中にstyle要素ごと記述することで、一緒にShadow rootの中に設置していく必要があります。
それを考慮したうえでtemplate要素の構造を考えてみましょう。

// 第4章の例
const div = document.createElement('div');
const slot = document.createElement('slot');
const style = document.createElement('style'); // style要素を作成
const shadow = div.attachShadow({
  mode: 'closed',
});

div.insertAdjacentHTML('afterbegin', '<p>こんえくま!🥒</p>');
// CSSを記述
style.textContent = `
  :host {
    background: pink;
  }
  slot {
    color: blue; /* 当たる */
  }
  p  {
    color: red; /* うまく当たらない */
  }
`;
shadow.append(slot);
shadow.append(style); // Shadow rootに追加
document.body.append(div);
// Custom Elementの例
class HogePiyoHTMLElement extends HTMLElement {
  cssText = `
    :host {
      display: block;
      background: pink;
    }
    slot {
      color: blue; /* 当たる */
    }
    span  {
      color: red; /* うまく当たらない */
    }
  `

  constructor() {
    super();

    const template = document.querySelector('#template');
    const style = document.createElement('style'); // style要素を作成
    const shadow = this.attachShadow({
      mode: 'closed',
    });

    // CSSを記述
    style.textContent = this.cssText;
    shadow.append(template.content.cloneNode(true));
    shadow.append(style); // Shadow rootに追加
  }
}

// 作ったオリジナルの要素をブラウザに覚えてもらう
customElements.define('hoge-piyo', HogePiyoHTMLElement);

おまけ:Custom Elementの良さ

独自の命名規則で属性を定義できたり、アクセサを使うことでIDL属性のように実装することができるところも面白いですが、1番の魅力は1つの要素だけで1つのUIとして完結させられるところではないでしょうか。

たとえば、input要素やtextarea要素はHTMLに記述するだけで、文字が書けてサイズも変更できるものが生まれます。
video[controls]要素も置いただけで操作可能なプレイヤーがすぐに生成されますが、これらのような独自の機能をもったものはShadow DOMが使われています。

Google Chromeの開発者ツールでは、次の手順でその実装の構造(Shadow hostの中身)を確認できるようになります。
実際にinput要素などを確認してみてください。

  1. 開発者ツールを開く(F12、Command+Option+l)
  2. 「Settings」を開く(歯車のマーク)
  3. 「Preferences」の「Elements」の「Show user agent shadow DOM」にチェックを入れる

第8章などのhoge-piyo要素の例では、Custom Elementが生成されたら決められたテンプレートに従って表示してくれるようにcustomElement.define()でブラウザと約束しましたが、これらの要素もそれと同じ理論で「あらかじめブラウザがそういう風に機能を提供するようになっている」というイメージです。

こうすると要素が生まれたタイミングでattachShadowをすることやtemplateの中身をShadow rootに埋め込んだりすることをブラウザが自動的にやってくれるので、ループ文も必要なくなります。

第10章 最後に

ここまで書いてきた内容の全部入りのコードを用意しました。

これをいじくりまわしてShadow DOMで遊んでみましょう!🥒

<style>
#list {
  display: flex;
  flex-wrap: wrap;
}

hoge-piyo {
  flex: 1;
  margin: 10px;
}
</style>

<div id="list">
  <hoge-piyo>
    <span slot="user-id"><a href="https://twitter.com/kawarabeEcma">@kawarabeEcma</a></span>
    <span slot="user-name">河童エクマ</span>
    <span slot="user-description">こんエクマ~!</span>
    <span slot="user-date">1月1日</span>
  </hoge-piyo>

  <hoge-piyo>
    <span slot="user-id">@hogehoge</span>
    <span slot="user-name">田中太郎</span>
    <span slot="user-description">おはようおはよう</span>
    <span slot="user-date">1月2日</span>
  </hoge-piyo>

  <hoge-piyo>
    <span slot="user-id">@piyopiyo</span>
    <span slot="user-name">山田太郎</span>
    <span slot="user-description">おはこんばんにちは</span>
    <span slot="user-date">1月3日</span>
  </hoge-piyo>
</div>

<template id="template">
<style>
  :host {
    background: cyan;
  }

  table {
    border-spacing: 4px;
  }

  th,
  td {
    padding: 10px;
  }

  th {
    white-space: nowrap;
    background: #efefef;
  }
</style>

  <table>
    <tbody>
      <tr>
        <th>ID</th>
        <td><slot name="user-id"></slot></td>
      </tr>

      <tr>
        <th>名前</th>
        <td><slot name="user-name"></slot></td>
      </tr>

      <tr>
        <th>本文</th>
        <td><slot name="user-description"></slot></td>
      </tr>

      <tr>
        <th>投稿日</th>
        <td><slot name="user-date"></slot></td>
      </tr>
    </tbody>
  </table>
</template>

<script>
{
  console.log('hoge-piyo要素の動きを定義します');
  class HogePiyoHTMLElement extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector('#template');
      const shadow = this.attachShadow({
        mode: 'closed'
      });

      shadow.append(template.content.cloneNode(true));
      console.log('hoge-piyo要素が生まれたよ', this);
    }

    connectedCallback() {
      console.log('hoge-piyo要素が追加されたよ', this);
    }

    disconnectedCallback() {
      console.log('hoge-piyo要素がDOMから削除されたよ', this);
    }
  }

  console.log('hoge-piyo要素をブラウザに知ってもらいます');
  customElements.define('hoge-piyo', HogePiyoHTMLElement);
}

{
  console.log('新しいhoge-piyoを作成してページに追加します。 ---');

  const list = document.querySelector('#list');
  const newHogePiyo = document.createElement('hoge-piyo');

  newHogePiyo.insertAdjacentHTML('afterbegin', `
    <span slot="user-id">@ponyoponyo</span>
    <span slot="user-name">佐藤太郎</span>
    <span slot="user-description">あたらしいやつ</span>
    <span slot="user-date">1月4日</span>
  `);
  list.append(newHogePiyo); // ちゃんとテンプレにはまって表示される
}
</script>
  1. そのメリットについてはこちらを参照:Open vs. Closed Shadow DOM

  2. shadow DOM の使い方

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5