カスタム要素

ウェブ機能: 自律カスタム要素

カスタム要素は、customElements.define() を介してタグ名に登録されたクラスです。登録と基本的なライフサイクルはよく理解されています。以下のルールは、日常的に誤って実装されている部分を対象としています。

ライフサイクル: 実装が間違っている部分

コールバック自体については十分に文書化されていますが、バグの原因となる誤解がいくつかあります。

  • connectedCallback は複数回実行できます。挿入のたびに起動するため、要素を移動すると再実行されます。セットアップをべき等にする - 最初の 1 回の実行を想定しない。
  • connectedMoveCallback は、移動時に切断 / 再接続のペアをスキップします。要素が Node.moveBefore() で再配置されると、disconnectedCallback → connectedCallback の代わりにこのコールバックが呼び出されるため、状態、フォーカス、iframe / メディア再生は移動後も維持されます。セットアップまたはティアダウンにコストがかかる場合や、目に見えて再実行される場合に定義します。
  • disconnectedCallback で破棄する。グローバル リスナー、タイマー、オブザーバーを削除しないとリークが発生します。ただし、要素が再挿入される可能性があるため、破棄を永続的なものとして扱わないでください。
  • コンストラクタで最初に super() を呼び出し、完全な DOM が存在することに依存しないようにします。this をタップした後に super() を呼び出すと、ReferenceError がスローされます。属性は通常(常にではありませんが)存在します。子要素のリストが長い場合、解析が完了していない可能性があります。1 回限りの初期化には connectedCallback、直接の子をモニタリングするには slotchange、属性をモニタリングするには attributeChangedCallback を使用します。connectedCallback は、すべての子が解析される前に呼び出される可能性があるため、後続の slotchange / attributeChangedCallback 呼び出しを無視しないでください。
  • attributeChangedCallback は observedAttributes の場合にのみ起動し、出力に影響しない属性については低コストに抑える必要があります。
  • ライフサイクル フックと observedAttributes は、登録時にスナップショットが作成されます。customElements.define() は両方を 1 回読み取ります。呼び出しの後にクラスにフックを追加したり、observedAttributes を拡張したりしても、無視されます。特に、define() の時点で observedAttributes がすでに存在していない限り、attributeChangedCallback は起動しません。

アップグレードのタイミングと:defined

要素は、そのクラスが登録される前に DOM に存在できます(サーバーでレンダリングされた HTML 内、またはスクリプトの読み込みが遅い場合)。定義されるまでは、動作のない不活性な HTMLElement です。customElements.define() が実行されると、ブラウザはドキュメント内の一致するすべての要素をアップグレードし、コンストラクタと connectedCallback を実行します。

モデルが日常的に見落とす 2 つの結果:

  1. アップグレード時にコンストラクタ / connectedCallback が実行されると、属性と子要素がすでに存在している可能性があります。connectedCallback の既存の属性を読み取って初期状態を同期します。要素が空の状態で開始されたと想定しないでください。
  2. connectedCallback でも子要素がまだ存在しない場合があります。HTML パーサーは、長い子リストの解析中に生成されることがあるため、すべての子要素が追加される前にコールバックが実行されることがあります。パターン(slotchange + attributeChangedCallback)については、上記のライフサイクルのコンストラクタの項目をご覧ください。

未定義の要素を非表示にして、スタイルが適用されていないコンテンツが一時的に表示されるのを防ぎます。ただし、フェイルオープンであるため、スクリプトが読み込まれなくてもコンテンツにアクセスできます。visibility: hidden でハード非表示にするのではなく、遅延を伴う opacity をアニメーション化します。これにより、要素がアップグレードされない場合でも、タイムアウト後に要素が表示されます。

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

これらのガードは、body 全体ではなく、ガードを必要とする要素、または :has(:not(:defined)) を介して特定のサブツリーにスコープします。1 つの重要でないウィジェット(共有ボタンなど)の読み込みが遅いことで、ページ全体の描画が遅れることがあってはなりません。

属性 ⇄ プロパティの反映

HTML 属性と JS プロパティの同期を維持して、マークアップとスクリプトの両方から要素を使用できるようにします。属性値は常に文字列です。強制的に変換してください。

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

値が存在する場所

上記の例では、DOM を信頼できる情報源として維持しています。各ゲッターは getAttribute を読み取ります。これは、いくつかの単純な文字列のような属性に適したデフォルトです。状態を保持できる場所は 1 つだけなので、同期が解除されることはありません。

よりリッチなコンポーネントの場合は、信頼できる情報源を JS フィールドに保持し、CSS ターゲティングやコンシューマによる読み取りなど、他の要素が参照する必要がある場合にのみ、属性に外向きに反映します。フィールドの読み取りは、アクセスごとに属性を解析して強制変換するよりもはるかに低コストであり、文字列以外の値がシリアル化によって往復することはありません。そのため、Lit などのライブラリは、属性をストレージとして扱うのではなく、JS で状態を保持し、選択的に反映します。つまり、すべてのプロパティではなく、外部から観察する必要があるもののみを反映します。

注意点:

  • observedAttributes のルール: 変更によって再レンダリングまたは副作用のトリガーが必要になる属性のみをリストします。すべてを監視すると、メリットがないにもかかわらず、すべてのミューテーションにコールバックが追加されます。
  • リフレクション ループ: 属性を書き込むセッターは attributeChangedCallback に再入します。上記のように oldVal !== newVal チェックで保護します。
  • スタイルのみの状態: スタイル設定(読み込み中、アクティブ、展開済み)を駆動するためだけに存在する内部状態の場合は、合成属性を反映するのではなく、カスタム状態を使用することをおすすめします。下記のカスタム状態をご覧ください。コンシューマーがマークアップから状態を設定できるようにする必要がある場合にのみ、属性に反映します。

カスタム ステータス

カスタム状態は、CSS がターゲットとする要素が自身について公開するブール値フラグです。ElementInternals で設定され、:state() 疑似クラスと一致します。合成属性を反映する代わりに、内部のランタイム UI 状態(busy、expanded、invalid)に使用します。これらは、コンシューマーの属性と競合することはなく、DOM に表示されることもなく、外部からは読み取り専用です(コンシューマーは :state() のスタイルを設定できますが、偽造することはできません)。

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet はセットであるため、独立した状態が(busy と error)を構成し、結合された data-status 列挙型は作成されません。

カスタム状態はランタイムのみです。DOM にシリアル化されないため、再読み込み、要素の再作成、サーバー レンダリングでは保持されません。状態をマークアップで宣言的に設定する必要がある場合や、読み込みをまたいで保持する必要がある場合は、属性の役割です(値の保存場所を参照)。信頼できる情報源から connectedCallback で復元します。経験則: コンポーネントが実行時に派生して所有し、スタイルのみを必要とするもののカスタム状態。マークアップまたはシリアル化から設定する必要があるものの属性。

コンポーネント内とコンシューマのスタイルシートの両方からこれらの状態をスタイル設定する方法については、ウェブ コンポーネントのスタイル設定をご覧ください。

命名規則

  • 真のプライベート状態: ハード カプセル化のためのネイティブ # フィールド(#count)。サブクラスが引き続きアクセスする必要がある場合にのみ _ 接頭辞を使用します。# フィールドは継承されません。サブクラスは基本クラスのプライベート フィールドを参照できません。
  • シンボルキー プロパティは、通常の列挙からは隠されていますが、シンボルを保持するコード(エクスポートするか、クラスの static フィールドとして公開する)からアクセスできるという、中間的な性質を持っています。各シンボルは一意であるため、説明を共有する 2 つのシンボルが競合することはありません。これは、サブクラスまたは兄弟クラスが、# フィールドでは提供できない制御されたアクセスを必要とする場合に便利です。
  • グローバル属性(style、class、id、slot、part、title、lang、dir)を独自のプロパティでシャドーイングしないでください。シャドーイングすると、組み込みの動作が機能しなくなります。disabled はフォーム コントロールでのみグローバルです。他の要素では、その効果を自分で実装する必要があります。

新しい API サーフェスに名前を付けるときは、プラットフォーム独自の規則に従います。カスタム要素のタグ名には、少なくとも 1 つのハイフンを含む小文字のケバブケース(my-toggle など)を使用し、プロパティとメソッドには lowerCamelCase を使用します。また、対応するプロパティと同期された小文字の属性名を使用します。