Webコンポーネント(3):Shadow DOM

この記事は、Webコンポーネントを紹介する連載記事で、3回目は、Shadow DOMについて紹介します。
おそらく以前のカスタム要素の記事を読まれている方は、さまざまなスペック、APIなど、覚えることが多くて飽きてくるかもしれません。しかし、今回のShadow DOMで学ぶべきAPIはelement.attachShadow()関数のみなので、気軽に始めてほしいです。短いコードから試してみて、詳しい内容を調べていこうと思います。
今回は、HTML、CSSのスコープをテーマにします。

前回の記事

すべての変数をグローバルに入れるのはやめよう

もし、あなたがフロントエンドの開発者なら、グローバルにすべてのことを解決しようとするコードを作っていることでしょう。
例として私たちが作成したCSS、HTML、Java Scriptを参考にしてみよう。
HTML、CSSともに1ページにすべてのエレメント、すべてのCSSルールが書かれています。
document.querySelector()さえあれば、このページのアルファとオメガを貫通できます。
HTLMもCSSも同様に、すべてpublicであり、すべてがglobalです。
しかし、SASS、LESSのようなツールは根本的な問題の解決策にはなりません。

分離:#shadow-root

では、Shadow DOMはどのようにHTMLとCSSのスコープを付与できるのでしょうか?
HTMLやCSSのどちらもページに適用されるものですが、簡単には理解できません。

まず始めてみよう。Chromeブラウザの開発者向けツールを開き、以下のコードを任意のページに貼り付けて、何が起こるか確認してみよう。以下のコードは、bodyspanタグを1つ追加し、その子にstyledivを再び追加しました。単に子ノードに追加されたstyleのスコープを調べるためです。

// どんな緑になるかな?
document.body.appendChild(document.createElement('span')).innerHTML
  = '<style>div { background-color: #82b74b; }</style><div>Hello</div>';


Googleで貼り付けてみました。
図からわかるように、styleタグはどこにつけてもグローバルであるため、ページに存在するすべてのdivを緑色にしてしまいました。

次にShadow DOMを使って同様に試してみよう。
上記のコードと変わった点は、attachShadow({mode: ‘open’})関数の実行をもう1つ追加しただけです。
この関数は、Shadow rootを生成しますが、これはDOMスコープの境界線の役割を持ちます。
{mode: ‘open’}は今のところ重要ではないのでスキップします。
ページを更新して、次のコードを入れ直してみよう。

// 今度こそHelloだけを緑に!
document.body.appendChild(document.createElement('span'))
  .attachShadow({mode: 'open'})
  .innerHTML = '<style>div { background-color: #82b74b; }</style><div>Hello</div>';


開発ツールを見ると、#shadow-root (open)が登場し、その下にあるstyleは外に漏れないことがわかります。
逆にグローバルに存在するスタイルも#shadow-root (open)の中にあるエレメントには影響しません。
これを確認するため、今回はabout:blankページに移動して、次のコードを入れてみよう。

document.body.appendChild(document.createElement('span')).innerHTML
  = '<style>div { background-color: #82b74b; }</style><div id="non-shadow">Hello</div>';
document.body.appendChild(document.createElement('span'))
  .attachShadow({mode: 'open'})
  .innerHTML = '<div id="shadow">Hello</div>';


上述のとおり、Shadow rootに存在するエレメントには、rootの外にあるグローバルスタイルが適用されないことがわかります。
上記のサンプルではスタイルを基準に説明しましたが、Shadow DOMはDOM自体を分離する役割を持ちます。
つまりShadow rootを基準にidを重複して使ってもよいし、root内外の同じ名前のclassも全く別のクラスの役割を果たします。
Shadow rootの外でShadow DOMのエレメントをセレクトすることもできません。
1つのHTMLドキュメントに数千件あるエレメントのスタイルを一度ですべて管理するため、どのようなclass名にするか悩んだり、id重複が怖くて使えないと心配する必要もありません。
Shadow DOM1つで1つの文書を管理するように、適切なidを配分すれば(あるいはそれさえも必要とせず)、短いセレクタとして十分にその役割を実行できます。

組み合わせ:<slot>

Shadow DOMを使わなくても、iframeを使えば似たような機能を実行できます。
しかしiframeを使用したDOMの分離は、次のような欠点があります。

  • httpリクエストがもう1回発生する
  • 別ページのため、消費されるリソースも高くて遅い
  • iframeのアドレスが同じドメインでない場合はアクセスできない

このような理由から、Twitterはiframe形式でサポートしていた機能をブラウザが対応している場合、Shadow DOM方式に切り替えました。

What does this change mean for you? Much lower memory utilization in the browser, and much faster render times. Tweets will appear faster and pages will scroll more smoothly, even when displaying multiple Tweets on the same page. – Upcoming Change to Embedded Tweet Display on Web

これらの利点に加え、iframeになくShadow DOMができることとして、スロットの組み合わせがあります。
スロットはHTMLでの組み合わせを組んで現れ、特別な機能を実行している場合に発見できます。
ol+liselect+itemform+inputなどがその例です。

Shadow DOMと一緒に使うスロットも、上記のol, li, selectなどと同じ概念を持って動作します。
olli子ノードに数字を付与するという具合です。
特別なマークアップを付与することもでき、スタイルを変えたり、アクションを実行する機能を付与することもできます。
次の例で、子ノード(Light DOM)をブロック要素で覆ってみます。

以下は、開発者ツールで編集して使用したり、小さなHTMLファイルを作成してテストすることができます。
文法を説明する前に、円滑な意思疎通のためいくつかの定義を話そう。
whatwg – Shadow tree

  • Shadow DOM:下記コードで、h1、pなどShadow rootについているDOM
  • Shadow root#shadow-root:)
  • Shadow hostShadow rootの親。下記コードでdiv#slot-test
  • Light DOM:ドキュメントのShadow hostについているノード。span

下記の動作を、上の定義で解釈すると、「Shadow DOMのスロットが持つ名前に合わせてLight DOMのノードが各スロットに挿入される」と言えます。

<body>
...
<div id="slot-test">
  <!-- Light DOM -->
  <span slot="title">Hello</span>
  <span slot="desc">world</span>
</div>
...
</body>
// Shadow DOM
document.querySelector('#slot-test')
  .attachShadow({mode: 'open'})
  .innerHTML = `
  <h1>
    <slot name="title"></slot>
  </h1>
  <p>
    <slot name="desc"></slot>
  </p>
  `;

コンポーネント:カスタム要素+Shadow DOM= DOM OOP

Shadow DOMを使わずにDOMの分離ができる方法として、iframeが使用できます。
実際のShadow DOMmode: closeのpolyfillはiframeで作成されました。
上記で作成したShadow DOMをサンプルにして、次のようにShadow DOMのエレメントに外部からアクセスしてみよう。

document.querySelector('#non-shadow'); // <div id="non-shadow">Hello</div>
document.querySelector('#shadow'); // null

Shadow DOMのノードは、idから取得できません。
Shadow DOMに存在するエレメントをShadow DOMの外から取得するには、次のような方法で対応できます。

document.querySelector('span').shadowRoot.querySelector('#shadow'); // <div id="shadow">Hello</div>

前回の記事で調べたカスタム要素について思い出してみよう。
カスタム要素は、HTML要素をJavaScriptのオブジェクトとして管理できます。

// JavaScriptとHTML要素を一緒に作成する
class MyElement extends HTMLElement {
  yey() {
    console.log('yey');
  }
}
document.querySelector('my-element').yey() // 'yey'

次にカスタム要素とShadow DOMを束ねたコードを見てみよう。

class MyElement extends HTMLElement {
    static get observedAttributes() {return ['lang']; }

    constructor() {
      super();

      // add shadow root in constructor
      const shadowRoot = this.attachShadow({mode: 'open'});
      shadowRoot.innerHTML = `
        <style>div { background-color: #82b74b; }</style>
        <div>yey</div>
      `;
      this._yey = shadowRoot.querySelector('div');
    }

    attributeChangedCallback(attr, oldValue, newValue) {
      if (attr == 'lang') {
        let yey;
        switch (newValue) {
          case 'ko':
            yey = '만세!';
          break;
          case 'es':
            yey = 'hoora!';
          break;
          case 'jp';
            yey = '万歳!';
          break;
          default:
            yey = 'yey!';
        }

        this._yey.innerText = yey;
      }
    }

    yell() {
      alert(this._yey.innerText);
    }
  }

  window.customElements.define('my-element', MyElement);

では、下図と上記コードを互いに代入してみよう。
下図は、カスタム要素、Shadow DOMをOOPオブジェクトの概念と同様に描いたものです。
カスタム要素はHTML要素を拡張してオブジェクトに作成し、Shadow DOMは、そのオブジェクトにスコープを提供します。
つまり、カスタム要素とShadow DOMはDOMをOOPの対象として確認できます。

カスタム要素が持つShadow DOMツリーの要素はOOPで内部実装に対応します。
外部から任意のオブジェクトのprivate属性を変更したい場合、それはどのような状況でしょうか?
privateを修正するという試みがまず誤っており、オブジェクトのアイデンティティに合わせて、必要であればメソッドを新たに追加する必要があります。Webコンポーネント(カスタム要素+Shadow DOM)も同様です。私たちが調べている技術は、ウェブコンポーネントであり、それ自体が独立して完全性のあるものでなければなりません。

下記の悪い例のようにセレクタを作成している場合、上図をもう一度思い出してください。

例1

<!-- GOOD! DOM OOP! -->
<my-element lang="es"></my-element>

// BAD IDEA!
  document.querySelector('my-element')
    .shadowRoot
    .querySelector('div')
    .innerText = 'hoora!'

例2

// GOOD! DOM OOP!
const myElement = document.querySelector('my-element');
myElement.yell();

// BAD IDEA!
const yey = document.querySelector('my-element')
  .shadowRoot
  .querySelector('div')
  .innerText;
alert(yey);

DOMをインターフェイスに

GoogleポリマーチームのRob Dodsonは、上図でDOMに対応するeventsattributesでインタフェースを構成するように勧告しています。
エレメントは最終的に状態を示すので、methodを実行するよりもattributes値を割り当てることが正しいということです。
その上でカスタム要素はattributesの値が変更されると、それに合わせて動作を実行すればよいでしょう。
また、重要なattributesの値や状態がある場合は、eventsにエクスポートして、必要な場所で実行することを提案しています。

確かに良い方法ですが、attributesの値は文字列、存在の有無と呼ばれる値だけを処理するしかありません。実際のコンポーネントを構成するときにどれほどの効用があるかは分かりません。上記のようにDOMのスコープだけうまく守っておけば十分良いコンポーネントインタフェースが作成されるでしょう。

Shadow DOMの詳細

ここでは簡単に主要な点だけ言及しておきます。

  • textareainputimageのようなエレメントはShadow DOMを持つことができない
  • Shadow DOMは何度も入れ子にできる。slotも同様
  • スロットに配布されたエレメントは、slot.assignedNodes()を介して配布できる
  • スロット内のエレメントが変更されたときslotchangeのイベントをslot要素にリスナーをつけて受け取る
  • Shadow hostのスタイルは:hostに変更する
  • Shadow hostのクラスに応じたスタイルは、:host-context(.classname)で可能である
  • スロットスタイルは::sloted(h1)方式とする
  • attachShadow({mode: ‘closed’})でShadow rootを作成すると、Shadow DOMへはアクセスできない
  • Shadow DOM内部で発生したイベントのtargetは、外部からのShadow hostに変更される

 

References

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop