シャドウ DOM の使用

ウェブコンポーネントにおける重要な側面の一つが、カプセル化です。マークアップ構造、スタイル、動作を隠蔽し、コード上の他のコードから分離することで、他の部分でクラッシュすることを防ぎ、コードをきれいにしておくことができます。シャドウ DOM API はこの主要部分であり、隠蔽され分離された DOM を要素に取り付けるための方法を提供しています。この記事ではシャドウ DOM を使う基本を記述しています。

メモ: シャドウ DOM は Firefox (バージョン 63 以降)、Chrome、Opera、 Safari が対応しています。 Chromium ベースの新しい Edge (75 以降)も対応しています。古い Edge は対応していません。

高水準のビュー

この記事は、すでにあなたが DOM (Document Object Model) の概念を理解していることを想定しています。これはツリー上の構造で、接続されたノードがマークアップ文書(ウェブ文書の場合は通常 HTML 文書)に現れるさまざまな要素や文字列を表します。例として、以下のような HTML の断片を考えてみましょう。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Simple DOM example</title>
  </head>
  <body>
      <section>
        <img src="dinosaur.png" alt="A red Tyrannosaurus Rex: A two legged dinosaur standing upright like a human, with small arms, and a large head with lots of sharp teeth.">
        <p>Here we will add a link to the <a href="https://www.mozilla.org/">Mozilla homepage</a></p>
      </section>
  </body>
</html>

この断片によって以下のような DOM 構造が構成されます。

シャドウ DOM により、通常の DOM ツリーの要素の下に隠れた DOM ツリーを取り付けることができます。このシャドウ DOM ツリーはシャドウルートから始まり、その下には普通の DOM ツリーと同様に任意の要素を追加することができます。

文書、シャドウルート、シャドウホストの相互作用を示す図の SVG 版。

以下にシャドウ DOM における用語を定義します。

  • シャドウホスト: シャドウ DOM が取り付けられた、通常の DOM ノード
  • シャドウツリー: シャドウ DOM の中にある DOM ツリー
  • シャドウ境界: シャドウ DOM と通常の DOM の境界
  • シャドウルート: シャドウツリーの根ノード

シャドウ DOM 内のノードには、シャドウでないノードと全く同じように影響を与えることができます。たとえば、子を追加したり、属性を設定したり、element.style.foo を使用して個々のノードのスタイルを設定したり、 <style> 要素内でシャドウ DOM ツリー全体へのスタイルを追加したりすることができます。違いは、シャドウ DOM 内のどのコードもその外の何かに影響を与えることができず、便利なカプセル化ができることです。

なお、シャドウ DOM は決して新しいものではありません。ブラウザーは長い間、要素の内部構造をカプセル化するためにこれを使用してきました。例えば、既定のブラウザーコントロールが公開されている <video> 要素を思い浮かべてください。 DOM には <video> 要素しか表示されませんが、そのシャドウ DOM の内部には、一連のボタンやその他のコントロールが含まれています。 Shadow DOM 仕様により、独自のカスタム要素のシャドウ DOM を実際に操作することができるようになりました。

基本的な使い方

任意の要素にシャドウルートを取り付けるには Element.attachShadow() メソッドを使用します。このメソッドはオプションオブジェクトを引数として取り、その中にはオプションが 1 つ、 mode オプションを open または closed の値で取ります。

let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

open の場合は、シャドウ DOM にメインページに書かれた JavaScript からアクセスできます。以下のように Element.shadowRoot プロパティを利用してアクセスできます。

let myShadowDom = myCustomElem.shadowRoot;

シャドウルートを mode: closed で取り付けた場合、外部からシャドウ DOM にアクセスすることができません。 myCustomElem.shadowRootnull を返します。シャドウ DOM を含む既成の要素、例えば <video> などは closed になっています。

メモ: このブログ記事を見ると、実は closed のシャドウ DOM を回避するのはさほど難しいことではなく、また、これを完全に隠すことはその価値の割には面倒です。

シャドウ DOM をカスタム要素のコンストラクターの一部として取り付けた場合(シャドウ DOM の最も有用な用途です)、次のような方法を使用することになります。

let shadow = this.attachShadow({mode: 'open'});

シャドウ DOM を要素に取り付けた場合、その操作は通常の DOM 操作と同じ DOM API を使うだけでよいのです。

let para = document.createElement('p');
shadow.appendChild(para);
// etc.

簡単な例を一通り扱う

カスタム要素内のシンプルなシャドウ DOM を見てみましょう。 <popup-info> (ライブ例を参照)です。この要素は画像アイコンとテキストを取り、アイコンをページに埋め込みます。アイコンがフォーカスされるとポップアップが表示され、さらなる情報を提供します。まずは HTMLElement を拡張して PopUpInfo というクラスを定義します。

class PopUpInfo extends HTMLElement {
  constructor() {
    // コンストラクターでは常に super を最初に呼び出してください
    super();

    // ここに要素の機能を記述します

    ...
  }
}

クラス定義の中で、要素のコンストラクターを定義し、その中で要素のインスタンスが生成されたときに、その要素が持つすべての機能を定義します。

シャドウルートの作成

最初にシャドウルートをカスタム要素に追加します。

// シャドウルートを生成
let shadow = this.attachShadow({mode: 'open'});

シャドウ DOM 構造の作成

次に、いくつかの DOM 操作を使用して、要素の内部シャドウ DOM 構造を作成します。

// spans の生成
let wrapper = document.createElement('span');
wrapper.setAttribute('class', 'wrapper');
let icon = document.createElement('span');
icon.setAttribute('class', 'icon');
icon.setAttribute('tabindex', 0);
let info = document.createElement('span');
info.setAttribute('class', 'info');

// 属性の中身を取得し、 info の span の中に入れる
let text = this.getAttribute('data-text');
info.textContent = text;

// アイコンを挿入
let imgUrl;
if(this.hasAttribute('img')) {
  imgUrl = this.getAttribute('img');
} else {
  imgUrl = 'img/default.png';
}
let img = document.createElement('img');
img.src = imgUrl;
icon.appendChild(img);

シャドウ DOM のスタイル付け

そのあと、 <style> 要素を作り CSS でスタイルを付けます。

// CSS を生成してシャドウ DOM に適用
let style = document.createElement('style');

style.textContent = `
.wrapper {
  position: relative;
}

.info {
  font-size: 0.8rem;
  width: 200px;
  display: inline-block;
  border: 1px solid black;
  padding: 10px;
  background: white;
  border-radius: 10px;
  opacity: 0;
  transition: 0.6s all;
  position: absolute;
  bottom: 20px;
  left: 10px;
  z-index: 3;
}

img {
  width: 1.2rem;
}

.icon:hover + .info, .icon:focus + .info {
  opacity: 1;
}`;

シャドウ DOM をシャドウルートに追加

最後のステップは、生成した要素すべてをシャドウルートに取り付けることです。

// 生成した要素をシャドウ DOM に取り付ける
shadow.appendChild(style);
shadow.appendChild(wrapper);
wrapper.appendChild(icon);
wrapper.appendChild(info);

カスタム要素の使用

一度クラスを定義すれば、カスタム要素の使用で説明したように、その要素を定義し、ページに配置するだけで簡単に使用できるようになります。

// 新しい要素を定義
customElements.define('popup-info', PopUpInfo);
<popup-info img="img/alt.png" data-text="Your card validation code (CVC) is an extra
                                    security feature — it is the last 3 or 4
                                    numbers on the back of your card.">

内部スタイルと外部スタイル

上記の例では <style> 要素を用いてシャドウ DOM にスタイルを適用しましたが、代わりに完全に <link> 要素から外部スタイルシートを参照することが可能です。

例えば、 popup-info-box-external-stylesheet のコードを少し見てみましょう(ソースコードはこちら)。

// 外部スタイルシートをシャドウ DOM に適用
const linkElem = document.createElement('link');
linkElem.setAttribute('rel', 'stylesheet');
linkElem.setAttribute('href', 'style.css');

// 生成された要素をシャドウ DOM に添付
shadow.appendChild(linkElem);

なお、 <link> 要素はシャドウルートの描画をブロックしないので、スタイルシートのロード中にスタイル付けされていないコンテンツ (FOUC) が一瞬表示されるかもしれないことに注意してください。

最近のブラウザーの多くは、共通のノードからクローンされた、あるいは同一のテキストを持つ <style> タグに対して、単一のバッキングスタイルシートを共有できるようにする最適化を実装しています。この最適化によって、外部スタイルでも内部スタイルでも性能は同程度になるはずです。

関連情報