T-CREATOR

Web Components の API 設計原則:属性 vs プロパティ vs メソッドの境界線

Web Components の API 設計原則:属性 vs プロパティ vs メソッドの境界線

Web Components を開発する際、最も悩ましいのが「この値は属性で渡すべきか、プロパティで渡すべきか、それともメソッドにすべきか」という API 設計の判断です。

この選択を誤ると、使いづらいコンポーネントになってしまいます。本記事では、属性・プロパティ・メソッドそれぞれの特性を理解し、適切な API 設計の判断基準を明確にしていきましょう。

背景

Web Components における 3 つの API インターフェース

Web Components は、カスタム要素として HTML に組み込まれるため、ネイティブ HTML 要素と同じように扱える必要があります。そのため、データや機能を外部に公開する方法として、主に 3 つのインターフェースが用意されています。

以下の図は、Web Components が外部とやり取りする 3 つの API インターフェースの全体像を示しています。

mermaidflowchart TB
    user["開発者"] -->|HTML で指定| attr["属性<br/>(Attributes)"]
    user -->|JavaScript で操作| prop["プロパティ<br/>(Properties)"]
    user -->|JavaScript で実行| method["メソッド<br/>(Methods)"]

    attr -->|文字列のみ| component["Web Component"]
    prop -->|あらゆる型| component
    method -->|実行トリガー| component

    component -->|状態変更| dom["DOM 更新"]

それぞれのインターフェースには明確な特性と制約があり、用途に応じて使い分けることが重要です。

属性(Attributes)の特性

属性は HTML タグ内に記述できる、最も基本的なインターフェースです。

typescript// 属性の例
<my-button disabled='true' label='送信'></my-button>

属性の最大の特徴は、文字列としてのみ扱われるという点です。true123 と書いても、内部では文字列 "true""123" として扱われます。

プロパティ(Properties)の特性

プロパティは JavaScript から直接アクセスできるインターフェースで、オブジェクトや配列など、あらゆる型のデータを扱えます。

typescript// プロパティの例
const button = document.querySelector('my-button');
button.items = [
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
];
button.config = { theme: 'dark', size: 'large' };

プロパティは JavaScript の世界で完結するため、型の制約がなく、複雑なデータ構造も扱えます。

メソッド(Methods)の特性

メソッドは、コンポーネントに対する操作やアクションを実行するためのインターフェースです。

typescript// メソッドの例
const dialog = document.querySelector('my-dialog');
dialog.show();
dialog.close();
dialog.reset();

メソッドは単なるデータの受け渡しではなく、「何かを実行する」という明確な意図を持ちます。

ネイティブ HTML 要素の設計パターン

Web Components の API 設計を考える上で、ネイティブ HTML 要素がどのように設計されているかを理解することが重要です。例えば <input> 要素を見てみましょう。

typescript// <input> 要素の API 設計例

// 属性:文字列で指定できる設定値
<input type="text" value="初期値" placeholder="入力してください">

// プロパティ:JavaScript から複雑な値を操作
const input = document.querySelector('input');
input.value = '新しい値';
input.files = fileList; // File オブジェクトの配列

// メソッド:操作の実行
input.focus();
input.select();
input.setCustomValidity('エラーメッセージ');

この設計パターンから、3 つのインターフェースの使い分けの原則が見えてきます。

課題

API 設計における判断の難しさ

Web Components の API を設計する際、開発者が直面する主な課題は以下の通りです。

課題 1:属性とプロパティの二重管理

属性とプロパティは密接に関連していますが、同期を取る必要があるため、実装が複雑になりがちです。

typescript// 同期が必要な例
class MyComponent extends HTMLElement {
  // 属性が変更されたらプロパティも更新
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'count') {
      this._count = parseInt(newValue);
    }
  }

  // プロパティが変更されたら属性も更新
  set count(value) {
    this._count = value;
    this.setAttribute('count', value.toString());
  }
}

この二重管理は、バグの温床になります。

課題 2:型変換の複雑さ

属性は文字列のみを扱うため、boolean や number、object などの型を扱う場合、変換処理が必要になります。

以下の図は、属性とプロパティ間での型変換の流れを示しています。

mermaidflowchart LR
    html["HTML 属性<br/>文字列のみ"] -->|パース| prop["プロパティ<br/>実際の型"]
    prop -->|シリアライズ| html

    subgraph conversion["型変換処理"]
        parse["文字列 → 型<br/>(parseInt, JSON.parse)"]
        serial["型 → 文字列<br/>(toString, JSON.stringify)"]
    end

特に複雑なのが boolean 型の扱いです。

typescript// boolean の変換例
<my-component disabled></my-component>  // 属性あり = true
<my-component disabled="false"></my-component>  // 文字列 "false"true!

// 正しい変換処理
attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'disabled') {
    // 属性の存在で判断(値は見ない)
    this._disabled = this.hasAttribute('disabled');
  }
}

課題 3:適切なインターフェース選択の基準不明確さ

「この機能は属性にすべきか、プロパティにすべきか、メソッドにすべきか」という判断基準が明確でないと、一貫性のない API になってしまいます。

typescript// 一貫性のない API の例(悪い例)
<my-form action='/api/submit'></my-form>; // 属性
form.validation = { required: true }; // プロパティ
form.setAttribute('submit-label', 'OK'); // 属性経由で設定

// このコンポーネントを使う開発者は混乱します

課題 4:SSR(Server-Side Rendering)との互換性

属性は HTML として静的に出力できますが、プロパティは JavaScript が実行されないと設定できません。

typescript// SSR で問題になる例
// サーバー側で出力される HTML
<my-list></my-list>

// クライアント側で必要な設定
<script>
  // JavaScript が実行されるまでデータが表示されない
  document.querySelector('my-list').items = [...];
</script>

SSR を考慮すると、可能な限り属性で初期状態を設定できるようにする必要があります。

パフォーマンスとメンテナンス性のトレードオフ

属性の変更は attributeChangedCallback をトリガーするため、頻繁に変更される値を属性にすると、パフォーマンスに影響します。

typescript// パフォーマンスに影響する例
class Counter extends HTMLElement {
  static get observedAttributes() {
    return ['count'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 毎回 DOM 更新が走る
    this.render();
  }
}

// 1秒間に60回更新する場合
setInterval(() => {
  counter.setAttribute('count', count++); // 重い
  // counter.count = count++; の方が軽量
}, 16);

解決策

判断基準 1:属性を使うべきケース

属性は、以下の条件をすべて満たす場合に使用します。

#条件理由
1文字列、数値、boolean で表現できる属性は文字列しか扱えない
2初期状態の設定に使われるHTML で宣言的に設定できる
3頻繁に変更されない属性変更はコールバックを伴うため重い
4SSR で必要サーバー側で HTML に出力できる
5外部から見える設定値DevTools で確認・デバッグしやすい

属性に適した実装例

以下は、属性として実装するのに適した例です。

typescript// 基本的な設定値を属性で定義
class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['disabled', 'variant', 'size'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'disabled':
        this._updateDisabledState();
        break;
      case 'variant':
        this._updateVariant(newValue);
        break;
      case 'size':
        this._updateSize(newValue);
        break;
    }
  }
}
html<!-- HTML で宣言的に使用 -->
<my-button disabled variant="primary" size="large">
  送信
</my-button>

このコードでは、disabledvariantsize といった、コンポーネントの基本的な外観や状態を制御する値を属性として定義しています。これらは初期設定として HTML に記述でき、かつ頻繁に変更されないため、属性として適切です。

Boolean 属性の正しい実装

Boolean 型の属性は、HTML の標準に従って「属性の存在」で真偽を判断します。

typescript// Boolean 属性の実装
class MyComponent extends HTMLElement {
  // 属性の存在で判断(値は見ない)
  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
}
html<!-- 正しい使い方 -->
<my-component disabled></my-component>

<!-- 誤った使い方(動作するが非推奨) -->
<my-component disabled="true"></my-component>
<my-component disabled="false"></my-component>
<!-- これも disabled と判定される -->

属性の値ではなく、属性が存在するかどうかで判断することで、ネイティブ HTML 要素と同じ動作になります。

判断基準 2:プロパティを使うべきケース

プロパティは、以下の条件のいずれかを満たす場合に使用します。

#条件理由
1オブジェクトや配列など複雑な型JavaScript の型をそのまま扱える
2頻繁に更新される値コールバック不要で高速
3内部状態の管理外部から直接見える必要がない
4関数やコールバック属性では扱えない

プロパティに適した実装例

以下は、プロパティとして実装するのに適した例です。

typescript// 複雑なデータ構造をプロパティで定義
class MyList extends HTMLElement {
  private _items: Item[] = [];
  private _config: Config = {};

  // オブジェクトや配列はプロパティで
  get items() {
    return this._items;
  }

  set items(value: Item[]) {
    this._items = value;
    this._render();
  }

  // 設定オブジェクトもプロパティで
  get config() {
    return this._config;
  }

  set config(value: Config) {
    this._config = { ...this._config, ...value };
    this._applyConfig();
  }
}
typescript// 使用例
const list = document.querySelector('my-list');

// 配列を直接代入
list.items = [
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
];

// オブジェクトで設定
list.config = {
  theme: 'dark',
  pageSize: 20,
};

このコードでは、配列やオブジェクトといった複雑なデータ構造をプロパティで扱っています。これらは文字列化できないため、属性では実装できません。

コールバック関数の実装

イベントハンドラやコールバック関数もプロパティとして実装します。

typescript// コールバック関数をプロパティで定義
class MyForm extends HTMLElement {
  private _onSubmit: ((data: FormData) => void) | null =
    null;

  get onSubmit() {
    return this._onSubmit;
  }

  set onSubmit(callback: (data: FormData) => void) {
    this._onSubmit = callback;
  }

  private handleSubmit() {
    const data = this._collectFormData();
    this._onSubmit?.(data);
  }
}
typescript// 使用例
const form = document.querySelector('my-form');
form.onSubmit = (data) => {
  console.log('送信データ:', data);
  // API に送信
};

コールバック関数は JavaScript の関数オブジェクトであり、属性では扱えないため、必ずプロパティで実装します。

判断基準 3:メソッドを使うべきケース

メソッドは、以下の条件のいずれかを満たす場合に使用します。

#条件理由
1アクションや操作を表す実行の意図が明確
2副作用を伴う処理状態変更が明示的
3引数が必要な処理パラメータを受け取れる
4戻り値が必要処理結果を返せる
5ユーザー操作の代替プログラムから操作をトリガー

メソッドに適した実装例

以下は、メソッドとして実装するのに適した例です。

typescript// 操作やアクションをメソッドで定義
class MyDialog extends HTMLElement {
  // 表示する
  show() {
    this.setAttribute('open', '');
    this._updateDisplay();
    this.dispatchEvent(new CustomEvent('show'));
  }

  // 閉じる
  close() {
    this.removeAttribute('open');
    this._updateDisplay();
    this.dispatchEvent(new CustomEvent('close'));
  }

  // フォームをリセット
  reset() {
    this._clearInputs();
    this.dispatchEvent(new CustomEvent('reset'));
  }

  // データを取得(戻り値あり)
  getData(): FormData {
    return this._collectData();
  }
}
typescript// 使用例
const dialog = document.querySelector('my-dialog');

// アクションの実行
dialog.show();

// データ取得
const data = dialog.getData();
console.log(data);

// 閉じる
dialog.close();

このコードでは、show()close()reset() といった操作をメソッドとして定義しています。これらは単なる値の設定ではなく、「何かを実行する」という明確な意図を持つため、メソッドとして実装するのが適切です。

引数を受け取るメソッド

引数が必要な処理もメソッドとして実装します。

typescript// 引数を受け取るメソッド
class MyChart extends HTMLElement {
  // データを追加
  addData(label: string, value: number) {
    this._data.push({ label, value });
    this._updateChart();
  }

  // 範囲を設定
  setRange(min: number, max: number) {
    this._range = { min, max };
    this._updateChart();
  }

  // 特定のデータを削除
  removeData(index: number) {
    this._data.splice(index, 1);
    this._updateChart();
  }
}
typescript// 使用例
const chart = document.querySelector('my-chart');

// データを追加
chart.addData('2024-01', 100);
chart.addData('2024-02', 150);

// 範囲を設定
chart.setRange(0, 200);

複数の引数を受け取る必要がある場合、プロパティではなくメソッドを使うことで、より明確な API になります。

属性とプロパティの同期パターン

属性とプロパティの両方を提供する場合、適切に同期を取る必要があります。

パターン 1:属性駆動型

属性を真の値とし、プロパティは属性へのアクセサとして機能させます。

typescript// 属性駆動型の実装
class MyComponent extends HTMLElement {
  static get observedAttributes() {
    return ['label'];
  }

  // プロパティは属性へのアクセサ
  get label() {
    return this.getAttribute('label') || '';
  }

  set label(value: string) {
    this.setAttribute('label', value);
  }

  // 属性変更時に処理
  attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ) {
    if (name === 'label') {
      this._updateLabel(newValue);
    }
  }
}

このパターンは、シンプルな値で、かつ属性として公開したい場合に適しています。

パターン 2:プロパティ駆動型

プロパティを真の値とし、属性は初期化時のみ使用します。

typescript// プロパティ駆動型の実装
class MyComponent extends HTMLElement {
  private _count: number = 0;

  static get observedAttributes() {
    return ['count'];
  }

  // プロパティが真の値
  get count() {
    return this._count;
  }

  set count(value: number) {
    const oldValue = this._count;
    this._count = value;
    // 属性は更新しない(または必要に応じて更新)
    this._updateDisplay(oldValue, value);
  }

  // 属性は初期化時のみ使用
  attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ) {
    if (name === 'count' && oldValue === null) {
      this._count = parseInt(newValue) || 0;
    }
  }
}

このパターンは、頻繁に更新される値で、パフォーマンスを重視する場合に適しています。

以下の図は、2 つの同期パターンの違いを示しています。

mermaidflowchart TB
    subgraph attr_driven["属性駆動型"]
        a1["属性"] -->|変更| a2["attributeChangedCallback"]
        a2 --> a3["内部状態更新"]
        a4["プロパティ setter"] -->|setAttribute| a1
    end

    subgraph prop_driven["プロパティ駆動型"]
        p1["プロパティ"] -->|変更| p2["内部状態更新"]
        p3["属性"] -->|初期化時のみ| p1
    end

実装のベストプラクティス

ベストプラクティス 1:ネイティブ要素を参考にする

迷ったときは、ネイティブ HTML 要素の設計を参考にしましょう。

typescript// <input> 要素を参考にした設計
class MyInput extends HTMLElement {
  // 属性:文字列で表現できる基本設定
  static get observedAttributes() {
    return ['type', 'placeholder', 'disabled'];
  }

  // プロパティ:JavaScript から操作する値
  get value() {
    /* ... */
  }
  set value(val) {
    /* ... */
  }

  get files() {
    /* ... */
  } // File[] 型はプロパティのみ

  // メソッド:操作やアクション
  focus() {
    /* ... */
  }
  select() {
    /* ... */
  }
  setCustomValidity(message: string) {
    /* ... */
  }
}

ベストプラクティス 2:TypeScript で型安全性を確保

TypeScript を使用することで、プロパティの型を明確にできます。

typescript// 型定義を明確にする
interface Item {
  id: number;
  name: string;
}

interface Config {
  theme: 'light' | 'dark';
  pageSize: number;
}

class MyList extends HTMLElement {
  private _items: Item[] = [];
  private _config: Config = {
    theme: 'light',
    pageSize: 10,
  };

  get items(): Item[] {
    return this._items;
  }

  set items(value: Item[]) {
    this._items = value;
    this._render();
  }

  get config(): Config {
    return this._config;
  }

  set config(value: Partial<Config>) {
    this._config = { ...this._config, ...value };
    this._applyConfig();
  }
}

型定義により、間違った型の値を設定するミスを防げます。

ベストプラクティス 3:属性の反映を制御する

observedAttributes で監視する属性を明示的に定義し、不要な更新を防ぎます。

typescript// 必要な属性のみ監視
class MyComponent extends HTMLElement {
  // 監視する属性を明示
  static get observedAttributes() {
    return ['disabled', 'label']; // 必要最小限に
  }

  attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ) {
    // 値が実際に変わった時のみ処理
    if (oldValue === newValue) return;

    switch (name) {
      case 'disabled':
        this._updateDisabled();
        break;
      case 'label':
        this._updateLabel(newValue);
        break;
    }
  }
}

具体例

例 1:Button コンポーネントの API 設計

実際の Button コンポーネントで、3 つのインターフェースをどう使い分けるか見ていきましょう。

属性の設計

Button の基本的な設定は属性で定義します。

typescript// 属性の定義
class MyButton extends HTMLElement {
  static get observedAttributes() {
    return [
      'disabled', // boolean: 無効化状態
      'variant', // string: ボタンの種類
      'size', // string: サイズ
      'type', // string: ボタンタイプ
    ];
  }

  attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ) {
    switch (name) {
      case 'disabled':
        this._updateDisabled();
        break;
      case 'variant':
        this._updateVariant(newValue);
        break;
      case 'size':
        this._updateSize(newValue);
        break;
      case 'type':
        this._updateType(newValue);
        break;
    }
  }

  // Disabled の実装(boolean 属性)
  get disabled(): boolean {
    return this.hasAttribute('disabled');
  }

  set disabled(value: boolean) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
}
html<!-- HTML での使用 -->
<my-button
  disabled
  variant="primary"
  size="large"
  type="submit"
>
  送信
</my-button>

属性により、HTML で宣言的に設定できるため、SSR にも対応できます。

プロパティの設計

イベントハンドラなど、JavaScript でのみ設定する値はプロパティで定義します。

typescript// プロパティの定義
class MyButton extends HTMLElement {
  private _onClick: ((e: Event) => void) | null = null;
  private _icon: HTMLElement | null = null;

  // イベントハンドラ(関数)
  get onClick() {
    return this._onClick;
  }

  set onClick(callback: (e: Event) => void) {
    if (this._onClick) {
      this.removeEventListener('click', this._onClick);
    }
    this._onClick = callback;
    if (callback) {
      this.addEventListener('click', callback);
    }
  }

  // アイコン要素(HTMLElement)
  get icon() {
    return this._icon;
  }

  set icon(element: HTMLElement | null) {
    this._icon = element;
    this._updateIcon();
  }
}
typescript// JavaScript での使用
const button = document.querySelector('my-button');

// コールバック関数を設定
button.onClick = (e) => {
  console.log('クリックされました', e);
};

// アイコン要素を設定
const icon = document.createElement('i');
icon.className = 'icon-send';
button.icon = icon;

関数や DOM 要素は属性では扱えないため、プロパティで実装します。

メソッドの設計

ボタンの操作に関する機能はメソッドで定義します。

typescript// メソッドの定義
class MyButton extends HTMLElement {
  // フォーカスを当てる
  focus() {
    this._button?.focus();
  }

  // クリックをトリガー
  click() {
    this._button?.click();
  }

  // ローディング状態にする
  setLoading(isLoading: boolean) {
    if (isLoading) {
      this.setAttribute('loading', '');
      this.disabled = true;
    } else {
      this.removeAttribute('loading');
      this.disabled = false;
    }
  }
}
typescript// 使用例
const button = document.querySelector('my-button');

// フォーカスを当てる
button.focus();

// プログラムからクリック
button.click();

// ローディング状態にする
button.setLoading(true);

// 非同期処理の例
async function handleSubmit() {
  button.setLoading(true);
  try {
    await submitForm();
  } finally {
    button.setLoading(false);
  }
}

メソッドを使うことで、複数の状態を同時に変更する操作を 1 つのメソッドにまとめられます。

例 2:Data Table コンポーネントの API 設計

より複雑な Data Table コンポーネントで、API 設計の全体像を見ていきましょう。

属性の設計

テーブルの基本的な表示設定を属性で定義します。

typescript// 属性の定義
class MyDataTable extends HTMLElement {
  static get observedAttributes() {
    return [
      'striped', // boolean: 縞模様
      'hoverable', // boolean: ホバーエフェクト
      'page-size', // number: ページサイズ
      'current-page', // number: 現在のページ
      'sort-column', // string: ソート列
      'sort-direction', // string: ソート方向
    ];
  }

  // Boolean 属性
  get striped(): boolean {
    return this.hasAttribute('striped');
  }

  set striped(value: boolean) {
    if (value) {
      this.setAttribute('striped', '');
    } else {
      this.removeAttribute('striped');
    }
  }

  // Number 属性
  get pageSize(): number {
    return parseInt(this.getAttribute('page-size') || '10');
  }

  set pageSize(value: number) {
    this.setAttribute('page-size', value.toString());
  }
}
html<!-- HTML での使用 -->
<my-data-table
  striped
  hoverable
  page-size="20"
  current-page="1"
  sort-column="name"
  sort-direction="asc"
>
</my-data-table>

プロパティの設計

テーブルのデータや設定オブジェクトはプロパティで定義します。

typescript// プロパティの定義
interface Column {
  key: string;
  label: string;
  sortable?: boolean;
  width?: string;
}

interface DataItem {
  [key: string]: any;
}

class MyDataTable extends HTMLElement {
  private _data: DataItem[] = [];
  private _columns: Column[] = [];
  private _onRowClick: ((item: DataItem) => void) | null =
    null;

  // データ配列
  get data(): DataItem[] {
    return this._data;
  }

  set data(value: DataItem[]) {
    this._data = value;
    this._render();
  }

  // カラム定義
  get columns(): Column[] {
    return this._columns;
  }

  set columns(value: Column[]) {
    this._columns = value;
    this._renderHeader();
  }

  // 行クリックハンドラ
  get onRowClick() {
    return this._onRowClick;
  }

  set onRowClick(callback: (item: DataItem) => void) {
    this._onRowClick = callback;
  }
}
typescript// JavaScript での使用
const table = document.querySelector('my-data-table');

// カラム定義を設定
table.columns = [
  { key: 'id', label: 'ID', sortable: true, width: '80px' },
  { key: 'name', label: '名前', sortable: true },
  { key: 'email', label: 'メール' },
];

// データを設定
table.data = [
  { id: 1, name: '山田太郎', email: 'yamada@example.com' },
  { id: 2, name: '佐藤花子', email: 'sato@example.com' },
];

// 行クリックイベント
table.onRowClick = (item) => {
  console.log('選択された行:', item);
};

配列やオブジェクトは属性では扱えないため、プロパティで実装しています。

メソッドの設計

テーブルの操作に関する機能をメソッドで定義します。

typescript// メソッドの定義
class MyDataTable extends HTMLElement {
  // ソートを実行
  sort(column: string, direction: 'asc' | 'desc' = 'asc') {
    this.setAttribute('sort-column', column);
    this.setAttribute('sort-direction', direction);
    this._sortData(column, direction);
    this._render();
  }

  // ページ移動
  goToPage(page: number) {
    const maxPage = Math.ceil(
      this._data.length / this.pageSize
    );
    if (page < 1 || page > maxPage) return;

    this.setAttribute('current-page', page.toString());
    this._render();
  }

  // データをリフレッシュ
  refresh() {
    this._render();
  }

  // 選択行を取得
  getSelectedRows(): DataItem[] {
    return this._selectedItems;
  }

  // すべて選択/解除
  selectAll(selected: boolean = true) {
    this._selectedItems = selected ? [...this._data] : [];
    this._updateSelection();
  }
}
typescript// 使用例
const table = document.querySelector('my-data-table');

// ソート実行
table.sort('name', 'desc');

// ページ移動
table.goToPage(2);

// 選択された行を取得
const selected = table.getSelectedRows();
console.log('選択された行:', selected);

// すべて選択
table.selectAll(true);

操作や状態の取得といった機能をメソッドで提供することで、使いやすい API になります。

例 3:Dialog コンポーネントの API 設計

Dialog コンポーネントでは、開閉の状態管理が重要になります。

属性とプロパティの組み合わせ

typescript// Dialog の実装
class MyDialog extends HTMLElement {
  static get observedAttributes() {
    return [
      'open', // boolean: 開閉状態
      'modal', // boolean: モーダルか否か
      'close-on-esc', // boolean: ESC で閉じるか
      'close-on-overlay', // boolean: オーバーレイクリックで閉じるか
    ];
  }

  // open 属性(状態を表す)
  get open(): boolean {
    return this.hasAttribute('open');
  }

  set open(value: boolean) {
    if (value) {
      this.setAttribute('open', '');
    } else {
      this.removeAttribute('open');
    }
  }

  // 設定オブジェクト(プロパティ)
  private _config = {
    onOpen: null as (() => void) | null,
    onClose: null as (() => void) | null,
  };

  get config() {
    return this._config;
  }

  set config(value: Partial<typeof this._config>) {
    this._config = { ...this._config, ...value };
  }
}

メソッドによる操作

typescript// Dialog のメソッド
class MyDialog extends HTMLElement {
  // 開く
  show() {
    this.open = true;
    this._config.onOpen?.();
    this.dispatchEvent(new CustomEvent('open'));
  }

  // 閉じる
  close() {
    this.open = false;
    this._config.onClose?.();
    this.dispatchEvent(new CustomEvent('close'));
  }

  // トグル
  toggle() {
    if (this.open) {
      this.close();
    } else {
      this.show();
    }
  }

  // 確認ダイアログとして使う
  async confirm(message: string): Promise<boolean> {
    return new Promise((resolve) => {
      this._setMessage(message);
      this.show();

      const handleConfirm = () => {
        this.close();
        resolve(true);
        cleanup();
      };

      const handleCancel = () => {
        this.close();
        resolve(false);
        cleanup();
      };

      const cleanup = () => {
        this.removeEventListener('confirm', handleConfirm);
        this.removeEventListener('cancel', handleCancel);
      };

      this.addEventListener('confirm', handleConfirm);
      this.addEventListener('cancel', handleCancel);
    });
  }
}
typescript// 使用例
const dialog = document.querySelector('my-dialog');

// 設定
dialog.config = {
  onOpen: () => console.log('ダイアログが開きました'),
  onClose: () => console.log('ダイアログが閉じました'),
};

// 開く
dialog.show();

// 確認ダイアログ
const result = await dialog.confirm('本当に削除しますか?');
if (result) {
  console.log('削除が確認されました');
}

この実装では、open 属性で状態を管理し、show()close() メソッドで操作を実行し、config プロパティで設定を行うという、3 つのインターフェースの役割分担が明確になっています。

以下の図は、Dialog コンポーネントにおける 3 つの API の役割分担を示しています。

mermaidflowchart TB
    subgraph attrs["属性(状態の表現)"]
        a1["open: 開閉状態"]
        a2["modal: モーダルか"]
        a3["close-on-esc: ESC で閉じる"]
    end

    subgraph props["プロパティ(設定)"]
        p1["config: 設定オブジェクト"]
        p2["onOpen: コールバック"]
        p3["onClose: コールバック"]
    end

    subgraph methods["メソッド(操作)"]
        m1["show(): 開く"]
        m2["close(): 閉じる"]
        m3["toggle(): 切り替え"]
        m4["confirm(): 確認"]
    end

    methods --> attrs
    props --> methods

API 設計のチェックリスト

最後に、Web Components の API を設計する際のチェックリストを示します。

#確認項目判断基準
1この値は文字列で表現できるか?Yes → 属性を検討 / No → プロパティ
2HTML で宣言的に設定する必要があるか?Yes → 属性 / No → プロパティ
3頻繁に更新される値か?Yes → プロパティ / No → 属性も可
4オブジェクトや配列を扱うか?Yes → プロパティのみ
5関数やコールバックか?Yes → プロパティのみ
6操作やアクションか?Yes → メソッド
7引数が必要か?Yes → メソッド
8戻り値が必要か?Yes → メソッド
9副作用を伴うか?Yes → メソッド
10ネイティブ要素はどう実装しているか?参考にする

このチェックリストに沿って判断することで、一貫性のある API 設計ができます。

まとめ

Web Components の API 設計における属性・プロパティ・メソッドの使い分けは、それぞれの特性を理解することで明確になります。

属性は、文字列で表現できる基本的な設定値に使い、HTML で宣言的に記述できるというメリットがあります。プロパティは、オブジェクトや配列など複雑な型を扱う場合や、頻繁に更新される値に適しています。メソッドは、操作やアクションを表現し、引数や戻り値が必要な処理に使います。

これらの判断基準を意識して API を設計することで、ネイティブ HTML 要素と同じように直感的に使える Web Components を作ることができるでしょう。迷ったときは、常にネイティブ要素の設計を参考にすることをおすすめします。

適切な API 設計により、開発者にとって使いやすく、メンテナンスしやすいコンポーネントライブラリを構築できます。

関連リンク