「壊れない」「壊さない」Web Components 〜 データの受け渡し(第1回/全3回)

梅木 和弥
mv

はじめに。

こんにちは。アプリケーションエンジニアの梅木です。

フロントエンド開発でのコンポーネントの設計においてこんな経験ないでしょうか。

  • 「ERBやBlade、Twigのテンプレートエンジンで作った管理画面」と、「マーケが運用するWordPress製のLP」で、どちらも同じ「ボタン群」を設置したいが共有する手段がない。
  • 「React製の日付ピッカー」をPHPのテンプレートエンジンで動いてる別プロダクトにも持ち込みたいが、React自体を入れるわけにはいかない。
  • コンポーネント化出来てないDOM(HTML, CSS)をコピペする運用をしていたが、コピー先での * { box-sizing: content-box; } やReset CSSとぶつかってレイアウトが壊れた。

「技術スタックが混在する現場でのコンポーネント再利用」は日常的にこういった課題で悩むことが多いと思います。

Web Components 使ってますか?

ReactやVueのようなフレームワークで実装した(された)「共通コンポーネントライブラリ」って強力だと思うんですが、結局は「同じフレームワークを採用しているという前提」に依存します。

そのためこれらの「フレームワーク・ロックイン」があるUI基盤を他の媒体に持ち込むことは困難ですよね。

こういった「フレームワーク・ロックイン」と「環境依存によるUI崩壊」の課題を、ブラウザの標準仕様レベルで解決するのが Web Components です。

Web Components は実験的な技術ではなく、全モダンブラウザでネイティブサポートされています。
また、特定のライブラリに依存しない「ポータビリティ」が強みです。

各テンプレートエンジンで <my-button> と書けば、それだけで動きます。

シリーズやります。

本シリーズでは、Web Components のコア価値である 2つの堅牢性 を実務レベルで達成するための設計パターンを紹介します。
実用的な話を中心に寄りつつ、設計面のお話も触れていければいいかなと考えています。

  1. 壊れない
    グローバルCSS(Reset CSSや !important)にコンポーネントが影響されない。や予期せぬJSの影響を受けず、ホスト側のJSで予期せぬ処理にコンポーネントが影響されない。
  2. 壊さない
    コンポーネント内部のスタイルやイベントが、ホスト側(呼び出し側)のレイアウトやロジックを汚染しない

ロードマップです。

フレームワークに慣れている開発者が実際に Web Components を実務に取り入れようとすると、結構躓くポイントが多いです。

現場の実情として、「ブラウザ標準APIの仕様」と「モダンフレームワークの常識」との間にある「摩耗」が課題としてあります。
本シリーズでは、その摩耗を解消する実装パターンを逆引き形式で解説します。

  テーマ キーワード
第1回(本記事) データの受け渡しで「壊れない」設計 Custom Elements
第2回 CSS干渉で「壊さない・壊れない」カプセル化 Shadow DOM / ::part
第3回 標準の <form> の挙動を「壊さない」協調設計 ElementInternals / アクセシビリティ

以前、負荷検証ツール K6 にて、全5回の連載をやりましたので、そちらのシリーズもぜひ。

1. フレームワークの保護下から外れることを認識しましょう。

Web Componentsのカスタムエレメントは、フレームワークの保護下にはありません。
※ ここは表現次第で受け取り方変わると思います。「Web Componentsはフレームワークへの依存がありません。」と受け取っていただければ。

フレームワーク・ロックインの恩恵と弊害

ReactやVue(場合によってはテンプレートエンジン)などのエコシステムは、「型レベル」や「ビジネスルールレベル」でデータを検証・加工してからコンポーネントに渡してくれます。

これだと「フレームワークがよしなにデータの受け渡しを守ってくれている」という利点もあると思うので手っ取り早さはあるんですが、
フレームワークの「アップデートでコンポーネントが破壊されることを考慮しなければいけない」ですし、他のフレームワークで「コンポーネントを利用したい」みたいな時のハードルが高くなりますよね。
※ これに関してはコンポーネントに限らずですが(笑)

つまり、良くも悪くも「フレームワーク・ロックインを抱えたコンポーネント」であることは否定出来ないかと。

カスタムエレメントはフレームワークに依存しません。

Web Componentsを導入した場合、カスタムエレメントはフレームワークの保護下から外れます。
この前提として理解してない現場では、データの受け渡しにおける事故が起きやすいです。

クラス継承ベースのオブジェクト指向に慣れている人であれば、ここまでの文章で「なるほど。コンストラクタ(もしくはそれに当たる関数)で綺麗に例外処理をかけたバリデーション処理を書く感じなのね」とお気づきかと思います。

まさにその通りで、基本は「オブジェクト指向をDOMに取り入れる」意識を持って頂ければ、Web Componentsは概ね攻略出来てると思います。
あとは、ブラウザ標準のAPIに慣れていくのみ。

少し脱線しましたが。

ホスト側からは、HTML属性(Attributes)として文字列が渡されることもあれば、JSプロパティ(Properties)として想定外のオブジェクトが注入されることもあります。この部分がフレームワークを利用している場合、上手くやってくれてたって感じですね。

「外部環境から渡されるデータは一切信用しない」ことが壊さないコンポーネントを作るための鉄則です。

解決策はこちら

以下はGetter/Setter を実装している簡単な例です。
不正な値が渡されてもコンポーネントがクラッシュしないよう、フォールバック(初期値)を備えた設計を取るよう実装しています。

class MyComponent extends HTMLElement {

  #config = { theme: 'light', size: 'medium' }; // 内部状態(プライベートフィールドで隠蔽)

  get config() {
    return this.#config;
  }

  set config(value) {
    // 境界でのバリデーションとフェイルセーフ
    if (typeof value !== 'object' || value === null) {
      console.warn('<my-component>: config must be an object.');
      return;
    }

    // 安全にマージして内部状態を更新
    this.#config = { ...this.#config, ...value };
    this.#requestUpdate(); // 独自の描画キュー(後述)
  }
}

フレームワークが守ってくれない Web Components での実装のポイントは、この境界で「防御層(フェイルセーフ)」を自前で構えることです。
※ オブジェクト指向に慣れてる方だと当たり前のアプローチだと思いますが...。

不正値が来た際に、「正常値に変換してDOMに残すのか、異常値として別の処理に回すのか」を仕様に応じてカプセル化してコーディング出来るのがクラスベースでのオブジェクト指向の強みですよね。(勿論継承も出来ますし)

テンプレートエンジン環境での対応

テンプレートエンジン(WordPressやBladeなど)からデータを渡す場合、多くの現場では以下のようにHTML属性(Attribute)にJSONを埋め込むアプローチが取られます。

<my-component config="{{ json_encode($config) }}"></my-component>

これがブラウザに届くと、単なる文字列として解釈されます。

<my-component config='{"theme":"dark","size":"large"}'></my-component>

この configattributeChangedCallback で受け取ると、中身は {"theme":"dark","size":"large"} というただの文字列となりますよね。仮にPHPやRails側で不正なデータが出力された瞬間に、JS側で JSON.parse が失敗し、コンポーネント全体がクラッシュ(画面が真っ白に)してしまいます。
先ほどのSetterにそのまま渡せば typeof value !== 'object' に引っかかり、警告が出て終わります。

attributeChangedCallback(name, oldVal, newVal) {
  if (name === 'config') {
    try {
      // 文字列を安全にオブジェクトに変換し、Setterへ流す
      this.config = JSON.parse(newVal); 
    } catch (e) {
      // クラッシュさせず、エラーを警告に留める(壊さない)
      console.warn('<my-component>: 不正なJSONが渡されました', newVal);
    }
  }
}

フレームワークが守ってくれない Web Components では、この境界で「防御層(フェイルセーフ)」を自前で構える必要があります。

2. HTML属性とJSプロパティの「競合」について。

実務でよく課題になるのがSSR環境での挙動です。

たとえば。

  • サーバーが <my-button count="5"> を含むHTMLを返す
  • ブラウザがHTMLをパースし、DOMに <my-button> が生成される(まだ未知の要素)
  • Reactのハイドレーションが走り、element.count = 10 をセットする
    (このときSetterは存在しない。ただのインスタンスプロパティとして上書きされる)
  • Custom Elementsの定義(customElements.define)が実行され、Upgradeが起きる
  • プロトタイプにSetterが生えるが、すでにインスタンスプロパティが優先され、Setterが呼ばれない

つまり count = 10 という値がSetterを通らずに残り、属性の "5" との間で食い違いが発生しますよね。

ReactやNext.jsでサーバーサイドレンダリングを採用しているプロジェクトでよく見る状況です。

この課題においても、フレームワークの保護下であれば気にすることが少なくなっちゃう部分なんですが、Web Components を導入する際は考慮が必要です。

解決策はこちら

コンポーネントがDOMに接続された瞬間(connectedCallback)に、既存のプロパティを一度削除し Setter を通して再評価することで競合を解消します。
connectedCallback

class MyButton extends HTMLElement {
  connectedCallback() {
    // カスタムエレメント定義前にJSからセットされたプロパティを救出
    this.#upgradeProperty('count');
    this.#requestUpdate();
  }

  #upgradeProperty(prop) {
    if (this.hasOwnProperty(prop)) {
      const value = this[prop];
      delete this[prop]; // インスタンス自身のプロパティを削除し、プロトタイプのSetterを叩けるようにする
      this[prop] = value;
    }
  }

  set count(val) {
    this.setAttribute('count', val);
  }
}

delete this[prop] はですが、これを記述することでインスタンスに直接生えてしまったプロパティ(ハイドレーションによる上書き)を delete で剥がすことが出来ます。
これにより、「正常にクラスのプロトタイプに定義した本来の Setter を機能させる」といった感じですね。

この対応により、どのタイミングでデータが流し込まれても、コンポーネントを最新の状態で初期化することが出来ます。

テンプレートエンジンを利用している場合での似たような事象

テンプレートエンジンを使っている現場では、以下のようなパターンで発生しやすいです。

パターンA:テンプレートエンジンが埋め込んだ初期値を、別のJSがプロパティにセットする

たとえば、Bladeで以下のようなHTMLを生成しているとします。

<!-- サーバー側で生成されたHTML -->
<my-button data-count="5"></my-button>

<script>
  // 初期化スクリプト:data属性を読んでプロパティにセット
  const el = document.querySelector('my-button');
  el.count = Number(el.dataset.count); // ← この時点でCustom Elementsはまだ未定義
</script>
パターンB:jQueryやVanilla JSの初期化処理が、define より先に走る
<my-button></my-button>

<script src="/js/init.js"></script>        <!-- ① 先に読み込まれる -->
<script src="/js/components.js"></script>  <!-- ② customElements.define はここ -->

init.js の中で document.querySelector('my-button').count = 10 が実行されると、②の define よりも先にプロパティが書き込まれます。

時系列で整理します。

  1. サーバーがHTMLを返し、ブラウザがDOMをパースする。<my-button> はまだ「未知の要素」
  2. 初期化JSが走り、element.count = 10 をセットする。Setterは存在しないため、ただのインスタンスプロパティとして格納される
  3. customElements.define が実行され、Upgradeが起きる。プロトタイプにSetterが生える
  4. → しかし、インスタンスプロパティがプロトタイプのSetterより優先されるため、Setterが呼ばれない

結果として、count = 10 という値がSetterによるバリデーションや描画更新を一切通らずに残ります。

こういったシチュエーションでも、先程提示した 「DOMに接続された瞬間(connectedCallback)に、既存のプロパティを一度削除し Setter を通して再評価する」対応が使えますね。

3. 複数の属性変更による「レンダリングの連続発火」対応。

標準APIであるobservedAttributes で属性変更を監視しUIを更新するのはWebAPIに慣れてる方だとお馴染みの基本テクニックだと思います。
ただ、このAPIを利用する際、レンダリングの連続発火イベントを考慮する必要があります。

例えばですが。

例えば、ホスト側(外部のJS)から以下のように複数の属性を同時に変更されたとします。

// 外部からの操作
const widget = document.querySelector('my-widget');
widget.setAttribute('title', '新着記事');
widget.setAttribute('status', 'loading');
widget.setAttribute('theme', 'dark');

この場合、ブラウザ標準の attributeChangedCallback は、属性が変更されるたびに同期的に発火しますよね。

もしこのコールバックの中で直接DOMを書き換える(レンダリングする)処理を書いていると、全く同じタイミングで3回も画面の再描画が走り、ブラウザの動作が重くなります(パフォーマンス劣化)。

愚直に処理すると不要な再レンダリングが3回連続で走り、パフォーマンスが著しく劣化に繋がります。

解決策はこちら

これを防ぐためには、複数の変更要求を一旦受け止めて複数の処理をまとめる「バッチ」対応で解決出来ます。

class MyWidget extends HTMLElement {
  #updatePending = false;

  static get observedAttributes() {
    return ['title', 'status'];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) {
      this[name] = newVal;
      this.#requestUpdate();
    }
  }

  async #requestUpdate() {
    // すでに描画の予約が入っていれば何もしない(ここで2回目、3回目の要求を弾く)
    if (this.#updatePending) return;
    this.#updatePending = true;

    // 同期的なコード(上記のsetAttribute3連発など)がすべて終わるまで「一瞬だけ待つ」
    await Promise.resolve();

    // 全ての変更が反映された最新の状態で、1回だけ描画する
    this.#render();
    this.#updatePending = false;
  }

  #render() {
    // 1回のイベントループで1度しか呼ばれない
    this.innerHTML = `<div>${this.title} - ${this.status}</div>`;
  }
}

この数行の設計を取り入れるだけで、パフォーマンスが向上します。
ブラウザ標準+基本的なオブジェクト指向の考え方だけで実現できる点もWeb Componentsの魅力ですよね。

4. CustomEventの設計。

コンポーネント内部の変化(クリック、入力値の変更など)を外に伝えるには CustomEvent を使います。
しかし、Shadow DOM内部で発火したイベントは、デフォルトではホスト側のDOMまで届きません。

ReactやVueのイベントシステムと正しくイベントを拾わせるには、伝播の設計を明示する必要があります。

bubblescomposed の活用

class MyInput extends HTMLElement {
  #handleChange(event) {
    // 内部のイベント伝播を止める(ホスト側のフレームワークを混乱させないため)
    event.stopPropagation();

    // 外界に向けて、新しく設計したイベントを再発火する
    this.dispatchEvent(new CustomEvent('my-change', {
      detail: { value: event.target.value },
      bubbles: true,   // DOMツリーを上へ伝播させる
      composed: true,  // Shadow DOMの境界を越えて外へ伝える
    }));
  }
}

composed: true を設定することで、Web Componentsは「独立したUIパーツ」として他のフレームワークと対等に通信できるようになります。

テンプレートエンジンを利用している場面では

ReactやVueの環境であれば、フレームワークの合成イベントシステムがShadow DOMの内外を吸収してくれる場面もあります。
しかし、テンプレートエンジン環境におけるイベント購読は addEventListener の直書きが基本です。

// テンプレートエンジン環境での典型的なイベント購読
document.querySelector('my-input').addEventListener('my-change', (e) => {
  console.log(e.detail.value);
});

このとき、コンポーネント側で composed: true を付け忘れると、Shadow DOMの境界でイベントが止まり、外側では一切拾えません。
フレームワーク環境と違って間に吸収してくれるレイヤーがないため、composed の付け忘れがそのまま「イベントが飛ばない」という事故に直結します。

デバッグも厄介です。エラーは出ず、ただハンドラが呼ばれないだけなので、原因の特定に時間がかかります。Web Componentsを外部に公開する場合は、bubbles: truecomposed: true の両方をデフォルトとするくらいの意識でちょうどよいと思います。
例:「共通の抽象クラスにこのデフォルトを持たせておくのも有効かなと。

まとめ

「壊れない」コンポーネントを作るためには、DOMを信頼できない境界と理解し、いかに「壊さない」よう防御力の高いアーキテクチャを敷くかが鍵になります。

第一回目の今回はデータの受け渡しに特化したお話をしました。

ただ、オブジェクトとして各プロパティ値が堅牢になっても「見た目」が安全とは限らないですよね。
ホスト側のグローバルCSSによって、「コンポーネントのUIのレイアウトが壊された」という課題が残っています。

次回は、外部のCSS干渉を完全に防ぎつつ、必要なテーマカラーだけを安全に注入する 「Shadow DOM と ::part を使ったカプセル化の現実解」 について解説します。

ここまで読んでいただきありがとうございます。

梅木 和弥/ アプリケーションエンジニア

Webのシステム開発における、設計・実装に携わっています。
業務ドメインを技術に翻訳する工程に注力しております。

最近はトムとジェリーにハマってます。

梅木 和弥 の書いた記事一覧

最新の関連記事

Download 資料ダウンロード

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。


Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般についてお気軽にご相談ください。専門スタッフによるDrupal無料相談も行なっております。