uhyohyo.net

JavaScript初級者から中級者になろう

十七章第四回 Shadow DOM

このページの最終更新日:

今回はWeb Components関連仕様の紹介の第2弾です。今回紹介するShadow DOMもまた、Web Componentsの重要な要素です。

ここでは、shadowというのは隠されているということを意味しています。簡単に言うと、Shadow DOMによりある要素の実装(中身)を隠蔽することができるのです。

ShadowRootを作る

Shadow DOMを使うには、要素に対してShadowRootを作ります。そのためには、Elementが持つattachShadowメソッドを呼び出します。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <p>これはテストです。</p>
    <script>
      document.body.attachShadow({mode: "open"});
      console.log(document.body.shadowRoot);
    </script>
  </body>
</html>

この例は、script要素中でbody要素に対してattachShadowを呼び出しています。このとき、引数を1つ渡します。この引数はオプションオブジェクトで、modeプロパティに"open"または"closed"を指定します。今回はとりあえずopenとしています。

この例を開いてもページに何も表示されないはずです。実は、ShadowRootが作られるとその中身が要素の中身となり、子要素たちは無視されるのです。(正確には無視されるというと語弊があるかもしれませんが、それはのちのち解説します。)

つまり、ShadowRootというのは実は中身を持つものであり、その中身がShadowRootを持つ要素(今回はbody要素)の中身となるのです。ShadowRootを用いて要素の中身を作ってやることで、要素の内部構造が外から見えなくなります。これにより、前回述べたような、外から直接要素の中身をいじられて挙動がおかしくなるというようなことを防ぐことができます。

上の例の12行目にあるように、ある要素に対してShadowRootが作られると、その要素のshadowRootプロパティでShadowRootオブジェクトを取得できます。

ShadowRootには中身があると述べましたが、実はこれはDocumentFragmentを継承するノードです。したがって、いつも通りの方法でこいつの中身をいじることができます。では、ShadowRootを用いてbodyの中身を作ってみましょう。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <p>これはテストです。</p>
    <script>
      document.body.attachShadow({mode: "open"});
      // body要素の中身としてp要素を作る
      var p = document.createElement("p");
      p.textContent = "これはShadowRootの中身です。";
      // bodyのshadowRootに追加
      document.body.shadowRoot.appendChild(p);
    </script>
  </body>
</html>

このページを開くと、「これはShadowRootの中身です。」とだけ表示されます。確かにShadowRootの中身がbodyの中身となっていますね。

では、attachShadowの引数に話を戻します。いまmodeが"open"となっているのを"closed"とすると、ShadowRootが隠されます。つまり、shadowRootプロパティを用いて取得できなくなります。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <p>これはテストです。</p>
    <script>
      document.body.attachShadow({mode: "closed"});
      console.log(document.body.shadowRoot); // null
    </script>
  </body>
</html>

これだと自分もShadowRootをいじれないような気がしますが、その心配はありません。実は、ShadowRootを作ったときはattachShadowの返り値としてShadowRootが得られるからです。これを用いれば、closedなShadowRootを作って自分だけはそれをいじるということができます。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <h1>test</h1>
    <p>これはテストです。</p>
    <script>
      var shadowRoot = document.body.attachShadow({mode: "closed"});
      var p = document.createElement("p");
      p.textContent = "これはShadowRootの中身です。";
      shadowRoot.appendChild(p);
    </script>
  </body>
</html>

では、これを前回紹介したCustom Elementsと組み合わせてみましょう。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <my-element content="hello">
      <p>ここは表示されません。</p>
    </my-element>
    <script>
      class MyElement extends HTMLElement {
        static get observedAttributes(){ return ['content']; }
        constructor(){
          super();
          // 自分に対応するshadowRootを作る
          this.attachShadow({mode: 'open'});
        }
        attributeChangedCallback(name, oldValue, newValue){
          if (name === 'content'){
            // 自分のShadow Rootの中身を書き換える
            this.shadowRoot.textContent = newValue || '';
          }
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

これを表示すると「hello」と表示されています。例えば以下のようなコードでmy-element要素のcontent属性を変更すればそれが反映されるのが分かります。


var elm = document.querySelector('my-element');
elm.setAttribute('content', 'Hey!');

今回はShadowRootを用いていますから、my-elementの中身をいじられても痛くも痒くもありません。

この例ではshadowRootはopenにしています。これだとshadowRootを見られたら中身をいじられてしまうので本当に中身を隠したければclosedにするべきでしょうが、Web Components的な観点からは、危険を承知であえて中身をいじりたいという物好きのためにopenにしておくのもありです。closedにしてしまうとまったく融通のきかないカスタム要素になってしまう可能性がありますからね。

とにかく、ShadowRootを用いて要素の実体(要素の中身)を隠すことができました。

ちなみに、ShadowRootの下にある隠された木構造のことをshadow tree (shadowツリー)といいます。また、ShadowRootを持っている要素のことはshadow host (shadowホスト)といいます。

Shadow DOMの機能は今のところshadowツリーを隠すだけですが、実はもう少し高度な機能があります。それをこれから紹介していきます。

その前にやや細かい話ですが、実はどんな要素に対してもShadowRootを作ることができるわけではありません。カスタム要素はOKですが、それ以外でShadowRootを作れるのは次の要素に限られます(HTML仕様書から引用)。

"article", "aside", "blockquote", "body", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "main" "nav", "p", "section", or "span"

これらの要素は、それ自体にセマンティクス(h1は見出しとか、blockquoteは引用とか)があったりスタイルが適用されていたりするとはいえ、その中身が透過される(中身に手を加えられずそのまま表示される)ような要素です。なので、中身がShadowRootにより別物になっても問題ないわけです。逆に、たとえばinput要素はそれ単体で入力フィールドとかボタンなどのコンポーネントを表現しているので、その実体を別物にするわけにはいきません。他にも、a要素もその中身に対してリンクになるという特別な変化をもたらしますから、ShadowRootにより中身を替えられるわけにはいかないのです。まあ、主にShadowRootを作るのはカスタム要素になると思いますから、こんなことは覚えなくてもなんとかなるでしょう。

ShadowRootとCSS

では、Shadow DOMの便利さを説明していきます。

実は、shadowツリーはCSSに関して特殊な扱いを受けます。簡単に言うと、shadowツリーの中身は外のスタイル定義の影響を受けません。また、逆に、shadowツリーの中のスタイル定義は外に影響を与えません。これはざっくりとした説明なので厳密には少し違うのですが、とにかく説明していきます。

外のスタイル定義の影響を受けないというのは、次の例を見れば分かります。今回は次の例を実際に用いたサンプルを用意しました。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
    <style>
      .foo {
        color: red;
      }
    </style>
  </head>
  <body>
    <p class="foo">赤い段落</p>
    <my-element></my-element>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();
          this.attachShadow({mode: 'open'}).innerHTML = `<p class="foo">赤くない段落</p>`;
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

7から9行目はCSSで、これはfooクラスを持つ要素の文字色を赤くするという指定です。これにより、13行目のp要素はfooクラスを持つため文字色が赤くなっています。

今回はmy-elementというカスタム要素を作りました。例によって要素はShadowRootを持ちます。今回は面倒くさいのでinnerHTMLを用いてShadowRootの中身を作りました。ここで注目すべきは、ShadowRootの中にもfooクラスを持つp要素があるということです。

しかし、このページを表示してみるとmy-elementによって表示されるp要素はfooクラスを持つにもかかわらず赤くなりません。これは、ShadowRootの外のスタイル定義(7-9行目)がShadowRootの中に影響を与えないことを意味しています。これは再利用可能なパーツを作るという目的にとても合致しています。カスタム要素はどこで誰が使っても良いわけですから、外にどんなスタイル定義があってもその中身が影響を受けるべきではありません。外のスタイル定義のせいでカスタム要素の表示がおかしくなるのは困りますね。

ただし、次のような場合に注意してください(サンプル2)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
    <style>
      .foo {
        color: red;
      }
    </style>
  </head>
  <body>
    <p class="foo">赤い段落</p>
    <div style="color: blue">
      <my-element></my-element>
    </div>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();
          this.attachShadow({mode: 'open'}).innerHTML = `<p class="foo">赤くない段落</p>`;
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

この例では、my-element要素を囲むdiv要素にcolor: blueというスタイルがあります。その結果、my-elementのShadowツリー内のp要素の文字色が青くなります。

これはShadowRootの中身が外のスタイルに影響を受けていることを示しており、先に述べたのと言っていることが違うように思えるかもしれません。しかし、実はそんなことはありません。今まで一貫して「外のスタイル定義の影響を受けない」と述べていたのはこのことを意図しています。実は、今回ShadowRootの中身は外のスタイルの継承の影響を受けているのです。スタイルの継承とは、親の要素が持つスタイルが子にも受け継がれることです。全てのプロパティが継承するわけではありませんが、今回のcolorプロパティは継承するプロパティのひとつです。すなわち、自身の文字色は親の文字色と同じになるということです。

Shadowツリー内へのスタイルの継承は、無いとそれはそれで困ります。カスタム要素の中身の文字色に特にこだわりは無く、それが使われる側の文字色に合わせたいという場合はスタイルの継承が必要になります。一方、スタイルの継承を無効化するのはそんなに難しくはありません。内側で別途スタイルを指定すればいいのです。次のようにすればmy-elementの中は黒色(というよりはデフォルトの色)になるでしょう。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
    <style>
      .foo {
        color: red;
      }
    </style>
  </head>
  <body>
    <p class="foo">赤い段落</p>
    <div style="color: blue">
      <my-element></my-element>
    </div>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();
          this.attachShadow({mode: 'open'}).innerHTML = `<p class="foo" style="color: initial">赤くない段落</p>`;
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

以上のように、スタイルの継承は便利だし、必要ないときは対処も簡単なので、ShadowRootの内側にも影響を及ぼすことができるのです。

この例では、shadowツリーの中でのスタイル指定が出てきました。実は、ShadowRootの下にstyle要素を置くこともできます(サンプル3)。


<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <p class="foo">赤い段落</p>
    <my-element></my-element>
    <script>
      class MyElement extends HTMLElement {
        constructor(){
          super();
          this.attachShadow({mode: 'open'}).innerHTML = `
            <style>
              p.foo {
                color: blue;
              }
            </style>
            <p class="foo">青い段落</p>`;
        }
      }

      customElements.define('my-element', MyElement);
    </script>
  </body>
</html>

このようにshadow treeの中に置かれたstyle要素はshadow treeの中にだけ影響を及ぼします。特に、shadow treeの外側にもセレクタp.fooにマッチするp要素がある(8行目)のに、それには影響を及ぼしていません。

これもShadowRootの便利な性質です。カスタム要素の表示をCSSで調整したいとき、そのCSSがカスタム要素の外側に影響を及ぼすのはやはり好ましくないからです。また、これにより、カスタム要素の中ではセレクタの被りを気にする必要がなくなります。この例ではカスタム要素の内側ではスタイリングのためにfooというクラスを使っていますが、カスタム要素の外側でも偶然fooというクラスが使われています。このようにクラス名が被った場合でも問題が起きないのがShadow DOMの利点であり、これによりカスタム要素側でも好きなようにクラス名を使うことができるのです。


以上がShadow DOMの基礎です。Shadow DOMではShadowRootの内側と外側を分離させることで、Web Componentsに求められる再利用性を担保しています。前回のCustom Elementsと今回のShadow DOMがWeb Componentsの根幹を成している技術ですが、これらに関してはまだ説明することがありますので、それを次回以降紹介していきます。