T-CREATOR

Web Components が “is not a constructor” で落ちる時:定義順序と複数登録の衝突を解決

Web Components が “is not a constructor” で落ちる時:定義順序と複数登録の衝突を解決

Web Components 開発で突然現れる「is not a constructor」エラー。このエラーは、カスタム要素の定義順序や複数登録の衝突が原因で発生します。コンソールに出るエラーメッセージを見て「なぜ?」と頭を抱えた経験はありませんか?

本記事では、このエラーが発生する根本原因から、実践的な解決方法まで、段階的に解説いたします。初心者の方でも理解できるよう、図解とコード例を交えながら丁寧に説明していきますね。

背景

Web Components とカスタム要素の基本

Web Components は、再利用可能なカスタム HTML 要素を作成できる標準技術です。その中核となるのが Custom Elements API で、この API を使って独自の HTML タグを定義できます。

javascript// カスタム要素の基本的な定義
class MyButton extends HTMLElement {
  constructor() {
    super();
    // 初期化処理
  }
}

// カスタム要素の登録
customElements.define('my-button', MyButton);

上記のコードでは、HTMLElement を継承したクラスを作成し、customElements.define() メソッドで登録しています。この登録処理が、今回のエラーの鍵となる部分なのです。

CustomElementRegistry の役割

ブラウザには CustomElementRegistry という特別なレジストリが存在します。このレジストリは、すべてのカスタム要素の定義を一元管理する重要な役割を担っています。

javascript// グローバルな CustomElementRegistry にアクセス
console.log(customElements);

// 登録済みの要素を取得
const MyButtonClass = customElements.get('my-button');

customElements オブジェクトを通じて、カスタム要素の登録状態を確認したり、既存の定義を取得したりできます。

以下の図で、Web Components におけるカスタム要素登録の基本的な流れを確認しましょう。

mermaidflowchart TB
    dev["開発者"] -->|"クラス定義"| classCode["class MyButton<br/>extends HTMLElement"]
    classCode -->|"customElements.define()"| registry["CustomElementRegistry"]
    registry -->|"名前とクラスを紐付け"| storage[("登録済み要素<br/>ストレージ")]
    html["HTML: &lt;my-button&gt;"] -->|"要素使用"| registry
    registry -->|"クラス取得"| instance["インスタンス生成"]
    instance -->|"constructor 呼び出し"| render["要素レンダリング"]

図で理解できる要点:

  • カスタム要素は CustomElementRegistry に一度だけ登録される
  • HTML から使用する際、レジストリを経由してクラスが取得される
  • 登録されたクラスから新しいインスタンスが生成される

スクリプトの読み込みタイミング

モダンな Web 開発では、複数の JavaScript ファイルを扱います。これらのファイルの読み込み順序が、エラー発生の原因になることがあるのです。

html<!DOCTYPE html>
<html>
  <head>
    <!-- ファイル A: button.js -->
    <script src="button.js"></script>

    <!-- ファイル B: card.js(button に依存) -->
    <script src="card.js"></script>
  </head>
  <body>
    <my-button>Click me</my-button>
    <my-card>Content</my-card>
  </body>
</html>

上記のように、複数のカスタム要素を定義する際、それぞれのファイルの読み込み順序と実行タイミングを適切に管理する必要があります。

課題

"is not a constructor" エラーが発生するケース

このエラーは、主に以下の 3 つのケースで発生します。それぞれ詳しく見ていきましょう。

ケース 1:同じ名前での複数回登録

カスタム要素は、同じ名前で複数回登録しようとするとエラーが発生します。

javascript// 1 回目の登録(成功)
class MyButton extends HTMLElement {
  constructor() {
    super();
  }
}
customElements.define('my-button', MyButton);
javascript// 2 回目の登録(エラー発生)
class MyButton extends HTMLElement {
  constructor() {
    super();
    this.textContent = 'Updated Button';
  }
}
customElements.define('my-button', MyButton);
// Error: Failed to execute 'define' on 'CustomElementRegistry':
// the name "my-button" has already been used with this registry

このエラーメッセージは、すでに同じ名前の要素が登録されていることを示しています。

ケース 2:定義前の要素使用

カスタム要素をクラス定義前に使おうとすると、constructor が存在しないというエラーになります。

javascript// まだ定義していない要素を使用しようとする
const button = new MyButton(); // ReferenceError or TypeError

// この時点で MyButton クラスは存在しない
javascript// 後から定義
class MyButton extends HTMLElement {
  constructor() {
    super();
  }
}
customElements.define('my-button', MyButton);

JavaScript の実行順序により、定義前にアクセスしようとしてエラーになるケースです。

ケース 3:モジュール間の循環参照

複数のファイルが互いに依存し合うと、予期しない実行順序になることがあります。

javascript// button.js
import { MyCard } from './card.js';

export class MyButton extends HTMLElement {
  constructor() {
    super();
  }
}
customElements.define('my-button', MyButton);
javascript// card.js
import { MyButton } from './button.js';

export class MyCard extends HTMLElement {
  constructor() {
    super();
    // MyButton を内部で使用
  }
}
customElements.define('my-card', MyCard);

上記のような循環参照では、どちらかのクラスが未定義の状態で実行され、エラーにつながります。

以下の図で、エラーが発生する典型的なパターンを視覚化しましょう。

mermaidflowchart TB
    subgraph pattern1["パターン 1:重複登録"]
        reg1["1 回目: define('my-btn', ClassA)"] -->|"成功"| storage1[("Registry に登録")]
        reg2["2 回目: define('my-btn', ClassB)"] -->|"同名検出"| error1["❌ DOMException"]
    end

    subgraph pattern2["パターン 2:定義前使用"]
        use["new MyButton()"] -->|"クラス未定義"| error2["❌ ReferenceError"]
        error2 -.->|"後から定義"| define["class MyButton {...}"]
    end

    subgraph pattern3["パターン 3:循環参照"]
        fileA["button.js"] -->|"import card.js"| fileB["card.js"]
        fileB -->|"import button.js"| fileA
        fileA -.->|"初期化順序が不定"| error3["❌ Constructor なし"]
    end

図で理解できる要点:

  • 同じ名前での再登録は CustomElementRegistry が拒否する
  • 定義前の使用は、クラス自体が存在しないため失敗する
  • 循環参照は、初期化順序が保証されずエラーになる

エラーメッセージの種類と意味

実際のエラーメッセージを理解することで、原因の特定が早くなります。

#エラーコードエラーメッセージ原因
1DOMExceptionFailed to execute 'define' on 'CustomElementRegistry': the name "xxx" has already been used同じ名前での複数回登録
2TypeErrorxxx is not a constructor未定義のクラスを new しようとした
3ReferenceErrorxxx is not definedクラス自体が存在しない
4TypeErrorCannot read property 'prototype' of undefinedクラスが undefined になっている

これらのエラーメッセージを見分けることで、問題の箇所を素早く特定できますね。

解決策

解決策 1:登録前のチェック処理

カスタム要素を登録する前に、すでに登録されているかを確認する方法が最も確実です。

javascript// 登録状態をチェックする関数
function defineCustomElement(name, constructor) {
  // すでに登録されているかチェック
  if (!customElements.get(name)) {
    customElements.define(name, constructor);
    console.log(`✓ ${name} を登録しました`);
  } else {
    console.warn(`⚠ ${name} はすでに登録済みです`);
  }
}

上記の関数では、customElements.get() メソッドで登録状態を確認してから、未登録の場合のみ define() を実行しています。

javascript// 実際の使用例
class MyButton extends HTMLElement {
  constructor() {
    super();
  }
}

defineCustomElement('my-button', MyButton);

// 2 回目の呼び出しでもエラーにならない
defineCustomElement('my-button', MyButton);
// Console: ⚠ my-button はすでに登録済みです

この方法により、複数回の登録試行があってもエラーを防げます。

解決策 2:whenDefined を使った非同期待機

customElements.whenDefined() メソッドを使うと、要素が登録されるまで待機できます。

javascript// カスタム要素が登録されるまで待機
async function waitForElement(name) {
  try {
    await customElements.whenDefined(name);
    console.log(`✓ ${name} の登録を確認しました`);
    return customElements.get(name);
  } catch (error) {
    console.error(`✗ ${name} の登録に失敗:`, error);
  }
}

この関数は Promise を返すため、async/await で使えます。

javascript// 使用例
async function initialize() {
  // my-button の登録を待つ
  const MyButtonClass = await waitForElement('my-button');

  // 登録後に安全にインスタンス化
  const button = new MyButtonClass();
  document.body.appendChild(button);
}

initialize();

非同期処理により、定義のタイミングを気にせず安全に要素を使用できるのです。

解決策 3:モジュールの依存関係整理

循環参照を避けるため、ファイル構成を見直すことも重要です。

javascript// base-element.js(共通の基底クラス)
export class BaseElement extends HTMLElement {
  constructor() {
    super();
  }

  // 共通メソッド
  log(message) {
    console.log(`[${this.tagName}] ${message}`);
  }
}
javascript// button.js(button のみを定義)
import { BaseElement } from './base-element.js';

export class MyButton extends BaseElement {
  constructor() {
    super();
    this.log('Button created');
  }
}

// 自身で登録
if (!customElements.get('my-button')) {
  customElements.define('my-button', MyButton);
}
javascript// card.js(card のみを定義)
import { BaseElement } from './base-element.js';

export class MyCard extends BaseElement {
  constructor() {
    super();
    this.log('Card created');
  }
}

// 自身で登録
if (!customElements.get('my-card')) {
  customElements.define('my-card', MyCard);
}

上記のように、共通の基底クラスを分離し、各コンポーネントが独立して登録できる構造にします。

以下の図で、解決策の実装パターンを確認しましょう。

mermaidflowchart TB
    start["要素登録開始"] --> check{"customElements.get(name)<br/>で登録済み?"}
    check -->|"未登録"| define["customElements.define()"]
    check -->|"登録済み"| skip["登録をスキップ"]
    define --> success["✓ 登録完了"]
    skip --> reuse["既存定義を再利用"]

    subgraph async["非同期待機パターン"]
        wait["whenDefined() で待機"] -->|"Promise resolve"| use["要素を使用"]
    end

    subgraph module["モジュール分離パターン"]
        base["base-element.js<br/>(共通基底)"] --> btn["button.js"]
        base --> card["card.js"]
        btn -.->|"循環参照なし"| card
    end

図で理解できる要点:

  • 登録前チェックにより、重複登録を防止できる
  • 非同期待機により、定義順序に依存しない実装が可能
  • モジュール分離により、循環参照を根本から解消できる

解決策 4:TypeScript での型安全な実装

TypeScript を使うと、型チェックにより実行前にエラーを検出できます。

typescript// custom-element-registry.d.ts(型定義)
interface CustomElementConstructor {
  new (): HTMLElement;
}

interface SafeCustomElementRegistry {
  define(
    name: string,
    constructor: CustomElementConstructor
  ): void;
  get(name: string): CustomElementConstructor | undefined;
  whenDefined(
    name: string
  ): Promise<CustomElementConstructor>;
}
typescript// safe-registry.ts(型安全なラッパー)
class SafeRegistry implements SafeCustomElementRegistry {
  private registry: CustomElementRegistry;

  constructor() {
    this.registry = customElements;
  }

  define(
    name: string,
    constructor: CustomElementConstructor
  ): void {
    if (!this.get(name)) {
      this.registry.define(name, constructor);
    }
  }

  get(name: string): CustomElementConstructor | undefined {
    return this.registry.get(name);
  }

  async whenDefined(
    name: string
  ): Promise<CustomElementConstructor> {
    await this.registry.whenDefined(name);
    return this.registry.get(name)!;
  }
}

export const safeRegistry = new SafeRegistry();
typescript// 使用例
import { safeRegistry } from './safe-registry.js';

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

// 型安全な登録
safeRegistry.define('my-button', MyButton);

TypeScript の型システムにより、コンパイル時に多くのエラーを防げるのです。

具体例

実践例 1:SPA での動的コンポーネント読み込み

シングルページアプリケーション(SPA)では、コンポーネントを動的に読み込むケースが多くあります。

javascript// component-loader.js
class ComponentLoader {
  constructor() {
    this.loadedComponents = new Set();
  }

  // コンポーネントを安全に読み込む
  async loadComponent(name, path) {
    // すでに読み込み済みかチェック
    if (this.loadedComponents.has(name)) {
      console.log(`${name} は読み込み済みです`);
      return customElements.get(name);
    }

    try {
      // モジュールを動的インポート
      const module = await import(path);

      // カスタム要素の登録を待機
      await customElements.whenDefined(name);

      // 読み込み完了を記録
      this.loadedComponents.add(name);

      return customElements.get(name);
    } catch (error) {
      console.error(`${name} の読み込みに失敗:`, error);
      throw error;
    }
  }
}

export const loader = new ComponentLoader();

上記のローダーは、コンポーネントの重複読み込みを防ぎ、安全に動的インポートを行います。

javascript// アプリケーションでの使用
import { loader } from './component-loader.js';

async function initializeApp() {
  try {
    // 必要なコンポーネントを読み込む
    await loader.loadComponent(
      'my-button',
      './components/button.js'
    );
    await loader.loadComponent(
      'my-card',
      './components/card.js'
    );

    console.log('すべてのコンポーネントを読み込みました');

    // コンポーネントを使用
    const button = document.createElement('my-button');
    document.body.appendChild(button);
  } catch (error) {
    console.error('初期化に失敗:', error);
  }
}

initializeApp();

この実装により、コンポーネントの読み込みタイミングを制御し、エラーを防げます。

実践例 2:開発環境での HMR 対応

Hot Module Replacement(HMR)を使う開発環境では、コードの変更時にモジュールが再読み込みされます。

javascript// button.js(HMR 対応版)
class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 10px 20px;
          background: #007bff;
          color: white;
          border: none;
          border-radius: 4px;
          cursor: pointer;
        }
      </style>
      <button><slot>Click me</slot></button>
    `;
  }
}
javascript// HMR 対応の登録処理
const ELEMENT_NAME = 'my-button';

// 既存の定義を取得
const existingClass = customElements.get(ELEMENT_NAME);

if (existingClass) {
  // すでに登録済みの場合は、既存のインスタンスを更新
  console.log(`${ELEMENT_NAME} を更新します`);

  // すべてのインスタンスを取得
  const instances = document.querySelectorAll(ELEMENT_NAME);

  // 各インスタンスを再レンダリング
  instances.forEach((instance) => {
    if (instance.render) {
      instance.render();
    }
  });
} else {
  // 初回登録
  customElements.define(ELEMENT_NAME, MyButton);
  console.log(`${ELEMENT_NAME} を登録しました`);
}

// Vite の HMR API を使用
if (import.meta.hot) {
  import.meta.hot.accept(() => {
    console.log('HMR: モジュールが更新されました');
  });
}

上記のコードでは、HMR によるリロード時に既存のインスタンスを更新し、エラーを回避しています。

実践例 3:マイクロフロントエンドでの統合

複数のチームが独立してコンポーネントを開発するマイクロフロントエンドでは、名前の衝突が起きやすくなります。

javascript// registry-manager.js
class RegistryManager {
  constructor() {
    this.prefixMap = new Map();
  }

  // チームごとのプレフィックスを登録
  registerTeam(teamName, prefix) {
    if (this.prefixMap.has(teamName)) {
      console.warn(
        `チーム ${teamName} はすでに登録済みです`
      );
      return;
    }
    this.prefixMap.set(teamName, prefix);
  }

  // プレフィックス付きの名前を生成
  createElementName(teamName, componentName) {
    const prefix = this.prefixMap.get(teamName);
    if (!prefix) {
      throw new Error(`チーム ${teamName} は未登録です`);
    }
    return `${prefix}-${componentName}`;
  }

  // 安全に要素を定義
  defineElement(teamName, componentName, constructor) {
    const fullName = this.createElementName(
      teamName,
      componentName
    );

    if (customElements.get(fullName)) {
      console.warn(`${fullName} はすでに定義済みです`);
      return false;
    }

    customElements.define(fullName, constructor);
    console.log(
      `✓ ${fullName} を登録しました(チーム: ${teamName})`
    );
    return true;
  }
}

export const manager = new RegistryManager();
javascript// team-a/button.js(チーム A のコンポーネント)
import { manager } from '../registry-manager.js';

manager.registerTeam('team-a', 'ta');

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

// 自動的に "ta-button" として登録される
manager.defineElement('team-a', 'button', TeamAButton);
javascript// team-b/button.js(チーム B のコンポーネント)
import { manager } from '../registry-manager.js';

manager.registerTeam('team-b', 'tb');

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

// 自動的に "tb-button" として登録される(衝突しない)
manager.defineElement('team-b', 'button', TeamBButton);

この実装により、複数チームが同じコンポーネント名を使っても、プレフィックスで区別できるのです。

html<!-- HTML での使用 -->
<!DOCTYPE html>
<html>
  <body>
    <!-- チーム A のボタン -->
    <ta-button>Team A Button</ta-button>

    <!-- チーム B のボタン -->
    <tb-button>Team B Button</tb-button>

    <script type="module" src="team-a/button.js"></script>
    <script type="module" src="team-b/button.js"></script>
  </body>
</html>

以下の図で、マイクロフロントエンドでの名前空間管理を視覚化しましょう。

mermaidflowchart TB
    teamA["チーム A"] -->|"prefix: ta"| regA["registerTeam('team-a', 'ta')"]
    teamB["チーム B"] -->|"prefix: tb"| regB["registerTeam('team-b', 'tb')"]

    regA --> defineA["defineElement('team-a', 'button', ClassA)"]
    regB --> defineB["defineElement('team-b', 'button', ClassB)"]

    defineA -->|"自動生成"| nameA["ta-button"]
    defineB -->|"自動生成"| nameB["tb-button"]

    nameA --> registry["CustomElementRegistry"]
    nameB --> registry

    registry -->|"衝突なし"| html["HTML で使用<br/>&lt;ta-button&gt;<br/>&lt;tb-button&gt;"]

図で理解できる要点:

  • 各チームが固有のプレフィックスを持つ
  • コンポーネント名に自動でプレフィックスが付与される
  • レジストリ内で名前の衝突が発生しない

実践例 4:テスト環境でのモック対応

ユニットテストでは、カスタム要素のモックが必要になることがあります。

javascript// test-utils.js
export class TestRegistry {
  constructor() {
    this.definitions = new Map();
    this.originalRegistry = customElements;
  }

  // テスト用の要素を登録
  defineForTest(name, constructor) {
    // テスト用の一時的な定義
    this.definitions.set(name, constructor);

    // 実際のレジストリには登録しない
    console.log(`[TEST] ${name} をモック登録しました`);
  }

  // テスト後のクリーンアップ
  cleanup() {
    this.definitions.clear();
    console.log(
      '[TEST] レジストリをクリーンアップしました'
    );
  }

  // モックされた要素を取得
  get(name) {
    return (
      this.definitions.get(name) ||
      this.originalRegistry.get(name)
    );
  }
}
javascript// button.test.js
import { TestRegistry } from './test-utils.js';

describe('MyButton', () => {
  let testRegistry;

  beforeEach(() => {
    // 各テスト前にレジストリを初期化
    testRegistry = new TestRegistry();
  });

  afterEach(() => {
    // テスト後にクリーンアップ
    testRegistry.cleanup();
  });

  test('ボタンが正しく生成される', () => {
    // モックの定義
    class MockButton extends HTMLElement {
      constructor() {
        super();
        this.clicked = false;
      }

      click() {
        this.clicked = true;
      }
    }

    // テスト用に登録
    testRegistry.defineForTest('my-button', MockButton);

    // テスト実行
    const ButtonClass = testRegistry.get('my-button');
    const button = new ButtonClass();

    button.click();
    expect(button.clicked).toBe(true);
  });

  test('複数回のテストで衝突しない', () => {
    // 別のモック定義
    class AnotherMockButton extends HTMLElement {
      constructor() {
        super();
        this.value = 'test';
      }
    }

    // 再度登録してもエラーにならない
    testRegistry.defineForTest(
      'my-button',
      AnotherMockButton
    );

    const ButtonClass = testRegistry.get('my-button');
    const button = new ButtonClass();

    expect(button.value).toBe('test');
  });
});

このテストユーティリティにより、各テストケースで独立したカスタム要素を使用できます。

まとめ

Web Components の「is not a constructor」エラーは、カスタム要素の登録メカニズムを理解することで確実に防げます。本記事で紹介した解決策をまとめますと、以下のようになります。

主な原因

  • 同じ名前での複数回登録
  • 定義前の要素使用
  • モジュール間の循環参照

効果的な解決策

  • customElements.get() による登録前チェック
  • customElements.whenDefined() による非同期待機
  • モジュール構成の見直しと依存関係の整理
  • TypeScript による型安全な実装

実践的なポイント

  • SPA では ComponentLoader パターンを使う
  • HMR 環境では既存インスタンスの更新を実装する
  • マイクロフロントエンドではプレフィックス管理を導入する
  • テスト環境では専用のレジストリを用意する

これらの手法を組み合わせることで、大規模なアプリケーションでも安定して Web Components を運用できるでしょう。エラーが発生した際は、本記事のエラーメッセージ一覧表を参照し、原因を特定してくださいね。

CustomElementRegistry の仕組みを正しく理解し、適切な実装パターンを選択することが、堅牢な Web Components 開発の鍵となります。

関連リンク