カスタム要素のライフサイクルとアップグレード
独自タグがいつ・どの順で初期化されるかを正確に押さえれば、属性の取りこぼしや未定義要素のちらつきを防げる。define・アップグレード・各コールバックの発火順序とフォーム関連APIを原理から整理します。
- 1.customElements.define より先にDOMへ書かれた要素は未定義のまま存在し、define時にアップグレードされてコンストラクタが走る。順序は define が後でも要素が先でも破綻しない。
- 2.コールバックの発火順は connectedCallback(接続)→ attributeChangedCallback(observedAttributes に挙げた属性のみ)→ disconnectedCallback(切断)。ただし接続時は初期属性が先に反映され、構築直後に observed 属性ぶんの attributeChanged がまとめて呼ばれる。
- 3.formAssociated = true と ElementInternals でフォーム部品化でき、formAssociatedCallback・formResetCallback・formDisabledCallback・formStateRestoreCallback が加わる。setFormValue で送信値を持つ。
定義・アップグレード:要素が「中身を得る」瞬間
カスタム要素は、customElements.define("my-card", MyCard) でタグ名とクラスを結びつけて初めて振る舞いを持ちます。重要なのは、定義より前にDOMへ書かれていてもエラーにならないことです。パーサは未知のハイフン入りタグを HTMLElement(正確には HTMLUnknownElement ではなく未定義のカスタム要素)として生成し、画面に存在はするが振る舞いを持たない状態で置いておきます。これを**未定義(undefined)**の要素と呼びます。
define が呼ばれると、ブラウザは文書中の同名タグをすべて探し、**アップグレード(upgrade)**します。アップグレードとは、既存の要素インスタンスに対してそのクラスのコンストラクタを実行し、プロトタイプを差し替えて「中身のある」カスタム要素に格上げする処理です。だから「要素を先に書く/define を先に呼ぶ」のどちらでも最終結果は同じになります。これがフレームワーク非依存で宣言的に使える理由です。基礎となる独自タグの考え方は Web コンポーネント に、要素がぶら下がる木構造は DOMツリーの内部構造 にまとまっています。
define を呼ぶと、ブラウザは文書中の同名要素にアップグレード反応を積み、その define 呼び出しの中で(=戻る前に)消化します。つまり define 直後には対象要素のコンストラクタは既に走り終えています。DOM挿入やパースで生じる反応も、専用のカスタム要素反応スタック(custom element reactions stack)が巻き戻る時点で処理される仕組みで、各DOM操作の直後にまとめて消化されます。customElements.upgrade(node) を呼べば、まだ未定義のあいだに挿入された部分木をその場でアップグレードできます。customElements.whenDefined("my-card") は定義完了を待つ Promise を返し、未定義のあいだだけプレースホルダを出す制御に使えます。
コンストラクタの制約:ここで属性に触ってはいけない
アップグレードでもパースでも、最初に走るのはクラスの constructor です。仕様はここに厳しい制約を課します。コンストラクタ内では 属性や子要素を読んだり付けたりしてはならず、super() 以外で要素の状態をほぼ変えられません。理由は、document.createElement("my-card") のようにまだ属性も子も無い空の状態で呼ばれる経路があり、そこで属性を前提にすると破綻するからです。実務では、コンストラクタは Shadow ルートの attachShadow と内部構造の用意までに留め、属性に依存する初期化は connectedCallback で行うのが定石です(Shadow DOM の境界は Shadow DOMのカプセル化とスロット合成 を参照)。
class MyCard extends HTMLElement {
static observedAttributes = ["label", "disabled"];
constructor() {
super();
// OK:Shadowルートと内部骨格の用意まで
this.attachShadow({ mode: "open" });
// NG例:this.getAttribute("label") / this.innerHTML = ... はここで触らない
}
connectedCallback() {
// 接続時に属性を読んで描画する
this.render();
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) this.render();
}
}
customElements.define("my-card", MyCard);
コールバックの発火順序
ライフサイクルコールバックは4種あり、呼ばれる順序には明確な規則があります。
| コールバック | 呼ばれるタイミング | 順序上の位置 |
|---|---|---|
| constructor | 生成・アップグレード時 | 最初。属性/子に触れない |
| attributeChangedCallback | observedAttributes の属性が変化 | 接続前でも発火。初期属性ぶんは接続直前にまとめて |
| connectedCallback | shadow を含む木へ接続されたとき | 属性初期化のあと |
| disconnectedCallback | 木から切断されたとき | 接続の対 |
| adoptedCallback | 別 document へ移動(adoptNode) | 移動時のみ |
肝心なのは「接続時に何が先に来るか」です。パース由来で属性付きに生成された要素では、コンストラクタ → observed 属性ぶんの attributeChangedCallback(初期値、oldValue は null)→ connectedCallback の順になります。つまり connectedCallback の時点で初期属性は既に反映済みと考えてよい設計です。一方、createElement で空に作ってから後で属性を setAttribute し、その後 appendChild した場合は、attributeChanged が接続より前に走り、最後に connectedCallback が来ます。いずれにせよ observed 属性の変化通知は connectedCallback より前に処理されるのが原則です。
attributeChangedCallback は、静的ゲッタ static get observedAttributes()(またはクラスフィールド static observedAttributes)が返した名前の属性だけを監視します。ここに無い属性をいくら変えても発火しません。逆に、ここに挙げた属性は接続前の初期値でも一度発火するため、初期化処理を二重に書かない注意が要ります。属性とプロパティ(JSのフィールド)は別物で、両者を同期させたいなら getter/setter で reflect を自前実装します。
接続・切断は移動でも対で呼ばれる点に注意します。要素を別の親へ appendChild で移すと、ブラウザは内部的に旧位置からの除去(disconnectedCallback)→新位置への挿入(connectedCallback)を行います。連続して同一タスク内で起きるため、状態を持つ部品では「切断=破棄」と決めつけず、this.isConnected で実際の接続状態を確認するのが安全です。
未定義要素の扱いとちらつき対策
define 前の未定義要素は、CSS から :defined / :not(:defined) 擬似クラスで狙えます。これを使うと、定義が済むまで未定義要素を隠すことで、属性反映前の素のテキストが一瞬見える「ちらつき(FOUC 的な未スタイル表示)」を防げます。
/* 定義が完了するまで、未定義のカスタム要素を隠す */
my-card:not(:defined) {
visibility: hidden;
}
未定義要素は HTML 的にはインライン要素扱いで、display の既定値も標準要素とは異なります。中身を持たない箱として存在し、getAttribute などDOM API は通常どおり使えますが、クラス由来のメソッドやゲッタはアップグレード後にしか生えません。customElements.whenDefined() で定義完了を待ってから処理を進めると、未定義状態への依存を避けられます。
フォーム関連API:ElementInternals でネイティブ部品になる
既定のカスタム要素はフォーム送信に参加しません。<form> の中に置いても値が送られず、:disabled 連動も効きません。これを解決するのが**フォーム関連カスタム要素(form-associated custom element)**です。クラスに static formAssociated = true を宣言し、コンストラクタで this.internals = this.attachInternals() を呼んで ElementInternals を取得すると、ネイティブのフォーム部品と同じように振る舞えます。
class MyInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
}
set value(v) {
// フォーム送信に乗る値を設定(name=... の値になる)
this.internals.setFormValue(v);
}
formResetCallback() {
this.value = "";
}
formDisabledCallback(disabled) {
// 親 fieldset/form の disabled 連動を受け取る
this.toggleAttribute("aria-disabled", disabled);
}
}
customElements.define("my-input", MyInput);
setFormValue で送信値を持たせ、internals.setValidity(...) で**制約検証(constraint validation)**に参加できます。これにより form.checkValidity() や :invalid 擬似クラスがネイティブ要素と同列に効きます(HTML 標準の検証機構は フォーム入力検証 を参照)。フォーム関連要素には、通常のライフサイクルに加えて専用コールバックが増えます。
| コールバック | 呼ばれるとき |
|---|---|
| formAssociatedCallback(form) | 要素が form に関連付けられた/外れた |
| formDisabledCallback(disabled) | 自身や祖先 fieldset の disabled が変化 |
| formResetCallback() | 所属 form がリセットされた |
| formStateRestoreCallback(state, mode) | bfcache 復帰や自動補完で状態を復元 |
頻出は、(1) define 前にDOMへ書いた要素は未定義のまま存在し、define 時にアップグレードされてコンストラクタが走る点(順序は問わない)、(2) コンストラクタでは属性・子に触れない、初期化は connectedCallback に置く点、(3) パース由来では constructor → 初期属性の attributeChanged(oldValue は null)→ connectedCallback の順で、observed 属性の変化通知は接続より前に処理される点、(4) attributeChangedCallback は observedAttributes に挙げた属性のみ発火する点、(5) :not(:defined) で未定義要素を隠してちらつきを防ぐ点、(6) formAssociated = true と attachInternals() + setFormValue でフォーム送信に参加し、formResetCallback 等が加わる点です。「コンストラクタで属性を読める」「observed に無い属性も通知される」は定番の誤りです。
まとめ
カスタム要素は customElements.define でタグ名とクラスを結び、定義前にDOMへ存在した同名要素はアップグレードでコンストラクタが走り格上げされます。よって要素と define の前後関係は結果に影響しません。コンストラクタは Shadow ルート用意までに留め、属性に触れないのが鉄則で、属性依存の初期化は connectedCallback に置きます。パース由来の要素では constructor →(observed 属性の初期値ぶん)attributeChangedCallback → connectedCallback の順に発火し、attributeChangedCallback は observedAttributes に列挙した属性だけを、初期値でも一度通知します。接続・切断は移動でも対で呼ばれるため isConnected で実状態を確認します。未定義要素は CSS の :not(:defined) で隠してちらつきを抑えられます。フォーム参加は static formAssociated = true と attachInternals() による ElementInternals(setFormValue / setValidity)で実現し、formAssociatedCallback / formDisabledCallback / formResetCallback / formStateRestoreCallback が加わります。土台は Web コンポーネント と DOMツリーの内部構造、カプセル化は Shadow DOMのカプセル化とスロット合成、検証連携は フォーム入力検証 と合わせて押さえると、ライフサイクル全体が一本につながります。
Web/フロントエンド Article
カスタム要素のライフサイクルとアップグレードを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
カスタム要素
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
コールバックの発火順は connectedCallback(接続)→ attributeChangedCallback(observedAttributes に挙げた属性のみ)→ disconnectedCallback(切断)。ただし接続時は初期属性が先に反映され、構築直後に observed 属性ぶんの attributeChanged がまとめて呼ばれる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「カスタム要素 / Web Components」に近いか確認する。
- 強みである「customElements.define より先にDOMへ書かれた要素は未定義のまま存在し、define時にアップグレードされてコンストラクタが走る。順序は define が後でも要素が先でも破綻しない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。