Web components の使い方

Web components(ウェブコンポーネント)の基本的な使い方に関する覚書です。カスタム要素やシャドウ DOM、スロットを使ったコンポーネントの作り方やテンプレート(template 要素)の使い方などについて。

作成日:2022年8月25日

Web Components とは

Web Components(ウェブコンポーネント)は、再利用可能でカプセル化された独自の HTML 要素(カスタム要素)を作成するための標準(web platform APIs )で、以下で構成されています。

  • Custom Elements(カスタム要素とその動作を定義するための JavaScript API)
  • Shadow DOM(外部から影響を受けないようにカプセル化するための JavaScript API)
  • HTML Template(<template> と <slot> 要素を使ったテンプレート)

React や Vue などのライブラリを使わずに、JavaScript だけでコンポーネント(Web ページで再利用できる部品)を作成することができます。

以下が Web Components を実装する基本的な流れです(順番は必ずしも以下の通りとは限りません)。

  1. ウェブコンポーネントの機能を明示したクラスもしくは関数を定義してカスタム要素を作成
  2. 作成したカスタム要素を CustomElementRegistry.define() メソッドを使って登録
  3. Element.attachShadow() メソッドを使って、シャドウ DOM をカスタム要素に紐付け
  4. 必要に応じて <template> と <slot> を使って、HTML テンプレートを定義
  5. ページ内の任意の場所で通常の HTML 要素のようにカスタム要素を使用

カスタム要素だけでコンポーネントを作成することもできますが、シャドウ DOM を利用することでカプセル化された再利用しやすいコンポーネントを作成することができます。

実際にはカスタム要素とシャドウ DOM を組み合わせてコンポーネントを作成します。また、template 要素を使用することで、カスタム要素の HTML 解析コストを削減することができ、slot 要素を使うことで、ユーザーが独自のコンテンツをコンポーネントに挿入することができるようになります。

参考サイト

Custom Elements

ウェブコンポーネントでは独自のメソッドやプロパティ、イベントなどを持つカスタム要素(Custom Elements)を作成することができます。

カスタム要素には以下の2種類があります。

  • 自律カスタム要素 (Autonomous custom elements) : HTMLElement クラスを拡張した要素
  • カスタマイズされた組み込み要素(Customized built-in elements) :HTMLButtonElement のなどの組み込みの HTML 要素を拡張した要素。※ 2022年8月の時点では、Safari が未対応(caniuse

自律カスタム要素

カスタム要素のクラスのオブジェクトは ES 2015 のクラス構文で実装します。

自律カスタム要素の場合は汎用的な HTMLElement クラス(インターフェース)を拡張します。

HTMLElement を拡張することで、カスタム要素は DOM API 全体を継承し、独自にクラスに追加したプロパティやメソッドが要素の DOM インターフェースの一部になることを意味します。

カスタム要素の基本的な動作を定義する場合は、コンストラクターをオーバーライドします。

コンストラクターを使用する場合は、常に super を最初に呼び出し、正しいプロタイプチェーン(基底クラスとの継承関係)と this 値が確立されるようにします(コンストラクタをオーバーライドするとき、this を使う前に Child コンストラクタの中で super として親のコンストラクタを呼ぶ必要があります)。

以下はカスタム要素 <my-element>の DOM インタフェース( HTMLElement を継承したクラス) MyElement を定義する例です。

// カスタム要素(クラスのオブジェクト)の定義
class MyElement extends HTMLElement {
  // コンストラクターをオーバーライド
  constructor() {
    // 常に super を最初に呼び出す(親クラスのコンストラクタの呼び出し)
    super();
    // 初期化や Shadow DOM の関連付け、イベントリスナなどを記述
  }
  //メソッドなどの定義などを記述
}
// ページにカスタム要素を登録(window. は省略)
customElements.define('my-element', MyElement);

ウェブ文書上でカスタム要素を制御するのは CustomElementRegistry オブジェクトです。このインスタンスを取得(参照)するには、customElements プロパティ(window.customElements)を使用します。

ページにカスタム要素を登録するには、CustomElementRegistry オブジェクトのメソッド customElements.define() を使います。

customElements.define() メソッドは引数に次のものを取ります。

  • 要素に与える名前を表す DOMString(カスタム要素の名前の文字列)。
  • 要素の振る舞いを定義したクラスのオブジェクト。
  • オプションオブジェクト(省略可 ※カスタマイズされた組み込み要素にのみ関係)。

クラスを別途定義せず、以下のように第2引数にクラスの定義を記述することもできます。

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();
    // 初期化や Shadow DOM の関連付け、イベントリスナなどを記述
  }
  //メソッドなどの定義などを記述
});

カスタム要素を作成する際のルール

  • カスタム要素の名前には、通常の要素とを区別できるようにするためにハイフン (-) を含める必要があります(例:<my-element>)。
  • 同じ名前のタグを複数回登録することはできません。
  • カスタム要素では必ず終了タグ(<my-element></my-element>)を記述する必要があります。

以下は「This is My Element!」と出力するだけの単純なカスタム要素の例です。

class MyElement extends HTMLElement {
  constructor() {
    super();
  }
  // 要素が document に追加された時に呼び出されるライフサイクルコールバック
  connectedCallback() {
    //this は <my-element>
    this.textContent = 'This is My Element!';
  }
}
customElements.define('my-element', MyElement);

以下をページに記述すると

<my-element></my-element>

以下のように出力されます。

<my-element>This is My Element!</my-element>

this でカスタム要素を参照

クラス定義の内部で、this で DOM 要素そのもの(クラスのインスタンス)を参照することができます。

上記の場合、this は <my-element> を指します。

this.setAttribute() で属性を設定したり、this.addEventListener() でイベントリスナーを設定したりすることができます。

DOM API 全体が要素コードの内部で利用可能なので、要素のプロパティにアクセスしたり、子要素を調べたり(this.children)、ノードをクエリしたり(this.querySelectorAll())することができます。

ライフサイクルフック

カスタム要素は、そのライフサイクル中にコードを実行するための特別なメソッド(ライフサイクルフック)を定義することができます。これらはカスタム要素リアクション(custom element reactions)と呼ばれます。

以下のメソッドを定義することで、ブラウザはカスタム要素のライフサイクルの変化に応じてそれらを自動的に呼び出します。

メソッド 説明(呼び出されるタイミング)
constructor() カスタム要素のインスタンスが作成またはアップグレードされる時に呼び出されます。状態の初期化、デフォルト値の設定、イベントリスナーの設定、Shadow DOM の作成に利用できます。constructor が呼ばれる時点では要素のインスタンスは作成されていますが、まだ DOM に追加されていない(属性の処理や割当をしていない)ので通常の DOM 操作はできません。Shadow DOM の取り付けや Shadow DOM の文書構造の定義はコンストラクタ内で行うことができます。custom element constructors and reactions日本語
connectedCallback() カスタム要素が DOM に挿入されるたびに毎回呼び出されます。リソースの取得やレンダリング、セットアップのコードを実行する際に利用できます。一般的には、リソースの取得やレンダリングに関連する作業などは、このタイミングまで作業を遅らせるようにします(constructor が呼ばれる時点ではまだ早すぎるため、通常の DOM 操作ははここで行います)。
disconnectedCallback() カスタム要素が DOM から削除されるたびに呼び出されます。クリーンアップのコードを実行するのに利用できます。
attributeChangedCallback() 監視している属性が追加、削除、更新、置換されるたびに呼び出されます。また、パーサーによって要素が作成されたとき、またはアップグレードされたときに初期値として呼び出されます。※ observedAttributes プロパティにリストされた属性(= static get observedAttributes() メソッドで指定された属性)のみが、このコールバックを受け取ります。
adoptedCallback() カスタム要素があるドキュメントから別のドキュメントに移動されるたび(document.adoptNode(el) を呼び出した時など)に呼び出されます。

リアクションコールバックは同期的

作成したカスタム要素でユーザーが el.setAttribute() を呼び出すと、ブラウザは即座に attributeChangedCallback() を呼び出します。

同様に、カスタム要素が DOM から削除されると(ユーザが el.remove() を呼び出したなど)、すぐに disconnectedCallback() を呼び出します。

参考:Custom element reactions

プロパティと属性

通常の HTML 要素では、独自の(非標準の)属性を設定することができ、getAttribute() や setAttribute() などを使ってその属性を操作することができます。

<div id="elem" foo="Foo!"></div>

<script>
  const elem = document.getElementById('elem');

  //非標準の属性でも getAttribute() で値を取得可能
  console.log(elem.getAttribute('foo'));  //Foo!
  //非標準の属性でも hasAttribute() で確認可能
  console.log(elem.hasAttribute('foo'));  //true

  //非標準の属性でも setAttribute() で値を設定可能
  elem.setAttribute('foo', 'Bar!');
  console.log(elem.getAttribute('foo')); //Bar!

  //但し、非標準の属性の場合、対応するプロパティは生成されない
  console.log(elem.foo);  //undefined

  //非標準の属性でも removeAttribute() で削除可能
  elem.removeAttribute('foo');
  console.log(elem.hasAttribute('foo'));  //false
</script>

参考サイト:javascript.info(属性とプロパティ)

カスタム要素でも、通常の HTML 要素同様、独自の(非標準の)属性を設定することができます。

以下はカスタム要素 <my-element> に独自の foo 属性が指定されていれば、その値を出力し、foo 属性が指定されていない場合は、「Foo」と出力するようにデフォルト値を設定しています。

class MyElement extends HTMLElement {

  connectedCallback() {
    // foo 属性のデフォルト値の設定
    const foo = this.getAttribute('foo') || 'Foo';
    this.textContent = foo;
  }
}
customElements.define('my-element', MyElement);
<my-element foo="My element!"></my-element><!-- My element! と出力-->
<my-element></my-element><!-- Foo と出力 -->

HTML 要素の標準(組み込み)の属性は要素の属性名のプロパティとしてアクセス及び設定することができます。別の言い方をすると、要素が標準の属性を持っている場合、対応するプロパティが生成されます。

例えば、JavaScript で HTML 要素の id プロパティを変更すると、変更された値は DOM 要素の id 属性に反映されます。カスタム要素でも、オブジェクトのプロパティを HTML 属性としてして反映させることで、要素の DOM 表現をその JavaScript の状態と同期することができます。

但し、カスタム要素に設定した独自の属性は、HTML の非標準の属性同様、対応するプロパティは生成されません。

<my-element foo="My element!"></my-element>

<script>
  class MyElement extends HTMLElement {
    connectedCallback() {
      const foo = this.getAttribute('foo') || 'Foo';
      this.textContent = foo;
    }
  }
  customElements.define('my-element', MyElement);

  const myElement = document.querySelector('my-element');
  console.log(myElement.getAttribute('foo'));  //My element!
  console.log(myElement.hasAttribute('foo'));  //true
  myElement.setAttribute('foo', 'Bar!');
  console.log(myElement.getAttribute('foo')); //Bar!

  //対応するプロパティは生成されない
  console.log(myElement.foo);  //undefined
</script>

属性に対応するプロパティを生成

Javascript からカスタム要素の属性をプロパティとして参照したり値を編集するには、getter/setter を用意しておきます。

getter/setter を用意しておくと、this.getAttribute(属性名) の代わりに this.属性名 で値を参照したり、this.setAttribute(属性名, 値) の代わりに this.属性名 = 値 で値を代入することができます。

参考:Reflecting content attributes in IDL attributes(※ IDL はインタフェース記述言語の略)

以下は foo 属性をプロパティとして参照できるようにした例です。

<my-element foo="My element!"></my-element>

<script>
class MyElement extends HTMLElement {

  // get 属性名() でプロパティの getter を定義
  get foo() {
    return this.getAttribute('foo');
  }

  // set 属性名() でプロパティの setter を定義
  set foo(val) {
    this.setAttribute('foo', val);
  }

  connectedCallback() {
    this.textContent = `${ this.foo }`;
  }
}
customElements.define('my-element', MyElement);

const myElement = document.querySelector('my-element');
//プロパティで参照
console.log(myElement.foo);  //My element!
//プロパティで設定(但し、表示には反映されない)
myElement.foo = '123';
console.log(myElement.foo);  //123
//setAttribute() で設定
myElement.setAttribute('foo', 'ABC');
console.log(myElement.foo);  //ABC
</script>

上記の場合、foo 属性に指定した値(My element!)がテキストとして表示され、プロパティで属性を参照することができます。

但し、上記の JavaScript による属性の変更は要素の表示には反映されません。属性の変更を監視して要素に反映させるには attributeChangedCallback() を利用します。

値がない属性の場合

HTMLタグの属性には disabled のように値を必要としないものもあります。以下は値を必要としない独自の属性 disabled の getter/setter の例です。

get disabled() {
  //hasAttribute() で disabled 属性が指定されているかどうかの真偽値を返す
  return this.hasAttribute('disabled');
}

set disabled(val) {
  // val が指定されている(true の)場合
  if (val) {
    // disabled 属性に値はいらないので、空文字列をセット
    this.setAttribute('disabled', '');
  } else {
    // disabled 属性を削除
    this.removeAttribute('disabled');
  }
}

Reflecting properties to attributes

プロパティの初期値を設定

前述の例の場合、属性(プロパティ)の初期値が設定されていないので、例えば属性を指定せずに以下のように記述すると「null」と表示されてしまいます。

<my-element></my-element>

以下は前述の例の foo 属性に初期値を設定する例です。

class MyElement extends HTMLElement {
  constructor() {
    //super を最初に呼び出す
    super();
    // foo 属性に対応するプロパティの初期化
    this._foo = 'Foo';
  }

  // get 属性名() でプロパティの getter を定義
  get foo() {
    if(this.hasAttribute('foo')) {
      // foo 属性が設定されていればその値を返す
      return this.getAttribute('foo');
    }
    // foo 属性が設定されていなければ _foo の値を返す
    return this._foo;
  }

  // set 属性名() でプロパティの setter を定義
  set foo(val) {
    // foo 属性の値を更新
    this.setAttribute('foo', val);
    // _foo の値を更新
    this._foo = val;
  }

  connectedCallback() {
    if(this.foo) {
      this.textContent = this.foo;
    }else{
      this.textContent = this._foo;
    }
  }
}
customElements.define('my-element', MyElement);
属性の変更の監視

カスタム要素の属性(※)が追加・削除・変更された際に呼び出される attributeChangedCallback() ライフサイクルフックを使って、属性に変更があった場合に何らかの処理を実行することができます。

※ 属性を監視対象とするには、static get observedAttributes() メソッドで監視する属性のリスト(配列)を指定する必要があります。

attributeChangedCallback() は引数に、属性名(name)と元の値(oldValue)、新しい値(newValue)を受け取ります。

以下は foo 属性を監視対象とし、値が変更された場合はコンソールに引数に渡された値を出力しています。複数の属性を監視している場合は、引数に渡される name を調べて何らかの処理をします。

static get observedAttributes() {
  //監視する属性のリスト(配列)を指定
  return ['foo'];
}

attributeChangedCallback(name, oldValue, newValue) {
  //属性(プロパティ)が変更された場合の何らかの処理
  console.log(name + ' attributes changed to:' + newValue);
}

以下は HTML で foo 属性が定義されたときや JavaScript で変更されたときに attributeChangedCallback() を利用して表示を更新する例です。

class HelloElement extends HTMLElement {
  constructor() {
    super();
  }

  // getter を定義
  get foo() {
    return this.getAttribute('foo');
  }

  // setter を定義
  set foo(val) {
    this.setAttribute('foo', val);
  }

  //監視する属性を指定
  static get observedAttributes() {
    return ['foo'];
  }

  //上記で指定した属性が変更された際に呼び出される
  attributeChangedCallback(attr, oldValue, newValue) {
    //foo 属性が変更された場合(第1引数の属性名が foo の場合)
    if(attr === 'foo') {
      //値が同じであれば何もしない
      if (oldValue === newValue) return;
      //foo 属性(プロパティ)の値を更新(この場合、getter/setter があるので省略可能)
      this[ attr ] = newValue;
      //出力を更新
      this.textContent = `Hello ${ this.foo }!`;
    }
  }

  connectedCallback() {
    if(this.foo) {
      //foo 属性の値があればその値を使って内容を更新
      this.textContent = `Hello ${ this.foo }!`;
    }else{
      //foo 属性の値がなければ「Hello Foo!」と表示
      this.textContent = 'Hello Foo!';
    }
  }
}
customElements.define('hello-element', HelloElement);

上記の場合、28行目のプロパティへの新しい値の代入は getter/setter を設定しているので省略しても同じことになります。

以下のように foo 属性を指定すると「Hello World!」と出力され、foo 属性を指定しない場合は、Hello Foo! と出力されます。

<hello-element foo="World"></hello-element>

また、JavaScript で foo 属性(プロパティ)を変更すると、表示も変更されます。

以下は、_render() というカスタム要素をレンダリングするメソッドを用意して、属性が変更された際に _render() を呼び出すように書き換えた例です。

カスタム要素が DOM に挿入されるたびに毎回呼び出される connectedCallback() では、1回だけ _render() を呼び出します。

class HelloElement extends HTMLElement {
  constructor() {
    super();
  }
  get foo() {
    return this.getAttribute('foo');
  }
  set foo(val) {
    this.setAttribute('foo', val);
  }

  //カスタム要素をレンダリングするメソッド
  _render() {
    if(this.foo) {
      this.textContent = `Hello ${ this.foo }!`;
    }else{
      this.textContent = 'Hello Foo!';
    }
  }

  //監視する属性を指定
  static get observedAttributes() {
    return ['foo'];
  }

  //上記で指定した属性が変更された際に呼び出される
  attributeChangedCallback(attr, oldValue, newValue) {
    if(attr === 'foo') {
      if (oldValue === newValue) return;
      this._render();
    }
  }

  connectedCallback() {
    //要素がページに挿入されたときは1回だけ _render() を呼び出します
    if (!this.rendered) {
      this._render();
      this.rendered = true;
    }
  }
}
customElements.define('hello-element', HelloElement);

ゲッターで初期値を設定

以下は、bg-color 属性及び color 属性が指定されていれば、それらの値をスタイルに反映するボタンのカスタム要素の例です。

getter を定義する際に、初期値を設定しています(このやり方が正しい方法かどうかはわかりません)。

また、以下の例では後述のシャドウ DOM とスロットを使用しています。

class colorButton extends HTMLElement {
  constructor() {
    super();

    //カスタム要素にシャドウ DOM を取り付ける
    this.attachShadow({ mode: "open" });

    // シャドウ DOM の文書構造を定義
    this.shadowRoot.innerHTML = `
      <style>
        button {
          border: none;
          border-radius: 4px;
          padding: 6px 12px;
          background-color: ${this.bgColor};
          color: ${this.color};
          cursor: pointer;
        }
      </style>
      <button type="button">
      <slot name="label" />
      </button>
    `;
  }

  // getter を定義
  get bgColor() {
    // 属性が指定されていてその値が空でなければ、その値を
    if (this.hasAttribute('background-color') && this.getAttribute('background-color')) {
      return this.getAttribute('background-color');
    } else {
      // 属性が指定されていなければ、初期値を返す
      return '#ccc';
    }
  }

  // getter を定義
  get color() {
    // 属性が指定されていてその値が空でなければ、その値を
    if (this.hasAttribute('color') && this.getAttribute('color')) {
      return this.getAttribute('color');
    } else {
      // 属性が指定されていなければ、初期値を返す
      return '#333';
    }
  }

  // setter を定義
  set bgColor(val) {
    this.setAttribute('background-color', val);
  }

  // setter を定義
  set color(val) {
    this.setAttribute('color', val);
  }

  //監視する属性を指定
  static get observedAttributes() {
    return ['color', 'background-color'];
  }

  //属性が変更された際に呼び出される
  attributeChangedCallback(attr, oldValue, newValue) {
    if (oldValue === newValue) return;
    // 属性(プロパティ)の値を更新(getter/setter があるので省略可能)
    this[attr] = newValue;
    //button 要素の属性の値でスタイルを更新(属性名がスタイルのプロパティ名と一致している)
    this.shadowRoot.querySelector('button').style.setProperty(attr, newValue);
  }
}
// カスタム要素を登録
customElements.define('color-button', colorButton);

例えば、以下のように記述すると、背景色が赤で、文字色が白のボタンが出力されます。

<color-button color="white" background-color="red">
  <span slot="label">Click</span>
</color-button>

属性を省略した場合は、getter で設定した初期値が適用されます。

<color-button>
  <span slot="label">Click</span>
</color-button>

JavaScript で以下のようにして、背景色や文字色を変更することが可能です。

const button = document.querySelector('color-button');

button.setAttribute('color', 'white');
button.setAttribute('background-color', 'green');

button.color = 'yellow';
button.bgColor = 'black';

以下は上記の例をプロパティに初期値を設定するように書き換えたものです。プロパティ名には先頭にアンダーバーを付けています。

class colorButton extends HTMLElement {
  constructor() {
    super();

    //プロパティの初期化
    this._bgColor = '#ccc';
    this._color = '#333';

    //カスタム要素にシャドウ DOM を取り付ける
    this.attachShadow({ mode: "open" });

    // シャドウ DOM の文書構造を定義
    this.shadowRoot.innerHTML = `
      <style>
        button {
          border: none;
          border-radius: 4px;
          padding: 6px 12px;
          background-color: ${this._bgColor};  /* プロパティの初期値 */
          color: ${this._color};  /* プロパティの初期値 */
          cursor: pointer;
        }
      </style>
      <button type="button">
      <slot name="label" />
      </button>
    `;
  }

  get bgColor() {
    return this._bgColor;
  }

  get color() {
    return this._color ;
  }

  set bgColor(val) {
    this.setAttribute('background-color', val);
    this._bgColor = val;
  }

  set color(val) {
    this.setAttribute('color', val);
    this._color = val;
  }

  //監視する属性を指定
  static get observedAttributes() {
    return ['color', 'background-color'];
  }

  //属性が変更された際に呼び出される
  attributeChangedCallback(attr, oldValue, newValue) {
    if (oldValue === newValue) return;
    // 属性(プロパティ)の値でスタイルを更新
    this.shadowRoot.querySelector('button').style.setProperty(attr, newValue);
  }
}
// カスタム要素を登録
customElements.define('color-button', colorButton);

Observing changes to attributes

要素のアップグレード

カスタム要素は customElements.define() を使って定義しますが、カスタム要素の定義と登録を一度に行う必要があるという意味ではありません。

カスタム要素は、customElements.define() によってその定義が登録される前に使用できます。

ページ上に <my-element> 要素を記述して、すぐには customElements.define('my-element', ...) を呼び出さずに、後から呼び出すことができます。

これはブラウザーが潜在的なカスタム要素を異なる方法で処理するためで、customElements.define() を呼び出して既存の要素にクラス定義を付与するプロセスを、「要素のアップグレード」と呼びます(customElement.define が呼び出されたとき、カスタム要素はアップグレードされます)。

タカスタム要素がいつ定義されるかを知るには、window.customElements.whenDefined() を使用できます。 要素が定義されると解決される Promise を返します。

customElements.whenDefined('hello-element').then(() => {
  console.log('hello-element defined');
});

CSS の :defined 擬似クラスを使って、定義されるまで要素を非表示したり、スタイルを適用することができます。

hello-element:not(:defined) {
  display: none; /* 定義されるまで非表示に */
}

hello-element:defined {
  display: block; /* 定義されたら表示 */
}

Element upgrades

Asynchronously Defining Custom Elements

カスタマイズされた組み込み要素

組み込みの(ビルトイン)クラスを継承することで、組み込みの要素を拡張したりカスタマイズすることができます。※ 2022年8月の時点では、Safari が未対応(caniuse

以下はクリックすると 'Hello!' と表示するボタンのカスタム要素の例です。

自律カスタム要素と同様、ES 2015 のクラス構文で定義しますが、汎用的な HTMLElement クラスではなく、拡張したい組み込み要素を表す HTML 要素インターフェイス(DOM インタフェース)を継承します。

この例の場合は、<button> 要素を拡張するので、HTMLButtonElement を継承しています。これにより、<button> 要素の特徴を備えた上に、定義した機能を持ちます。

また、customElements.define() での定義の登録では、第3引数に extends オプションで拡張する要素を指定します。※ 各 HTML 要素と DOM インタフェースは1対1に対応しているわけではないので(同じインタフェースを共有するさまざまなタグが存在するので)、どの HTML 要素を拡張するのかを明示的にブラウザに伝える必要があります。

// HTMLButtonElement を拡張
class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    //クリックイベントのリスナーを設定
    this.addEventListener('click', () => alert('Hello!'));
  }
}

//第3引数に具体的な要素を指定する必要があります
customElements.define('hello-button', HelloButton, {extends: 'button'});

自律カスタム要素と同様、以下のように記述することもできます。

customElements.define('hello-button',
  class extends HTMLButtonElement {
    constructor() {
      super();
      this.addEventListener('click', () => alert('Hello!'));
    }
  },
  {extends: 'button'}
);

HTML 側は customElements.define() の第3引数 extends に指定した既存の要素を使い、 is 属性に定義したカスタム要素の名前を指定します。

以下の場合、1つ目のボタンをクリックすると 'Hello!' とアラート表示します。

<button> 要素と同じスタイルや disabled 属性のようなボタンの標準の機能を持っているので、2つ目のボタンはクリックできません。

<button is="hello-button">Click</button>

<button is="hello-button" disabled>Disabled</button>

以下は、name 属性が指定されていれば、アラートで表示する内容をその値を使って変更する例です。

class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    //変数の初期化
    let helloTo = 'World'
    //クリックイベントのリスナーを設定
    this.addEventListener('click', () => alert('Hello ' + helloTo + '!'));
    // name 属性が指定されていれば、その値を helloTo に設定
    if(this.hasAttribute('name')) {
      helloTo = this.getAttribute('name')
    }
  }
}
customElements.define('hello-button', HelloButton, {extends: 'button'});

以下のように name 属性を指定したボタンをクリックすると「Hello Foo!」とアラート表示します。

<button is="hello-button" name="Foo">Click</button>

自律カスタム要素と同様、ライフサイクルフックも利用できます。

以下は attributeChangedCallback() を使って、name 属性に変更があれば、その変更を helloTo プロパティに反映させる例です。

class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    //プロパティの初期値を設定
    this.helloTo = 'World'
    this.addEventListener('click', () => alert('Hello ' + this.helloTo + '!'));
    if(this.hasAttribute('name')) {
      this.helloTo = this.getAttribute('name')
    }
  }

  //監視する属性のリスト(配列)を指定
  static get observedAttributes() {
    return ['name'];
  }

  //ライフサイクルフック
  attributeChangedCallback(name, oldValue, newValue) {
    //属性(プロパティ)が変更された場合の何らかの処理
    if(name === 'name') {
      if (oldValue === newValue) return;
      //name 属性の値が変更されたら、その値で helloTo プロパティを更新
      this.helloTo = newValue;
    }
  }

  //ライフサイクルフック
  connectedCallback() {
    if(this.hasAttribute('name')) {
      this.helloTo = this.getAttribute('name')
    }
  }
}
customElements.define('hello-button', HelloButton, {extends: 'button'});

Shadow DOM

カスタム要素だけでもコンポーネントとして機能しますが、外部からの影響を受ける(CSS やJ avaScript により修正されてしまう)可能性があります。また、同様にあるコンポーネントに定義したスタイルが、他のコンポーネントに影響を与える可能性もあります。

Shadow DOM(シャドウ DOM)は、ページの残りの部分から分離された(カプセル化された)シャドウ DOM ツリー(Shadow tree)を生成し、それを通常の DOM ツリー(Light tree)に組み込む機能です。

通常、コンポーネントを作成する場合、カスタム要素とシャドウ DOM を組み合わせて使います(カスタム要素の本体をシャドウ DOM 上に組み立てます)。

シャドウ DOM の機能を使うと、通常の DOM ツリーの要素の下に隠れた DOM ツリー(シャドウツリー)を取り付けることができます。シャドウツリーはシャドウルートから始まり、その下には普通の DOM ツリーと同様に innerHTML やその他の DOM API を使って任意の要素を追加する(シャドウ DOM の文書構造を定義して要素を組み立てる)ことができます。

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

カスタム要素などの DOM 要素にシャドウルートを追加して、通常の DOM ツリーの要素の下にシャドウツリーを取り付けることができます。

DOM ツリー(Light tree) document シャドウ ホスト シャドウルートを 取り付ける DOM 要素 シャドウ境界 シャドウツリー(Shadow tree) シャドウ ルート

シャドウ DOM はシャドウルートを境界に DOM に対してスコープを生成します(独自の id スコープを持ちます)。

シャドウルートの中からはシャドウルートの外に影響を及ぼさず、シャドウルートの外からはシャドウ DOM の中に影響を及ぼしません。

展開したツリー(レンダリング用) document シャドウ ホスト シャドウツリー 異なるスコープ(カプセル化されている)

カプセル化

シャドウ DOM(Shadow DOM)は、通常の DOM(Light DOM)とは切り離された(異なるスコープを持つ)文書ツリーで、以下のような特徴があります。

  • シャドウ DOM の要素は通常の DOM からの querySelector では取得できません(シャドウルートからは可能)。
  • シャドウ DOM 要素の id 属性と通常の DOM の id 属性は衝突しません(シャドウツリー内でのみ一意である必要があります)。
  • シャドウ DOM は独自のスタイルシートを持ち、外部の DOM からのスタイルルールは適用されません。また、外部の DOM のスタイルに影響しません。

MDN:シャドウ DOM の使用

attachShadow()

任意の要素にシャドウルートを取り付けるには Element.attachShadow() メソッドを使用します。

Element.attachShadow() メソッドは、シャドウ DOM ツリーを特定の要素に追加し、そのシャドウルートへの参照を返します(シャドウルートを生成します)。

このメソッドは mode オプションのオプションオブジェクトを引数として取り、値にはカプセル化モード(open または closed)を指定します。

意味
open シャドウルートの要素に、Element.shadowRoot を使用して、ルートの外部の JavaScript からアクセスできます。
closed 外部からシャドウ DOM にアクセスすることができません。 Element.shadowRoot は null を返します。この場合は、attachShadow() で返される参照によってのみシャドウ DOM にアクセスすることができます。

返り値

ShadowRoot オブジェクト(シャドウルート)への参照。

シャドウルートへの参照は、Element.shadowRoot プロパティでも受け取ることができます。これは attachShadow() で mode オプションが open に設定されて作成されたときに提供されます(mode オプションが closed に設定した場合は null を返します)。

このオブジェクト(シャドウルート)は要素のようなもので、innerHTML や append といった DOM メソッドを使って文書構造を構築することができます。

また、シャドウルートを持つ要素はシャドウホストと呼ばれ、シャドウルートの host プロパティ(ShadowRoot が取り付けられた DOM 要素への参照)として利用できます。

シャドウ DOM を使うと、通常の DOM ツリーから影響を受けたり、影響を与えたりすることなく要素を作成することができます。

このため、Web Components で独自の要素を作成する場合、カスタム要素を定義する際に、attachShadow() メソッドを使用して、シャドウ DOM をカスタム要素に取り付け、そのシャドウ DOM ツリーに文書構造やスタイルの定義を行っていくことで、再利用しやすいコンポーネントを作成できます。

以下はシャドウ DOM を使ったカスタム要素 <hello-greeting> の例です。

シャドウ DOM をカスタム要素で使用するには、定義の際にコンストラクターの中で this.attachShadow() を使ってカスタム要素(this)にシャドウ DOM を取り付けます。

カスタム要素にシャドウ DOM を取り付けただけでは、要素は空なので、シャドウルート(シャドウ DOM)に innerHTML や append などの DOM メソッドを使って文書構造を構築します。

以下では、this.attachShadow() の返り値(シャドウルートへの参照)を変数に入れていますが、this.shadowRoot でシャドウルートを参照することもできます。

class HelloGreeting extends HTMLElement {
  //コンストラクター
  constructor() {
    //super を最初に呼び出す
    super();
    //カスタム要素 <hello-greeting> に空のシャドウ DOM を取り付ける
    // shadow はシャドウルートへの参照
    const shadow = this.attachShadow({mode: 'open'});

    //変数 helloTo の初期化
    let helloTo = 'World';
    // name 属性が設定されていれば
    if(this.hasAttribute('name')) {
      // その値を変数 helloTo に代入
      helloTo = this.getAttribute('name');
    }

    //シャドウルートにシャドウ DOM の文書構造を定義
    shadow.innerHTML = `
    <style>
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    </style>
    <p class="hello">
      Hello, ${helloTo}
    </p>`;
  }
};
//カスタム要素を登録
customElements.define('hello-greeting', HelloGreeting);

以下はライフサイクルフックの connectedCallback() を使って、その中で属性の値を取得してシャドウルートから p 要素をクエリして、出力するテキストを設定するように書き換えた例です。

ライフサイクルフック内ではシャドウルートに this.shadowRoot でアクセスできます(コンストラクター内で定義した shadow にはアクセスできない)。

class HelloGreeting extends HTMLElement {
  //コンストラクター
  constructor() {
    //super を最初に呼び出す
    super();
    //カスタム要素 <hello-greeting> に空のシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});

    //シャドウルートにシャドウ DOM の文書構造を定義
    shadow.innerHTML = `
    <style>
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    </style>
    <p class="hello"></p>`;
  }

  //カスタム要素が DOM に挿入されるたびに毎回呼び出されるライフサイクルフック
  connectedCallback() {
    //変数 helloTo の初期化
    let helloTo = 'World';
    // name 属性が設定されていれば
    if(this.hasAttribute('name')) {
      // その値を変数 helloTo に代入
      helloTo = this.getAttribute('name');
    }
    //シャドウ DOM に追加した p 要素にテキストを設定
    this.shadowRoot.querySelector('p.hello').textContent = ` Hello, ${helloTo}`;
  }
};
//カスタム要素を登録
customElements.define('hello-greeting', HelloGreeting);

以下は innerHTML の代わりに createElement() などの DOM の API を使って書き換えた例です。createElement() で要素を生成し、生成した要素を appendChild() でシャドウルートに追加しています。

class HelloGreeting extends HTMLElement {
  //コンストラクター
  constructor() {
    super();
    //カスタム要素 <hello-greeting> に空のシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});

    //変数 helloTo の初期化
    let helloTo = 'World';
    // name 属性が設定されていれば
    if(this.hasAttribute('name')) {
      // その値を変数 helloTo に代入
      helloTo = this.getAttribute('name');
    }

    // p 要素の生成
    const p = document.createElement('p');
    // p 要素の class 属性を設定
    p.setAttribute('class', 'hello');
    // p 要素のテキストの生成
    p.textContent = `Hello, ${helloTo}`;
    // style 要素(CSS)の生成
    const style = document.createElement('style');
    // CSS を生成
    style.textContent = `
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    `;
    // 生成した要素をシャドウルート(シャドウ DOM)に追加(シャドウ DOM 構造を作成)
    shadow.appendChild(style);
    shadow.appendChild(p);
  }
};
//カスタム要素を登録
customElements.define('hello-greeting', HelloGreeting);

以下は上記をライフサイクルフック connectedCallback() を使って書き換えた例です。

class HelloGreeting extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    const p = document.createElement('p');
    p.setAttribute('class', 'hello');
    const style = document.createElement('style');
    style.textContent = `
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    `;
    shadow.appendChild(style);
    shadow.appendChild(p);
  }

  connectedCallback() {
    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      helloTo = this.getAttribute('name');
    }
    this.shadowRoot.querySelector('p.hello').textContent = ` Hello, ${helloTo}`;
  }
};
customElements.define('hello-greeting', HelloGreeting);

上記のいずれかを記述して、以下をページに配置すると「Hello, Foo」と出力され、CSS で設定したフォントサイズや色などが適用されます。但し、シャドウ DOM で設定した CSS はページの他の p 要素には影響しません(また、ページの CSS の影響も受けません)。

<hello-greeting name="Foo"></hello-greeting>

Chrome の開発者ツールで確認すると、カスタム要素のすべてのコンテンツは #shadow-root の下にあります。

レンダリング用メソッドで Shadow DOM の文書構造を定義

コンストラクタの中ではカスタム要素に空のシャドウ DOM を取り付けて、別途定義したレンダリング用メソッドの中で Shadow DOM の文書構造を定義することもできます。

以下の例では、カスタム要素が DOM に挿入される際に呼び出されるライフサイクルメソッド connectedCallback() で1回だけレンダリング用メソッドを呼び出していますが、attributeChangedCallback() で呼び出すなど、いろいろな方法があるかと思います。

class HelloGreeting extends HTMLElement {
  //コンストラクター
  constructor() {
    //super を最初に呼び出す
    super();
    //カスタム要素 <hello-greeting> に空のシャドウ DOM を取り付ける
    this.attachShadow({mode: 'open'});
  }

  //レンダリング用メソッド
  _render() {
    //変数 helloTo の初期化
    let helloTo = 'World';
    // name 属性が設定されていれば
    if(this.hasAttribute('name')) {
      // その値を変数 helloTo に代入
      helloTo = this.getAttribute('name');
    }

    //シャドウルートにシャドウ DOM の文書構造を定義
    this.shadowRoot.innerHTML = `
      <style>
        p {
          font-size: 18px;
          font-weight: bold;
          color: green;
        }
      </style>
      <p class="hello">
        Hello, ${helloTo}
      </p>
    `;
  }

  //カスタム要素が DOM に挿入される際に呼び出されるライフサイクルメソッド
  connectedCallback() {
    if (!this.rendered) {
      //レンダリング用メソッドを呼び出す
      this._render();
      this.rendered = true;
    }
  }
};
//カスタム要素を登録
customElements.define('hello-greeting', HelloGreeting);

シャドウツリー内で要素を取得

シャドウツリー内で要素を取得するには、ツリーの内側からクエリーを行う必要があります。

以下の場合、document.querySelectorAll('p.hello') ではカスタム要素内の p 要素を取得できませんが、this.shadowRoot.querySelectorAll('p.hello') で取得することができます。

class HelloGreeting extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      helloTo = this.getAttribute('name');
    }
    shadow.innerHTML = `
    <style>
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    </style>
    <p class="hello">
      Hello, ${helloTo}
    </p>`;
  }
  connectedCallback() {
    //document からはシャドウツリー内の要素を取得できない
    console.log(document.querySelectorAll('p.hello').length);  //0
    //シャドウルートからはシャドウツリー内の要素を取得できる
    console.log(this.shadowRoot.querySelectorAll('p.hello').length);  //1
  }
};
customElements.define('hello-greeting', HelloGreeting);

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

前述の例では <style> 要素を生成してシャドウ DOM にスタイルを適用しましたが、代わりに <link> 要素を使用して外部スタイルシートを参照することもできます。

但し、<link> 要素はシャドウルートの描画をブロックしないので、スタイルシートのロード中にスタイル付けされていないコンテンツが一瞬表示される可能性があります。また、外部スタイルシートはキャッシュされます。

class HelloGreeting extends HTMLElement {
  constructor() {
    super();

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

    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      helloTo = this.getAttribute('name');
    }

    const p = document.createElement('p');
    p.setAttribute('class', 'hello');
    p.textContent = `Hello, ${helloTo}`;

    // link 要素を生成し、外部のスタイルをシャドウ DOM に適用
    const linkElem = document.createElement('link');
    // link 要素に rel 属性を設定
    linkElem.setAttribute('rel', 'stylesheet');
    // link 要素に href 属性を設定
    linkElem.setAttribute('href', 'style.css')
    ;
    // 生成した要素をシャドウ DOM に追加
    shadow.appendChild(linkElem);
    shadow.appendChild(p);
  }
};
customElements.define('hello-greeting', HelloGreeting);

以下は innerHTML を使う場合の例です。

class HelloGreeting extends HTMLElement {
  constructor() {
    super();

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

    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      helloTo = this.getAttribute('name');
    }

    // link 要素を使って外部スタイルシートを参照
    shadow.innerHTML = `
    <link rel="stylesheet" href="style.css">
    <p class="hello">
      Hello, ${helloTo}
    </p>
    `;
  }
};
customElements.define('hello-greeting', HelloGreeting);
style.css(外部スタイルシート)
p {
  font-size: 18px;
  font-weight: bold;
  color: red;
}

Chrome の開発者ツールで確認すると以下のように表示されます。

属性の値からスタイルを設定

カスタム要素に独自の属性を指定できるようにして、その値を使ってスタイルを設定する例です。

web-components-exampleslife-cycle-callbacks を参考にさせていただきました。

class Rect extends HTMLElement {

  // 監視する属性を指定
  static get observedAttributes() {
    return ['w', 'h', 'c'];
  }

  constructor() {
    // コンストラクタでは super() を最初に呼び出す
    super();
    //カスタム要素に空のシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});
    //div 要素を生成
    const div = document.createElement('div');
    //style 要素を生成
    const style = document.createElement('style');
    //生成した要素をシャドウ DOM に追加
    shadow.appendChild(style);
    shadow.appendChild(div);
  }

  //属性からスタイルを更新するメソッド
  updateStyle() {
    //シャドウルート
    const shadow = this.shadowRoot;
    //属性が指定されていれば、その値を使い、指定されていなければデフォルトを使う
    const width = this.getAttribute('w') || '200';
    const height = this.getAttribute('h') || '100';
    const color = this.getAttribute('c') || 'green';
    //シャドウ DOM に追加した style 要素の内容を更新
    shadow.querySelector('style').textContent = `
      div {
        width: ${width}px;
        height: ${height}px;
        background-color: ${color};
      }
    `;
  }

  connectedCallback() {
    console.log('カスタム要素がページに追加されました');
    this.updateStyle();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log('カスタム要素の属性が変更されました');
    this.updateStyle();
  }
}
customElements.define('custom-rect', Rect);

以下を記述すると、背景色がピンクで幅100px、高さ70pxの長方形が表示されます。それぞれの属性で、背景色、幅、高さを調整することができ、属性を指定しない場合はデフォルト値が適用されます。

<custom-rect c="pink" w="100" h="70"></custom-rect>

Shadow DOM のスタイリング

一般的には、シャドウ DOM 内で定義したローカルスタイルはシャドウツリーの中でのみ作用し、ドキュメントスタイルはそのシャドウツリーの外側で利用できます。

シャドウ DOM 内の要素のスタイリングはシャドウ DOM 内で定義しますが、シャドウ DOM 内から外側のシャドウホストをスタイリングできる :host のような特別な CSS セレクタがあります。

また、カスタム要素には以下のような特徴があります。

カスタム要素は display: inline

カスタム要素はデフォルトでは display: inline になっています。

contain: content(パフォーマンスの向上)

通常、Web コンポーネントのレイアウト/スタイル/ペイントは文書ツリーの他の部分から独立しているので、:host で CSS contain を使用することで、パフォーマンスを向上させることができます。

:host {
  display: block; /* カスタム要素はデフォルトで display: inline */
  contain: content; /* パフォーマンスを向上 */
}

Shadow DOM v1 / Styling

:host 擬似クラス

:host は CSS の 擬似クラスで、シャドウ DOM の中から外側のカスタム要素(シャドウホスト)を選択します。このセレクタはシャドウ DOM の外で使われたときには効果がありません。

以下の場合、<my-element> 要素自身(シャドウホスト)をスタイリングします(すべての要素の文字色が緑色になります)

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();

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

    shadow.innerHTML = `
    <style>
      :host {
        color: green;
      }
    </style>
    <div class="foo">
      <h3>Foo</h3>
      <p class="desc">Lorem ipsum dolor sit amet.</p>
      <p>Porro velit nulla dolores cupiditate aspernatur</p>
    </div>
    `;
  }
});

シャドウホスト(上記の場合 <my-element>)は通常の DOM(Light DOM)の中にあるので、CSS ルールに影響されます。 :host とドキュメントの両方にスタイルされたプロパティがある場合は、ドキュメントのスタイルが優先されます。

例えば、ドキュメント内に以下が宣言されている場合、<my-element>のすべての要素の文字色が赤色になります。

<style>
  my-element {
    color: red;
  }
</style>

:host を使って定義したルールより、親ページ(ドキュメント)で定義したルールの方が詳細度が高くいため、:host を使ってコンポーネントのデフォルトのスタイルを設定して、ドキュメント内の CSS で(ユーザー側で)上書きできすることができます。

但し、シャドウ DOM 内で定義したローカルスタイルで !important が指定されている場合はローカルスタイルが優先されます。

:host(selector)

:host() は、シャドウホストがカッコ内に指定された selector にマッチする場合にのみ適用されるセレクタ(擬似クラス関数)です。

このセレクタは :host 同様、シャドウ DOM の外で使われたときには効果がありません。

以下の場合、シャドウホストであるカスタム要素 <my-element> がカッコ内に指定された .bar や [disabled] にマッチする場合、スタイルが適用されます。

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();

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

    shadow.innerHTML = `
    <style>
      :host {
        color: green;
      }
      :host(.bar) {
        color: orange;
      }
      :host([disabled]) {
        color: #ccc;
      }
    </style>
    <div class="foo">
      <h3>Foo</h3>
      <p class="desc">Lorem ipsum dolor sit amet.</p>
      <p>Porro velit nulla dolores cupiditate aspernatur</p>
    </div>
    `;
  }
});

上記の場合、以下のようなカスタム要素を記述すると、最初の要素の文字色は緑、bar クラスを指定した要素の文字色はオレンジ、disabled 属性を指定した要素の文字色は薄いグレーになります。

<my-element></my-element>
<my-element class="bar"></my-element>
<my-element disabled></my-element>
:host-context(selector)

:host-context() は、カッコ内に指定された selector がシャドウホストの祖先に一致した場合にのみ適用されるセレクタ(擬似クラス関数)です。

これを利用すると、カスタム要素やそのシャドウ DOM 内の要素は、外部 DOM 内の位置や、祖先要素に適用されたクラスや属性に基づいて、異なるスタイルを適用することができます。

以下の場合、親要素に .light が指定されていれば、シャドウホストの文字色は #666 になり、.dark が指定されていれば、シャドウホストの文字色は #eee になります。

また、親要素に aside 要素があれば、枠線とパディングが適用されます。

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();

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

    shadow.innerHTML = `
    <style>
      :host {
        color: green;
      }
      :host-context(.light)  {
        color: #666;
      }
      :host-context(.dark)  {
        color: #eee;
      }
      :host-context(aside) {
        display: block;
        border: 1px solid #999;
        padding: 1rem;
      }
    </style>
    <div class="foo">
      <h3>Foo</h3>
      <p class="desc">Lorem ipsum dolor sit amet.</p>
      <p>Porro velit nulla dolores cupiditate aspernatur</p>
    </div>
    `;
  }
});
<style>
  /*  ドキュメントのスタイル  */
  .light {
    background-color: #efefef;
  }
  .dark {
    background-color: #333;
  }
</style>

<div class="light">
  <my-element></my-element> <!-- 文字色は #666 -->
</div>

<div class="dark">
  <my-element></my-element> <!-- 文字色は #eee -->
</div>

<aside>
  <my-element></my-element> <!-- 文字色は green、枠線とパディングが適用される -->
</aside>
CSS 変数を使ったスタイルフック

CSS カスタムプロパティ(CSS 変数)を使用してスタイリングフックを提供することで、ユーザーで内部スタイルを調整(上書き)することができます。

シャドウ DOM やテンプレートで、var() 関数を使って CSS カスタムプロパティを受け取れるようにし、その際に第2引数のフォールバックにデフォルト値を指定します。

これにより、ユーザー側で何も指定しない(CSS 変数を定義しない)場合は、第2引数に指定したデフォルト値が適用されます。

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    shadow.innerHTML = `
    <style>
      :host {
        display: block;
        background: var(--my-elem-bg, #9E9E9E); /* CSS 変数を受け取れるように */
        color: var(--my-elem-color, azure);; /* CSS 変数を受け取れるように */
        border-radius: 10px;
        padding: 10px;
      }
    </style>
    <div class="foo">
      <h3>Foo</h3>
      <p class="desc">Lorem ipsum dolor sit amet.</p>
      <p>Porro velit nulla dolores cupiditate aspernatur</p>
    </div>
    `;
  }
});

以下の場合、dark クラスを指定したカスタム要素 my-element は CSS 変数を宣言することで、上記で指定したスタイル(フォールバックのデフォルト値)を上書きします。

<style>
  /*  ドキュメントのスタイル  */
  my-element.dark {
    --my-elem-bg: black;  /* CSS 変数を宣言(定義) */
    --my-elem-color : yellow;  /* CSS 変数を宣言(定義) */
  }
</style>

<div>
  <my-element></my-element>
  <!-- 文字色は azure で背景色は #9E9E9E (デフォルト)-->
</div>

<div>
  <my-element class="dark"></my-element>
  <!-- 文字色は yellow で背景色は black-->
</div>

上記の場合は、クラスを利用しましたが、以下のように属性を利用するなど様々な方法が考えられます。

 /* シャドウ DOM でのスタイル */
:host([background]) {
  background: var(--my-elem-bg: #9E9E9E);
  border-radius: 10px;
  padding: 10px;
}

この例では、:host([background]) により、カスタム要素(シャドウホスト)に background 属性を指定すると以下の背景色が適用されます。

<!-- ドキュメントのページ -->
<style>
  my-element {
    margin-bottom: 32px;
    --my-elem-bg: black;
  }
</style>

<my-element background>...</my-element><!-- background 属性を指定 -->

Creating style hooks using CSS custom properties

slot 要素

スロット(slot 要素)は、ユーザーが独自のマークアップ(コンテンツ)を入力できるコンポーネント内のプレースホルダーです。Light DOM のコンテンツを Shadow DOM に挿入することができます(利用する側でコンテンツをスロットの位置へ挿入することができます)。

名前付きスロット

名前付きスロットでは、シャドウ DOM の slot 要素の name 属性と Light DOM の slot 属性が一致した際に、その Light DOM が挿入されてレンダリングされます(Light DOM のコンテンツを自動的に挿入してくれます)。

以下のカスタム要素 <user-info> は Light DOM を挿入できる2つのスロット(slot 要素)を提供します。

Shadow DOM では、<slot name="xxxx"> で挿入位置(Light DOM での slot="xxxx" を持つ要素がレンダリングされる場所)を定義します。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    //Light DOM のコンテンツを挿入する位置に slot 要素を配置して name 属性を指定
    this.shadowRoot.innerHTML = `
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});

カスタム要素 <user-info> の中に slot 属性の値が埋めたいスロットの名前と同じになる要素を記述します。

<user-info>
  <span slot="username">Foo</span>
  <span slot="birthday">1989.03.21</span>
</user-info>

Chrome の開発者ツールで確認すると以下のように表示されます。

ブラウザは Light DOM から要素を取得し、Shadow DOM の対応するスロットにレンダリングしていきます。

結果は Flattened DOM tree(フラット化された DOM ツリー)と呼ばれます。フラット化された DOM ツリーはレンダリングとイベント処理の目的でのみ存在し、どのように見えるかを示していますが、ドキュメント内のノードは実際には移動していません。

同じスロット名を持つ複数の要素

Light DOM に同じスロット名を持つ複数の要素がある場合、それらは順々にスロットに追加されます。

例えば以下の場合、<slot name="username"> に3つの要素を持つフラット化された DOM ツリーになります(この例では p 要素を挿入しています)。

<user-info>
  <p slot="username">Foo</p>
  <p slot="username">Bar</p>
  <p slot="username">Baz</p>
</user-info>

開発者ツールで確認すると以下のように表示されます。

シャドウホストの直接の子だけが slot 属性を持つことができる

slot 属性はシャドウホスト(上記の場合、カスタム要素 <user-info>)の直接の子に対してのみ有効で、ネストされた要素に対しては無視されます。

以下の場合、2つ目の span 要素は無視されます。

<user-info>
  <span slot="username">Foo</span>
  <div>
    <!-- user-info の直接の子でないため、無効なスロット -->
    <span slot="birthday">1989.03.21</span>
  </div>
</user-info>

以下はタイトルと複数のアイテムを持つメニューのカスタム要素(コンポーネント)の例です。

タイトルとアイテムは名前付きスロットを使ってユーザー側でマークアップを挿入します(::slotted() はスロットされた要素を選択するセレクタです)。

customElements.define('custom-menu', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
    <style>
      ul {
        margin: 10px 0 0;
        list-style: none;
        padding-left: 20px;
      }
      .closed ul {
        display: none;
      }
      ::slotted([slot="title"]) {
        font-weight: bold;
        cursor: pointer;
        border: 1px solid #999;
        padding: 5px 10px;
      }
    </style>
    <div class="menu">
      <slot name="title"></slot>
      <ul>
        <slot name="item"></slot>
      </ul>
    </div>
    `;
    //name="title" のスロット(シャドウルートからクエリ)をクリックした際の処理
    this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
      //メニュー(ul 要素)の表示・非表示をトグル
      this.shadowRoot.querySelector('.menu').classList.toggle('closed');
    };
  }
});

以下はカスタム要素 <custom-menu> のマークアップの例です。

<span slot="title"> は <slot name="title"> に挿入されます。

<li slot="item"> は <slot name="item"> に順に挿入され、リストを形成します(同じスロット名を持つ複数の要素がある場合、それらは順々にスロットに追加されます)。

<custom-menu>
  <span slot="title">Menu</span>
  <li slot="item">Item 1</li>
  <li slot="item">Item 2</li>
</custom-menu>

開発者ツールで確認すると以下のように表示されます。

有効な DOM では <li> は <ul> の直接の子でなければならないので、Nu Html Checker で検証すると「Element li not allowed as child of element custom-menu in this context. 」のようなエラーになりますが、問題なく表示されます。

以下はシャドウルートを展開して表示したものです。

上記の例の場合、メニューのアイテムを li 要素で挿入するようにしましたが、以下は ul 要素と li 要素で挿入するように変更したものです。

また、それに伴い、ul 要素の表示・非表示をトグルするためのスタイルも変更しています。

この場合、 <li> は <ul> の直接の子として挿入しているので、有効な DOM になり、Nu Html Checker で検証してもエラーにはなりません。

customElements.define('custom-menu', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
    <style>
      ::slotted([slot="item"]) {
        list-style: none;
      }
      .closed ::slotted([slot="item"])  {
        display: none;
      }
      ::slotted([slot="title"]) {
        font-weight: bold;
        cursor: pointer;
        border: 1px solid #999;
        padding: 5px 10px;
        margin-bottom: 10px;
        display: inline-block;
      }
    </style>
    <div class="menu">
      <slot name="title"></slot>
      <slot name="item"></slot>
    </div>
    `;
    //name="title" のスロット(シャドウルートからクエリ)をクリックした際の処理
    this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
      //メニュー(ul 要素)の表示・非表示をトグル
      this.shadowRoot.querySelector('.menu').classList.toggle('closed');
    };
  }
});
<custom-menu>
  <span slot="title">Menu</span>
  <ul slot="item">
    <li>Item 1</li>
    <li>Item 2</li>
  </ul>
</custom-menu>

開発者ツールで確認すると以下のように表示されます。

フォールバックコンテンツ

<slot> 要素の中にフォールバックとして、デフォルトのコンテンツを記述しておくことができます。

ブラウザは Light DOM に対応するスロットがない場合、デフォルトのコンテンツを表示します。

以下の場合、Light DOM に slot="username" がなければ、Anonymous を、slot="birthday" がなければ、N/A をレンダリングします。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    this.shadowRoot.innerHTML = `
      <div>Name:
        <slot name="username">Anonymous</slot>
      </div>
      <div>Birthday:
        <slot name="birthday">N/A</slot>
      </div>
    `;
  }
});

デフォルトスロット

Shadow DOM 内の、名前を持たない最初の <slot> 要素がデフォルトスロットになります。

デフォルトスロットには、カスタム要素(Light DOM)のトップレベルの子ノードのうち slot 属性を持たないすべてのノードが取得されて入ります。これにはテキストノードも含まれます。

以下は Light DOM で slot 属性を持たない要素を表示するデフォルトスロットを追加した例です。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    this.shadowRoot.innerHTML = `
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
      <div>Other information:
        <slot></slot>
      </div>
    `;
  }
});

以下の場合、すべての slot 属性を持たない Light DOM のコンテンツは Other information: の div 要素に入ります。要素はスロットに次々に追加されるので、スロットなしの(slot 属性を持たない)コンテンツは両方ともデフォルトスロットになります。

<user-info>
  <p>something...</p>
  <span slot="username">Foo</span>
  <span slot="birthday">1989.03.21</span>
  <div>something else ...</div>
</user-info>

開発者ツールで確認すると以下のように表示されます。

スロットコンテンツのスタイリング

スロットされた要素は light DOM に由来するので、これらの要素はドキュメントスタイルを使用します。Shadow DOM で定義されたローカルスタイルはスロットされたコンテンツに適用されません。

以下はローカルスタイルで span 要素の文字色を指定しています。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    this.shadowRoot.innerHTML = `
      <style>
        span { color: red; }
      </style>
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});

以下の例の場合、ドキュメントスタイルによりスロットされた span 要素は font-weight: bold が適用されますが、ローカルスタイルの color: red は適用されません。

<style>
  /* ドキュメントのスタイル */
  span { font-weight: bold }
</style>

<user-info>
  <span slot="username">Foo</span>
  <span slot="birthday">1989.03.21</span>
</user-info>

コンポーネント内でスロットされた要素をスタイリングしたい場合は、<slot> 要素自体をスタイリングするか、::slotted() 擬似要素を使用します。

以下は、<slot> 要素自体にスタイルを設定しているので、スロットされた span 要素は font-weight: bold と color: red が適用されます。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    this.shadowRoot.innerHTML = `
      <style>
        slot {
          font-weight: bold;
          color: red;
        }
      </style>
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});
::slotted() 擬似クラス

::slotted() 擬似クラスを使うと、スロットされた要素を選択してスタイルを適用することができます(要素がスロットに挿入されたとき、slotted と呼ばれます)。

このセレクタは :host などと同様、シャドウ DOM 内の CSS の中で使われた時のみ機能します。

/* スロット内に配置された全ての要素を選択 */
::slotted(*) {
  font-weight: bold;
}

/* スロット内に配置された span 要素を選択 */
::slotted(span) {
  font-weight: bold;
}

以下の場合、スロット内に配置された全ての要素に font-weight: bold が適用され、.foo の要素に color: red が適用されます。

customElements.define('user-info', class extends HTMLElement {
  constructor() {
    super();

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

    this.shadowRoot.innerHTML = `
      <style>
        ::slotted(*) {
          font-weight: bold;
        }
        ::slotted(.foo) {
          color: red;
        }
      </style>
      <div>Name:
        <slot name="username"></slot>
      </div>
      <div>Birthday:
        <slot name="birthday"></slot>
      </div>
    `;
  }
});
<user-info>
  <div slot="username" class="foo">Foo</div>
  <div slot="birthday">1989.03.<span class="date">21</span></div>
</user-info>

::slotted() セレクタはスロット内でトップレベルのノードのみ選択することが可能です。

以下の場合、::slotted(div .date) はトップレベルのノードではないので選択されず、スタイルは適用されません。

::slotted(*) {
  font-weight: bold;
}
::slotted(.foo) {
  color: red;
}
/* 以下は適用されません */
::slotted(div .date) {
  color: blue;
}

slotchange イベント

slotchange イベントは、ユーザーが light DOM から子を追加/削除した場合など、スロットのノードが変更されると発生します(スロットに入っているノードの子ノードが変更された場合、 slotchange イベントは発生しません)。

以下は、カスタム要素 <custom-menu> に setTimeout() を使って、1秒後にスロットのメニューアイテムを挿入し、2秒後にスロットのメニュータイトルの内容を変更する例です。

shadowRoot はイベントハンドラを持たないため、slotchange のリスナーは div 要素に設定しています。

customElements.define('custom-menu', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
    <div class="menu">
      <slot name="title"></slot>
      <ul>
        <slot name="item"></slot>
      </ul>
    </div>
    `;
    // class="menu" の div 要素に slotchange イベントのリスナを設定
    this.shadowRoot.querySelector('div.menu').addEventListener('slotchange',
      e => console.log("slotchange: " + e.target.name)
    );
  }
});

//カスタム要素を取得
const menu = document.getElementById('c-menu');

//1秒後にメニューアイテム(スロット)を追加
setTimeout(() => {
  const item = document.createElement('li');
  item.setAttribute('slot', 'item');
  item.textContent = 'Item 3';
  menu.appendChild(item);
  console.log('1秒後: メニューアイテムを追加')
}, 1000);

//2秒後にメニュータイトル(スロット)の内容を変更(slotchange イベントは発火しない)
setTimeout(() => {
  menu.querySelector('[slot="title"]').innerHTML = "New Menu";
  console.log('2秒後: メニュータイトルを変更');
}, 2000);
<custom-menu id="c-menu">
  <span slot="title">Menu</span>
  <li slot="item">Item 1</li>
  <li slot="item">Item 2</li>
</custom-menu>

コンソールには以下が出力されます。

light DOM からの slot="title" と slot="item" が対応するスロットに入ると、slotchange: title と slotchange: item が直ちに(初期化の際に)発生します。

そして、1秒後に slot="item" が追加されたときに slotchange: item が発生します。

2秒後に slot="title" の内容が変更されたとき slotchange イベントは発生しません。スロット要素内のコンテンツの変更では、slotchange イベントは発生しません(スロットの子ノードが変更された場合、 slotchange イベントは発生しません)。

slotchange: title  //初期化の際に発生
slotchange: item   //初期化の際に発生
1秒後: メニューアイテムを追加
slotchange: item  //スロットを追加時に発生
2秒後: メニュータイトルを変更

light DOM のその他の種類の変更(light DOM の内部の変更)を監視するには、(要素のコンストラクターで)MutationObserver を利用します。

Slot API

Shadow DOM API は、スロットとノードを操作するための以下のようなメソッドを提供します。

HTMLSlotElement.assignedNodes()

assignedNodes()HTMLSlotElement インターフェイスのプロパティで、スロットへ割り当てられている DOM ノード(配列)を返します。

オプションで {flatten: true} を指定すると、利用可能な子の <slot> 要素すべてに割り当てられたノード(ネストされたコンポーネントの場合はネストされたスロット)を返し、ノードが割り当てられていない場合にはフォールバックコンテンツを返します。

flatten オプションはデフォルト(省略時)は false です。

以下は、<slot> 要素に割り当てられたノードを取得して、その HTML をコンソールに出力する例です。

customElements.define('custom-menu', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="menu">
        <slot name="title"></slot>
        <ul>
          <slot name="item"></slot>
        </ul>
      </div>
    `;

    // slot 要素を全て取得
    const slots = this.shadowRoot.querySelectorAll('slot');

    // それぞれの slot 要素を調べる
    slots.forEach(slot => {
      // スロットの名前を出力
      console.log('slot name: ' + slot.name);
      // スロットへ割り当てられている DOM ノード(配列)を取得
      const nodes = slot.assignedNodes();
      // それぞれの DOM ノードを調べる
      nodes.forEach(node => {
        // DOM ノードの HTML を出力
        console.log( node.outerHTML);
      })
    })
  }
});
<custom-menu id="c-menu">
  <span slot="title">Menu</span>
  <li slot="item">Item 1</li>
  <li slot="item">Item 2</li>
  <li slot="item">Item 3</li>
</custom-menu>

上記の場合、コンソールには以下のように出力されます。

slot name: title
<span slot="title">Menu</span>
slot name: item
<li slot="item">Item 1</li>
<li slot="item">Item 2</li>
<li slot="item">Item 3</li>

以下は slotchange イベントで、スロットのノードが変更される際に、そのスロットの名前が item であれば、スロットへ割り当てられている DOM ノードを取得して出力する例です。

customElements.define('custom-menu', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="menu">
        <slot name="title"></slot>
        <ul>
          <slot name="item"></slot>
        </ul>
      </div>
    `;

    // class="menu" の div 要素にリスナを設定
    this.shadowRoot.querySelector('div.menu').addEventListener('slotchange', e => {
      //変更のあった(slotchange イベントを発生した)スロットを取得
      let slot = e.target;
      // スロットの名前が item であれば
      if (slot.name == 'item') {
        // スロットへ割り当てられている DOM ノード(配列)を取得
        const nodes = slot.assignedNodes();
        // それぞれの DOM ノードを調べる
        nodes.forEach(node => {
          // DOM ノードのテキストを出力
          console.log( node.textContent);
        })
      }
    });
  }
});

const menu = document.getElementById('c-menu');
//1秒後にメニューアイテムを追加
setTimeout(() => {
  const item = document.createElement('li');
  item.setAttribute('slot', 'item');
  item.textContent = 'Item 4';
  menu.appendChild(item);
  console.log('1秒後: メニューアイテムを追加')
}, 1000);

上記の場合、コンソールには以下のように出力されます。

Item 1  //初期化時
Item 2  //初期化時
Item 3  //初期化時
1秒後: メニューアイテムを追加
Item 1
Item 2
Item 3
Item 4

HTMLSlotElement.assignedElements()

assignedElements() はスロットへ割り当てられている DOM 要素(配列)を返します。(要素ノードに対して前項の assignedNodes() と同じ動作をします)。

Element.assignedSlot

assignedSlotElement インターフェイスの読み取り専用プロパティで、node が割り当てられている <slot> 要素(HTMLSlotElement)を返します。

<custom-menu id="c-menu">
  <span slot="title">Menu</span>
  <li slot="item">Item 1</li>
  <li slot="item">Item 2</li>
  <li slot="item">Item 3</li>
</custom-menu>

<script>
  customElements.define('custom-menu', class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.innerHTML = `
        <div class="menu">
          <slot name="title"></slot>
          <ul>
            <slot name="item"></slot>
          </ul>
        </div>
      `;
    }
  });

  const slottedSpan = document.querySelector('custom-menu span')
  console.log(slottedSpan.assignedSlot);
  //出力 <slot name="title"></slot>

  const slottedLi0 = document.querySelectorAll('custom-menu li')[0];
  console.log(slottedLi0.assignedSlot);
  //出力 <slot name="item"></slot>

</script>

template 要素

<template> 要素は テンプレートとして使いたい HTML マークアップを定義するための要素で、定義したマークアップを再利用することができます。

template 要素は HTML マークアップテンプレートの格納場所として機能し、HTML 上に見えない(ページの読み込み時にすぐには描画されない)要素を作成します。

<template> タグで囲われたマークアップは DocumentFragment となり、ブラウザはこれらのコンテンツを無視します(解析のみ行います)が、後で実行時に JavaScript でアクセスし、アクティブ化できます。

テンプレートはそれ自体でも使用できますが、ウェブコンポーネント(カスタム要素とシャドウ DOM)と組み合わせるとさらに効果的です。

テンプレートをシャドウ DOM の内容としてカスタム要素を定義する(テンプレートのコンテンツをシャドウ DOM に追加する)ことで、テンプレート内のスタイルはカスタム要素内にカプセル化されます。

また、template 要素を使用する方法の場合、テンプレートのコンテンツが 1 回だけ解析されるため、HTML 解析コストを削減します(shadowRoot で innerHTML を呼び出すと、インスタンスごとに HTML が解析されます)。

template 要素の特徴

通常は適切な囲みタグを必要とする場合でも template 内のコンテンツは有効な HTML になります。

例えば、有効な DOM ではテーブルの行 <tr> は <table> 内に配置する必要がありますが、<template> 要素では以下のように <tr> を置くことができます。

<template>
  <tr>
    <td>Foo</td>
  </tr>
</template>

また、スタイルやスクリプトを <template> 要素に入れることもできます。

<template>
  <style>
    p { color: green; }
  </style>
  <script>
    console.log("Hello");
  </script>
</template>

ブラウザは <template> のコンテンツを「ドキュメント外」とみなすため、コンテンツにあるスタイルは適用されず、スクリプトも実行されません。

<template> のコンテンツは JavaScript で content プロパティ(template.content)としてアクセスし、クローンすることで新しいコンポーネントで再利用できます。

コンテンツはドキュメントに挿入されたときにアクティブになります(スタイルが適用され、スクリプトが実行される等)。

以下はとても単純なテンプレートの例です。

テンプレートは<template>タグで定義し、id 属性を付与してコンポーネントクラス内で参照できるようにするのが一般的です。この例では、p 要素で「My paragraph」と表示し、コンソールに「hello from My paragraph!」と出力します。

<template id="my-paragraph">
  <style>
    p {
      color: white;
      background-color: #666;
      padding: 5px;
    }
  </style>
  <p>My paragraph</p>
  <script>
    console.log('hello from My paragraph!');
  </script>
</template>

以下は上記のテンプレートをシャドウ DOM に追加して使用するコンポーネントのクラス(カスタム要素 <my-paragraph>)の定義です。

この例ではカスタム要素名とテンプレートの id 属性を同じ文字列にしていますが、異なっていても問題ありません。

クラスの定義内では、template 要素を getElementById() で取得し、そのコンテンツ(の複製)を使って、カスタム要素のシャドウ DOM を作成しています。

customElements.define('my-paragraph',
  class extends HTMLElement {
    constructor() {
      //コンストラクタ内では super() を最初に呼び出す
      super();
      //テンプレート(id が my-paragraph の template 要素)を取得
      const template = document.getElementById('my-paragraph');
      //テンプレートのコンテンツ(content プロパティ)を取得
      const templateContent = template.content;
      //このカスタム要素にシャドウ DOM を取り付け
      const shadow = this.attachShadow({mode: 'open'});
      //テンプレートのコンテンツを複製したものをシャドウルートに追加
      shadow.appendChild(templateContent.cloneNode(true));
    }
  }
);

テンプレートのコンテンツは、content プロパティ(template.content)で DocumentFragment として利用できます。

※ テンプレートは再利用するので(content プロパティをそのまま使ってしまうと元のテンプレートに影響するため)、テンプレートのコンテンツを cloneNode() を使用して複製したものを使用します。

cloneNode() の引数には true を指定して content 以下の全要素(ノードとそのサブツリー及び子ノードのテキストも含め)をコピーします。

カスタム要素にシャドウ DOM を取り付け、テンプレートのコンテンツをシャドウルートに追加します。

テンプレートのコンテンツ(の複製)をシャドウ DOM に追加しているため、テンプレートのスタイル(CSS)はカスタム要素内にカプセル化されます。

上記の場合、9〜13行目は以下のようにチェーンして記述することもできます。

this.attachShadow({mode: 'open'}).appendChild(template.content.cloneNode(true));

HTML 文書に以下のように追加して利用します。

<my-paragraph></my-paragraph>

開発者ツールで確認すると以下のように表示されます。

以下は、シャドウ DOM で innerHTML を使って定義するカスタム要素の例です。name 属性が設定されていれば、その値を使って出力します。

customElements.define('hello-greeting', class extends HTMLElement {
  //コンストラクター
  constructor() {
    //super を最初に呼び出す
    super();
    //カスタム要素 <hello-greeting> に空のシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});
    //変数 helloTo の初期化
    let helloTo = 'World';
    // name 属性が設定されていれば
    if(this.hasAttribute('name')) {
      // その値を変数に代入
      helloTo = this.getAttribute('name');
    }
    //シャドウルートにシャドウ DOM の文書構造を innerHTML で定義
    shadow.innerHTML = `
    <style>
      p {
        font-size: 18px;
        font-weight: bold;
        color: green;
      }
    </style>
    <p class="hello">
      Hello, ${helloTo}
    </p>`;
  }
});

以下は、上記をテンプレートを使って書き換えた例です。

<template id="my-hello">
  <style>
    p {
      font-size: 18px;
      font-weight: bold;
      color: green;
    }
  </style>
  <p class="hello"></p>
</template>

p 要素のコンテンツは innerHTML を使って定義する場合は、テンプレートリテラルを使って変数の値を出力していますが、以下ではテンプレートのコンテンツの複製(templateContent)の p 要素の innerHTML を使って出力しています(16行目)。その後、テンプレートのコンテンツの複製(templateContent)をシャドウルートに追加しています。

customElements.define('hello-greeting', class extends HTMLElement {
  //コンストラクター
  constructor() {
    super();
    //変数 helloTo の初期化
    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      //このカスタム要素に name 属性が設定されていればその値を変数 helloTo に
      helloTo = this.getAttribute('name');
    }
    //テンプレートを取得
    const template = document.getElementById('my-hello');
    //テンプレートのコンテンツ(複製)
    const templateContent = template.content.cloneNode(true);
    //上記テンプレートのコンテンツからクエリーを行って p 要素を取得
    templateContent.querySelector('.hello').innerHTML = `Hello, ${helloTo}`
    //カスタム要素 <hello-greeting> にシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});
    //テンプレートのコンテンツをシャドウルートに追加
    shadow.appendChild(templateContent);
  }
});

以下も結果は同じですが、以下ではテンプレートのコンテンツの複製をシャドウ DOM に追加した後にシャドウルートからクエリーを行って取得した p 要素の innerHTML を使って出力しています(18行目)。

customElements.define('hello-greeting', class extends HTMLElement {
  //コンストラクター
  constructor() {
    super();
    //変数 helloTo の初期化
    let helloTo = 'World';
    if(this.hasAttribute('name')) {
      //このカスタム要素に name 属性が設定されていればその値を変数 helloTo に
      helloTo = this.getAttribute('name');
    }
    //テンプレートを取得
    const template = document.getElementById('my-hello');
    //カスタム要素 <hello-greeting> にシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});
    //テンプレートのコンテンツを複製したものをシャドウルートに追加
    shadow.appendChild(template.content.cloneNode(true));
    //ツリーの内側(シャドウルート)からクエリーを行って p 要素を取得
    shadow.querySelector('.hello').innerHTML = `Hello, ${helloTo}`;
  }
});

CSS カスタムプロパティの利用

以下はテンプレートで CSS カスタムプロパティを利用して、ユーザー側でスタイルを調整できるようにする例です(関連:CSS 変数を使ったスタイルフック)。

テンプレート
<template id="my-button">
  <style>
    button {
      border: none;
      border-radius: 4px;
      padding: 6px 12px;
      background-color: var(--bg-color, green); /* CSS 変数でフォールバックを指定 */
      color: var(--color, lightyellow); /* CSS 変数でフォールバックを指定 */
      cursor: pointer;
    }
  </style>
  <button type="button">
    <slot name="label"></slot>
  </button>
</template>
カスタム要素の定義と登録
customElements.define( 'my-button', class extends HTMLElement {
  constructor() {
    //super を最初に呼び出す
    super();
    //テンプレートを取得
    const template = document.getElementById('my-button');
    //カスタム要素にシャドウ DOM を取り付け
    const shadow = this.attachShadow({ mode: 'open' });
    //テンプレートのコンテンツを複製したものをシャドウルートに追加
    shadow.appendChild(template.content.cloneNode(true));
  }
});

ドキュメント側のカスタム要素のスタイルで CSS 変数を定義することで、テンプレートのスタイル(CSS 変数で指定したフォールバックの値)を変更することができます。

<style>
  /*  ドキュメントのスタイル  */
  my-button.dark {
    --bg-color: darkgreen;  /* CSS 変数を定義 */
    --color: rgb(214, 245, 214);  /* CSS 変数を定義 */
  }
</style>

<my-button class="dark"><!-- 上記のスタイルが適用される -->
  <span slot="label">Click</span>
</my-button>

<my-button><!-- テンプレートで指定したデフォルトのスタイル -->
  <span slot="label">Click</span>
</my-button>

属性を使ってスタイリング

以下は属性に指定された値を使ってスタイルを適用する例です(もっと良い方法があるかも知れません)。

このカスタム要素 <color-button> には背景色と文字色を設定できる独自の属性(background-color 属性と color 属性)を指定することができます。

また、複製したテンプレートのコンテンツで button 要素を取得して、イベントリスナーを設定しています(ボタンをクリックするとアラートを表示)。

テンプレート
<template id="color-button">
  <style>
    button {
      border: none;
      border-radius: 4px;
      padding: 6px 12px;
      background-color: #ccc;
      color: #333;
      cursor: pointer;
    }
  </style>
  <button type="button">
    <slot name="label"></slot>
  </button>
</template>

カスタム要素の定義では、背景色と文字色の値を格納するプロパティ(_bgColor と _color)を用意して、それぞれに getter/setter を作成しています。

そして、属性が変更された際に呼び出されるライフサイクルフック attributeChangedCallback() を使って、属性が変更されたら、button 要素のスタイルを更新します。

customElements.define('color-button', class extends HTMLElement {
  //コンストラクター
  constructor() {
    super();
    //プロパティの初期化
    this._bgColor = '#ccc';
    this._color = '#333';

    //テンプレートを取得してのコンテンツの複製を生成
    const templateContent = document.getElementById('color-button').content.cloneNode(true);

    //テンプレート(コンテンツの複製)の button 要素にイベントリスナーを設定
    templateContent.querySelector('button').addEventListener('click', ()=> {
      alert('クリックされました!');
    })

    //カスタム要素 <hello-greeting> にシャドウ DOM を取り付ける
    const shadow = this.attachShadow({mode: 'open'});
    //テンプレートのコンテンツを複製したものをシャドウルートに追加
    shadow.appendChild(templateContent);
  }
  //getter
  get bgColor() {
    if(this.hasAttribute('background-color')) {
      return this.getAttribute('background-color')
    }
    return this._bgColor;
  }
  //getter
  get color() {
    if(this.hasAttribute('color')) {
      return this.getAttribute('color')
    }
    return this._color;
  }
  //setter(background-color 属性と _bgColor プロパティの値を設定)
  set bgColor(val) {
    //属性名は CSS のプロパティ名と同じに設定
    this.setAttribute('background-color', val);
    this._bgColor = val;
  }
  //setter(color 属性と _color プロパティの値を設定)
  set color(val) {
    //属性名は CSS のプロパティ名と同じに設定
    this.setAttribute('color', val);
    this._color = val;
  }

  //監視する属性を指定
  static get observedAttributes() {
    return ['color', 'background-color'];
  }

  //属性が変更された際に呼び出されるライフサイクルフック
  attributeChangedCallback(attr, oldValue, newValue) {
    if (oldValue === newValue) return;
    //シャドウルートから button を取得してスタイルを設定
    this.shadowRoot.querySelector('button').style.setProperty(attr, newValue);
  }
});

属性の名前は background-color と color として、CSS のプロパティ名と同じにすることで、attributeChangedCallback() で第1引数の属性名を、style.setProperty() の第1引数に渡しています。

属性の名前を、CSS のプロパティ名と異なるものにすると、例えば、以下のように attributeChangedCallback() の第1引数の属性名を検査しなければなりません。

attributeChangedCallback(attr, oldValue, newValue) {
  if (oldValue === newValue) return;
  //属性名により分岐処理
  if (attr == 'col') {
    this.shadowRoot.querySelector('button').style.setProperty('color', newValue);
  }
  if (attr == 'bg-color') {
    this.shadowRoot.querySelector('button').style.setProperty('background-color', newValue);
  }
}

ドキュメントでは、以下のように属性を指定して、背景色と文字色を変更することができます。

<color-button> <!-- デフォルトのボタン  -->
  <span slot="label">Click</span>
</color-button>

<color-button color="yellow" background-color="orange">
  <span slot="label">Click</span>
</color-button>

また、getter/setter を作成しているので、JavaScript でプロパティ名や setAttribute() などを使って属性の値を取得・変更することができます(attributeChangedCallback() でスタイルを更新しているので、変更は要素に反映されます)。

const cbtn = document.querySelector('color-button');
//文字色を変更
cbtn.color = 'pink';
//背景色を変更
cbtn.setAttribute('background-color', 'purple');

console.log(cbtn.getAttribute('color'));  //pink
console.log(cbtn.bgColor);  //purple

イベント

template 要素のコンテンツを cloneNode() で複製した DocumentFragment にイベントリスナーを設定しても動作しません(イベントは発生しません)。

例えば、以下のような button 要素をコンテンツに持つ template 要素がある場合、

HTML
<template id="my-btn">
  <button>Click</button>
</template>
<div id="target"></div>

template 要素のコンテンツ(DocumentFragment)を取得して、イベントリスナーを設定しても動作しません(クリックしてもクリックイベントは発生しません)。

JavaScript
const template = document.getElementById('my-btn');
const target = document.getElementById('target');
// コンテンツの button 要素の複製(DocumentFragment)を取得
const button = template.content.cloneNode(true);
// button 要素にイベントリスナーを設定(動作しない)
button.addEventListener('click', ()=> {
  alert('Hello!')
});
// button 要素の複製を DOM に挿入
target.appendChild(button);

この場合、以下のように firstElementChild を使って、DocumentFragment の最初の子要素を取得すると、イベントが発生し、期待通りに動作します。

JavaScript
const template = document.getElementById('my-btn');
const target = document.getElementById('target');
// DocumentFragment の最初の子要素を取得
const button = template.content.cloneNode(true).firstElementChild;
// または const button = template.content.firstElementChild.cloneNode(true);
button.addEventListener('click', ()=> {
  alert('Hello!')
});
target.appendChild(button);

または、以下のように appendChild() などを使って DOM に挿入してからイベントリスナーを設定すればイベントが発生します。

JavaScript
const template = document.getElementById('my-btn');
const target = document.getElementById('target');
const button = template.content.cloneNode(true);
// フラグメントを DOM に挿入
target.appendChild(button);
// DOM に追加してからイベントリスナーを設定
target.querySelector('button').addEventListener('click', ()=> {
  alert('Hello!')
});

また、以下のように template 要素の中でボタンにイベントハンドラを設定することもできます。

HTML
<template id="my-btn">
  <button onclick="alert('Hello!')">Click</button>
</template>
<div id="target"></div>

上記は以下のように記述しても同じです。

HTML
<template id="my-btn">
  <button onclick="hello()">Click</button>
  <script>
    function hello() {
      alert('Hello!');
    }
  </script>
</template>
<div id="target"></div>

上記の場合、以下のようにボタンを DOM に挿入するだけです。

JavaScript
const template = document.getElementById('my-btn');
const target = document.getElementById('target');
const button = template.content.cloneNode(true);
target.appendChild(button);