誰にでも、できればやりたくないと思うプロジェクトがあるはずです。コードが管理不能になり、スコープが拡張し、修正の上に修正を重ねる有様。コードが絡まり合い、構造が崩壊。コーディングは混乱を招くことがあります。

そこで責任の所在がはっきりした、シンプルで独立したモジュールです。これを使用することで、プロジェクトはスムーズに進みます。コードをカプセル化すると、実装についての心配も軽減できます。あるインプットが与えられたときにモジュールが何をアウトプットするかが分かっていれば、その目標が「どのように達成されたか」を理解する必要は必ずしもないのです。

モジュールの概念を単一のプログラミング言語で理解するのは、難しいことではありません。しかし、ウェブ開発の世界では、多様な技術を組み合わせることが必要になります。ブラウザHTML、CSS、JavaScriptを解析し、ページのコンテンツ、スタイル、機能をレンダリングします。

それぞれは、そう簡単には調和しないもので、理由は以下の通りです。

  • 関連するコードが3つ以上のファイルに分割され得る。
  • グローバルスタイルとJavaScriptオブジェクトは、予期せぬかたちで互いに干渉し合うことがある。

これらに加えて、サーバーで使用される言語ランタイム、フレームワーク、データベース、その他の依存関係から発生する問題もあります。

Web Componentsについての動画での解説もご覧ください。

Web Componentsとは?

Web Componentsは、つまり、カプセル化された単一の責任を持つコードブロックです。あらゆるページで再利用することができます。

HTMLの<video>タグを考えてみよう。URLを指定すると、サイト訪問者は再生、一時停止、戻る、進む、音量調整などの操作を行うことができます。

スタイルと機能は提供されていますが、さまざまな属性やJavaScriptのAPIコールを使って変更することができます。<video> 要素は、他のタグの中にいくつでも入れることができ、干渉することはありません。

独自の機能が必要な場合はどうすればよいのでしょうか。例えば、ページの単語数を表示する要素など。HTML<wordcount>タグは(まだ)ありません。

ReactVue.jsなどのフレームワークにより、コンテンツ、スタイル、機能を1つのJavaScriptファイルで定義できるWeb Componentsを作成することができます。これにより多くの複雑なプログラミングの問題を解決できます。以下の点を念頭に置くことが重要です。

  • フレームワークの使い方を学び、進展に合わせてコードを更新していく必要がある。
  • あるフレームワーク用に書かれたコンポーネントが、他のフレームワークと互換性を持つことはほとんどない。
  • フレームワークの人気は上がったり下がったりしている。開発チームやユーザーの気まぐれや優先順位に左右される傾向にある。
  • Standard Web Componentsは、JavaScriptだけでは実現が難しいブラウザの機能を追加することができる(Shadow DOMなど)。

幸いなことに、ライブラリやフレームワークで導入された人気のコンセプトは、たいていWebの標準として取り込まれています。そして、時間はかかりましたが、Web Componentsの登場にいたります。

Web Componentsの歴史

ベンダー固有の多くの失敗を経ながらも、標準的なWeb Componentsのコンセプトは2011年のFronteers ConferenceでAlex Russell氏によって発表されました。そして、GoogleのPolymerライブラリ(現在の提案に基づくポリフィル)がその2年後に産声を上げています。ChromeとSafariでの実装は2016年になってからのことです。

ブラウザベンダーによる詳細な交渉には時間を要したものの、2018年にはFirefox、2020年(MicrosoftによるChromiumエンジンへの切り替え時)にはEdgeに、Web Componentsが追加されました。

当然のように、Web Componentsを積極的に採用する(またはできる)開発者はほとんどいませんでしたが、ようやく安定したAPI、ブラウザによるサポートといった面が充実してきました。万事OKというわけではありませんが、フレームワークベースのコンポーネントに代わる選択肢として、ますます現実味を帯びてきています。

お気に入りのものをまだ捨てたくないという人でも、Web Componentsはあらゆるフレームワークと互換性があり、APIも今後何年にもわたってサポートされる予定です。

Web Componentsのリポジトリが公開されており、誰でも自由に閲覧することができます。

…とはいえ、自分でコードを書くのは楽しいものです。

JavaScriptフレームワークを使わずに書くWeb Componentsの入門編をお届けします。Web Componentsとは何なのか、そして、実際のウェブプロジェクトでどのように活用できるのか、見てみましょう。HTML5、CSS、JavaScriptの知識が必要になります。

Web Componentsを使ってみよう

Web Componentsは、<hello-world></hello-world>のようなカスタムHTML要素です。HTMLの仕様で公式にサポートされている要素と干渉しないように、名前にダッシュを使用する必要があります。

要素を制御するには、ES2015クラスを定義します。何でも良いのですが、hello-worldがよく使われます。クラスは、HTML要素のデフォルトのプロパティとメソッドを表すHTMLElementインターフェースを継承する必要があります。

注)Firefoxでは、HTMLParagraphElement、HTMLImageElement、HTMLButtonElementといった特定のHTML要素を拡張することができます。これは他のブラウザではサポートされておらず、Shadow DOM を作成することはできません。

実際に何か有用な動作を行うためには、このクラスにはconnectedCallback() という名前のメソッドが必要です。これは要素がドキュメントに追加された時に呼び出されます。

class HelloWorld extends HTMLElement {

  // connect component
  connectedCallback() {
    this.textContent = 'Hello World!';
  }

}

この例では、要素のテキストに”Hello World”が設定されています。

このクラスを特定の要素に対するハンドラとして定義するには、CustomElementRegistryを利用します。

customElements.define( 'hello-world', HelloWorld );

ブラウザは、JavaScript が読み込まれたときに、<hello-world>要素とHelloWorldクラスを関連付けます (例: <script type="module" src="./helloworld.js"></script> )。

これで、カスタム要素ができました。

CodePenのデモ

このコンポーネントについて、他の要素と同様にCSSでスタイルを設定することができます。

hello-world {
  font-weight: bold;
  color: red;
}

属性の追加

このコンポーネントは、ただ同じテキストが出力されるだけで、気の利いたものではありません。他の要素と同じように、HTML属性を追加できます。

<hello-world name="Craig"></hello-world>

これにより、”Hello Craig!”と表示されるようにテキストを上書きします。これを実行するには、HelloWorldクラスにconstructor()関数を追加します。すると、各オブジェクト作成時に実行されます。注意点は以下の通りです。

  1. super()メソッドを呼び出して、親HTMLElementを初期化し
  2. 他の初期化を行います。今回は、デフォルトで”World”に設定されているnameプロパティを定義します。
class HelloWorld extends HTMLElement {

  constructor() {
    super();
    this.name = 'World';
  }

  // more code...

このコンポーネントは、name属性のみ考慮します。静的なobservedAttributes()からは、監視するプロパティの配列が返されます。

// component attributes
static get observedAttributes() {
  return ['name'];
}

attributeChangedCallback()メソッドは、HTMLで属性が定義されたとき、またはJavaScriptで属性が変更されたときに呼び出されます。このメソッドには、プロパティ名、古い値、新しい値が渡されます。

// attribute change
attributeChangedCallback(property, oldValue, newValue) {

  if (oldValue === newValue) return;
  this[ property ] = newValue;

}

この例では、nameプロパティのみが更新されることになりますが、必要に応じてプロパティを追加することができます。

最後に、connectedCallback()メソッド内のメッセージを微調整します。

// connect component
connectedCallback() {

  this.textContent = `Hello ${ this.name }!`;

}

CodePenのデモ

ライフサイクルメソッド

ブラウザは、Web Componentsのライフサイクルを通して、6つのメソッドを自動的に呼び出します。最初の4つは上記の例ですでに触れたとおりですが、一覧でご紹介します。

constructor()

コンポーネント初期化時に呼ばれます。super()を呼び出す必要があり、デフォルトの設定やその他のプリレンダリング処理を行うことができます。

static observedAttributes()

ブラウザが監視する属性名の配列を返します。

attributeChangedCallback(propertyName, oldValue, newValue)

属性が設定・変更されるたびに呼び出されます。HTMLで定義したものは即座に渡されますが、JavaScriptによりそれを修正することができます。

document.querySelector('hello-world').setAttribute('name', 'Everyone');

ここでは、実行に際して再レンダリングが必要となる場合があります。

connectedCallback()

Web ComponentがDocument Object Modelに追加された時に呼び出され、必要なレンダリングをすべて実行します。

disconnectedCallback()

Web ComponentがDocument Object Modelから削除されたときに呼び出されます。保存した状態の削除やAjaxリクエストの中断など、クリーンアップが必要な場合に便利です。

adoptedCallback()

この関数は、Web Componentがあるドキュメントから別のドキュメントに移動したときに呼び出されます。どんなケースを思い浮かべるか悩むところですが、良い使い道が見つかるかもしれません。

Web Componentsと他の要素との相互作用について

Web Componentsには、JavaScriptフレームワークにはないユニークな機能があります。

Shadow DOM

上記で作成したWeb Componentでも十分機能しますが、外部からの干渉を受けないわけではなく、CSSやJavaScriptにより修正されてしまう可能性があります。同様に、あるコンポーネントに定義したスタイルが、他のコンポーネントに影響を与える可能性もあります。

Shadow DOMは、Web Componentに別途DOMを付加することで、このカプセル化の問題を解決してくれます。

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

モードは次のどちらかです。

  1. “open” – 外部ページのJavaScriptでもShadow DOMにアクセスできる(shadowRoot使用)
  2. “closed” – Web Component内でのみShadow DOMにアクセスできる

Shadow DOMは他のDOM要素と同様に操作することができます。

connectedCallback() {

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

  shadow.innerHTML = `
    <style>
      p {
        text-align: center;
        font-weight: normal;
        padding: 1em;
        margin: 0 0 2em 0;
        background-color: #eee;
        border: 1px solid #666;
      }
    </style>

    <p>Hello ${ this.name }!</p>`;

}

このコンポーネントでは、”Hello”テキストを<p>要素内でレンダリングし、スタイルを設定しています。フォントや色などの一部のスタイルは明示的に定義されていないため、ページから継承されますが、コンポーネント外のJavaScriptやCSSで変更することはできません。

CodePenのデモ

このWeb Componentsのスタイルは、ページ上の他の文章や、他の<hello-world>コンポーネントにも影響を与えることはできません。

なお、CSSの:hostセレクタは、Web Component内から外側の<hello-world>要素のスタイルを指定することができます。

:host {
  transform: rotate(180deg);
}

また、<hello-world class="rotate90">のように、要素が特定のクラスを使用するときに適用されるスタイルを設定することもできます。

:host(.rotate90) {
  transform: rotate(90deg);
}

HTMLテンプレート

スクリプト内でHTMLを定義することは、複雑なWeb Componentsではあまり実用的でない可能性があります。テンプレートを使用すると、Web Componentで使用できるHTMLのかたまりをページで定義できます。これにはいくつかのメリットがあります。

  1. JavaScript内の文字列を書き換えることなく、HTMLコードをいじることができます。
  2. コンポーネントは、種類ごとにJavaScriptのクラスを作成することなく、カスタマイズできます。
  3. HTMLはHTMLとして定義する方が簡単で、コンポーネントのレンダリング前にサーバーやクライアントで修正することも可能です。

テンプレートは<template>タグで定義します。IDを付与してコンポーネントクラス内で参照できるようにするのが現実的です。この例では、3つの段落で「Hello」メッセージを表示しています。

<template id="hello-world">

  <style>
    p {
      text-align: center;
      font-weight: normal;
      padding: 0.5em;
      margin: 1px 0;
      background-color: #eee;
      border: 1px solid #666;
    }
  </style>

  <p class="hw-text"></p>
  <p class="hw-text"></p>
  <p class="hw-text"></p>

</template>

Web Componentクラスはこのテンプレートにアクセスし、その内容を取得、要素を複製して、場所に限らず一意のDOMフラグメント作成を保証することができます。

const template = document.getElementById('hello-world').content.cloneNode(true);

DOMを修正し、Shadow DOMに直接追加可能です。

connectedCallback() {

  const

    shadow = this.attachShadow({ mode: 'closed' }),
    template = document.getElementById('hello-world').content.cloneNode(true),
    hwMsg = `Hello ${ this.name }`;

  Array.from( template.querySelectorAll('.hw-text') )
    .forEach( n => n.textContent = hwMsg );

  shadow.append( template );

}

CodePenのデモ

テンプレートスロット

スロットは、テンプレートをカスタマイズするためのものです。例えば、<hello-world>Web Componentを使用しながら、Shadow DOM内の<h1>の見出しにメッセージを配置するとします。具体的には、このようなコードを書くことができます。

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>

</hello-world>

(slot属性に注意)

任意で、別の段落など他の要素を追加することも可能です。

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

テンプレート内にスロットを実装できるようになりました。

<template id="hello-world">

  <slot name="msgtext" class="hw-text"></slot>

  <slot></slot>

</template>

<h1>(”msgtext”属性)が、”msgtext”という名前の<slot>に挿入されます。<p>にはスロット名が割り当てられていませんが、次に利用可能になった無名の<slot>で使用されます。テンプレートは、次のようになります。

<template id="hello-world">

  <slot name="msgtext" class="hw-text">
    <h1 slot="msgtext">Hello Default!</h1>
  </slot>

  <slot>
    <p>This text will become part of the component.</p>
  </slot>

</template>

ただし、現実にはこんなに単純にはいきません。Shadow DOM内の<slot>要素は、挿入された要素を指します。<slot>を見つけてから.assignedNodes()メソッドを使用して、内部の子の配列を返すことによってのみ、これにアクセスできます。更新したconnectedCallback()メソッドは以下の通りです。

connectedCallback() {

  const
    shadow = this.attachShadow({ mode: 'closed' }),
    hwMsg = `Hello ${ this.name }`;

  // append shadow DOM
  shadow.append(
    document.getElementById('hello-world').content.cloneNode(true)
  );

  // find all slots with a hw-text class
  Array.from( shadow.querySelectorAll('slot.hw-text') )

    // update first assignedNode in slot
    .forEach( n => n.assignedNodes()[0].textContent = hwMsg );

}

CodePenのデモ

また、Web Component内の特定のスロットを対象にすることはできますが、挿入された要素に直接スタイルを設定することはできません。

<template id="hello-world">

  <style>
    slot[name="msgtext"] { color: green; }
  </style>

  <slot name="msgtext" class="hw-text"></slot>
  <slot></slot>

</template>

テンプレートスロットは少し特殊ですが、JavaScriptの実行に失敗した場合でもコンテンツが表示されるのが強みです。このコードでは、デフォルトの見出しと段落を表示しています。Web Componentクラスが正常に実行された場合にのみ置き換えられます。

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

したがって、何らかの形でプログレッシブエンハンスメントを実装することができます。たとえそれがJavaScriptが必要です」というメッセージであったとしてもです。

宣言型Shadow DOM

上記の各例では、JavaScriptを使用してShadow DOMを構築しています。これが唯一の選択肢であることに変わりはありませんが、実験的にChrome用の宣言型Shadow DOMが開発されています。これにより、サーバー側でのレンダリングが可能になり、レイアウトのずれやスタイルのちらつき(FOUC)を回避できます。

次のコードは、HTMLパーサーによって検出され、先のセクションで扱ったのと同じShadow DOMが作成されます(必要に応じてメッセージを更新する必要があります)。

<hello-world name="Craig">

  <template shadowroot="closed">
    <slot name="msgtext" class="hw-text"></slot>
    <slot></slot>
  </template>

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

この機能はどのブラウザでも利用できるわけではなく、FirefoxやSafariで正常に機能する保証はありません。宣言型Shadow DOMの詳細はこちらをご覧ください。ポリフィルはシンプルですが、実装内容が変更される可能性があることには、ご注意ください。

Shadow DOMイベント

Web Componentは、ページDOMと同様に、Shadow DOMの任意の要素にイベントを結びつけることができます。

shadow.addEventListener('click', e => {

  // do something

});

stopPropagationを行わない限り、イベントはページDOMにバブルアップされますが、再び対象に指定されます。したがって、イベントはその中の要素ではなく、カスタム要素から発生するような様相を呈します。

他のフレームワークでWeb Componentsを利用する

作成したWeb Componentは、すべてのJavaScriptフレームワークで機能します。どのフレームワークも、HTMLの要素を理解し得ません。<hello-world>コンポーネントは<div>と同じように扱われ、DOM内に配置されます(ここでクラスが有効になる)。

custom-elements-everywhere.comでは、フレームワークの一覧と Web Componentのノートが公開されています。React.jsについてはいくつかの課題がありますが、ほとんどは完全に互換性があります。JSXで<hello-world>を使用することは可能です。

import React from 'react';
import ReactDOM from 'react-dom';
import from './hello-world.js';

function MyPage() {

  return (
    <>
      <hello-world name="Craig"></hello-world> 
    </>
  );

}

ReactDOM.render(<MyPage />, document.getElementById('root'));

…しかし、以下の点にはご注意ください。

  • ReactはHTML属性にプリミティブ型しか渡せない(配列やオブジェクトは渡せない)
  • ReactはWeb Componentのイベントをリッスンできないので、手動で独自のハンドラを利用する必要がある

Web Componentsへの批判とその課題

Web Componentsは大幅に改善されましたが、管理が面倒になりがちです。

スタイリングの難しさ

Web Componentsのスタイリングにはいくつかの課題があります。例えば、スコープの限られたスタイルの上書きです。ただし、解決策はいくつもあります。

  1. Shadow DOMの使用を避ける: カスタム要素に直接コンテンツを追加することができます(ただし、他のJavaScriptが偶然または悪意を持ってそれを変更する可能性もあります)。
  2. :hostクラスを使用する: 上で見たように、スコープ付きCSSはカスタム要素にクラスが設定されたときに特定のスタイルを適用することができます。
  3. CSSのカスタムプロパティ(変数)を確認する: カスタムプロパティはWeb Componentsにカスケードされるので、要素がvar(-my-color)を使用している場合、外側のコンテナ(例えば:root)で--my-colorを設定すれば、それが使用されることになります。
  4. part属性を活用する: 新しい疑似要素::part()は、part属性を持つ内部コンポーネントのスタイルを指定できます。例えば、<hello-world>コンポーネント内の<h1 part="heading">は、セレクタhello-world::part(heading) でスタイリングできます。
  5. スタイルの文字列を渡す: <style>ブロックの中で適用する属性として渡すことができます。

どの解決策も、理想的とは言えません。また、他のユーザーがどのようにWeb Componentをカスタマイズするかについても、慎重に計画する必要があります。

無視される入力

Shadow DOMにある <input>, <textarea>, or <select> フィールドは、これを内包するフォーム内で自動的に紐付けられるわけではありません。初期のWeb Component採用者は、ページDOMに隠しフィールドを追加したり、FormDataインターフェースを使用したりして、値を更新していました。どちらも特に実用的ではなく、Web Componentのカプセル構造を壊してしまいます。

新しいElementInternalsインターフェースは、Web Componentがフォームにフックして、カスタム値や有効性を定義できるようにするものです。Chromeで実装されていますが、他のブラウザではポリフィルが利用可能です。

デモとして、基本的な<input-age name="your-age"></input-age>コンポーネントを作成することにします。このクラスは、静的なformAssociated値をtrueに設定する必要があり、オプションとして、外部フォームが紐付けられたときにformAssociatedCallback()メソッドを呼び出すことが可能です。

// <input-age> web component
class InputAge extends HTMLElement {

  static formAssociated = true;

  formAssociatedCallback(form) {
    console.log('form associated:', form.id);
  }

コンストラクタでは、attachInternals()メソッドを実行する必要があります。これにより、コンポーネントはフォームや、(値の検証やバリデーションをする必要のある)他のJavaScript コードとやり取りすることができます。

  constructor() {

    super();
    this.internals = this.attachInternals();
    this.setValue('');

  }

  // set form value

  setValue(v) {

    this.value = v;

    this.internals.setFormValue(v);

  }

ElementInternalのsetFormValue()メソッドは、ここで空文字列にて初期化された親フォームに対する要素の値を設定します(複数の名前と値のペアを持つFormDataオブジェクトを渡すことも可能)。その他のプロパティとメソッドは以下の通りです。

  • form: 親フォーム
  • labels: (コンポーネントにラベルを付ける)要素の配列
  • willValidate、checkValidity、validationMessageなどの制約検証APIオプション

connectedCallback()メソッドは、前の場合と同様にShadow DOMを作成しますが、フィールドに加えられた変更を監視する必要があるため、setFormValue()が使えます。

  connectedCallback() {

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

    shadow.innerHTML = `
      <style>input { width: 4em; }</style>
      <input type="number" placeholder="age" min="18" max="120" />`;

    // monitor input values
    shadow.querySelector('input').addEventListener('input', e => {
      this.setValue(e.target.value);
    });

  }

このWeb Componentを使って、他のフォームフィールドと同様に動作するHTMLフォームを作成できます。

<form id="myform">

  <input type="text" name="your-name" placeholder="name" />

  <input-age name="your-age"></input-age>

  <button>submit</button>

</form>

これで十分機能しますが、少し複雑な感じはします。

CodePenのデモで確認する

詳しくは、より高機能なフォームコントロールの記事をご覧ください。

まとめ

Web Componentsは、JavaScriptフレームワークの地位と性能が向上する中で、賛同や人気を得るのに苦労してきました。ReactやVue.jsAngularの経験者であれば、Web Componentsは複雑で不格好に見えるでしょう。特に、データバインディングや状態管理といった機能の欠如について、そのような印象が抱かれがちです。

まだ解決すべき課題はありますが、Web Componentsの未来は明るいと思います。フレームワークにとらわれず、軽量で高速、そしてJavaScriptだけでは不可能な機能を実装することができるのです。

10年前、jQuery無しのサイト構築に取り組む人はほとんどいませんでしたが、各種ブラウザは、優れたネイティブの代替案(querySelectorなど)を追加しました。同じことがJavaScriptフレームワークにも起こり、Web Componentsはその最初の暫定的な一歩だと言えるでしょう。

Web Componentsの使い方について、何か疑問はございますか?コメント欄でお聞かせください。

Craig Buckler

英国出身のフリーランスウェブ開発者、ライター、講演者。長年ウェブ標準とパフォーマンスを専門とする。