T-CREATOR

Vue.js コンポーネント API 設計:props/emit/slot を最小 API でまとめる

Vue.js コンポーネント API 設計:props/emit/slot を最小 API でまとめる

Vue.js でコンポーネントを設計する際、親子間のやり取りをどう設計するかは非常に重要です。props でデータを渡し、emit でイベントを通知し、slot でコンテンツを差し込む——この 3 つの API を最小限に保つことで、メンテナンス性が高く、理解しやすいコンポーネントを実現できます。

この記事では、props・emit・slot の基本から、実際のプロジェクトで使える設計パターン、そして「API を増やしすぎない」ための考え方までを、初心者の方にもわかりやすく解説していきますね。

背景

Vue.js のコンポーネントは、再利用可能な UI のパーツとして設計されています。コンポーネント同士が連携するために、Vue は次の 3 つの基本 API を提供しています。

  • props:親から子へデータを渡す
  • emit:子から親へイベントを通知する
  • slot:親から子へ HTML やコンポーネントを差し込む

この 3 つを組み合わせることで、柔軟かつ疎結合なコンポーネント設計が可能になるのです。

Vue.js コンポーネント連携の基本構造

以下の図は、親コンポーネントと子コンポーネントの間でデータやイベント、コンテンツがどのように流れるかを示しています。

mermaidflowchart TB
  parent["親コンポーネント"]
  child["子コンポーネント"]

  parent -->|"props<br/>(データ渡し)"| child
  child -->|"emit<br/>(イベント通知)"| parent
  parent -.->|"slot<br/>(コンテンツ差し込み)"| child

図の要点

  • props は親 → 子の一方向データフロー
  • emit は子 → 親のイベント通知
  • slot は親が子の内部構造を柔軟にカスタマイズできる仕組み

このように、各 API には明確な役割があり、それぞれを適切に使い分けることが、シンプルで保守しやすいコンポーネント設計の第一歩となります。

課題

Vue.js のコンポーネント設計では、以下のような課題に直面することがよくあります。

props が増えすぎて管理が複雑になる

コンポーネントの機能を増やすたびに props を追加していくと、親コンポーネントから渡すデータが膨大になり、どの props が必須でどれがオプションなのか分かりにくくなります。

emit イベントが多すぎて追いづらい

イベント名が統一されていなかったり、似たようなイベントが乱立したりすると、親コンポーネント側で「どのイベントをハンドリングすれば良いのか」が不明確になりますね。

slot の使いどころが曖昧

slot を使いすぎると、コンポーネントの責任範囲が曖昧になり、逆にメンテナンス性が下がることもあります。どこまでを slot で柔軟にし、どこまでを固定するかのバランスが難しいのです。

API 設計の複雑さがもたらす影響

以下の図は、API が増えすぎた場合の影響をフローチャートで示しています。

mermaidflowchart LR
  start["コンポーネント設計開始"]
  add_props["props 追加"]
  add_emit["emit 追加"]
  add_slot["slot 追加"]
  complex["API が複雑化"]
  hard_to_maintain["保守性低下"]
  hard_to_understand["理解困難"]

  start --> add_props
  start --> add_emit
  start --> add_slot
  add_props --> complex
  add_emit --> complex
  add_slot --> complex
  complex --> hard_to_maintain
  complex --> hard_to_understand

図で理解できる要点

  • props、emit、slot を無計画に追加すると、API が複雑化する
  • 複雑化したコンポーネントは保守性が低下し、理解が困難になる
  • 設計段階で API を最小限に保つ意識が重要

このような課題を解決するために、次のセクションでは「最小 API」の考え方と具体的な設計手法を紹介します。

解決策

コンポーネント API を最小限に保つためには、以下の原則に従って設計することが有効です。

props は必要最小限に絞る

props は「外部から制御したいデータ」だけに限定します。内部で完結できる状態は、コンポーネント内部で管理しましょう。

emit は意図を明確にする

イベント名は動詞で統一し、「何が起きたのか」を明確に伝えるようにします。似たようなイベントは統合し、引数で詳細を渡すと良いでしょう。

slot はコンテンツのカスタマイズに限定する

slot は「表示内容を差し替えたい場合」に使い、ロジックの制御には使わないようにします。名前付き slot を活用することで、差し込み位置を明確にできますね。

最小 API 設計の原則フロー

以下の図は、最小 API を実現するための設計判断フローを示しています。

mermaidflowchart TD
  start["新しい機能を追加したい"]
  check_internal["内部で完結できる?"]
  use_internal["内部状態で管理"]
  check_data["データを渡す必要がある?"]
  use_props["props を追加"]
  check_event["イベント通知が必要?"]
  use_emit["emit を追加"]
  check_content["コンテンツをカスタマイズしたい?"]
  use_slot["slot を追加"]
  done["設計完了"]

  start --> check_internal
  check_internal -->|"はい"| use_internal
  check_internal -->|"いいえ"| check_data
  use_internal --> done
  check_data -->|"はい"| use_props
  check_data -->|"いいえ"| check_event
  use_props --> done
  check_event -->|"はい"| use_emit
  check_event -->|"いいえ"| check_content
  use_emit --> done
  check_content -->|"はい"| use_slot
  check_content -->|"いいえ"| done
  use_slot --> done

図で理解できる要点

  • まず「内部で完結できるか」を検討する
  • 必要に応じて props、emit、slot を段階的に追加する
  • 不要な API を追加しないための判断基準が明確になる

このフローに従うことで、必要最小限の API でコンポーネントを設計できるようになります。

具体例

ここでは、ボタンコンポーネントを例に、props・emit・slot を最小限に設計する方法を段階的に見ていきましょう。

ステップ 1:最小限の props を定義する

まず、ボタンコンポーネントに必要な props を洗い出します。ここでは「種類(primary/secondary)」と「無効化フラグ」のみに絞ります。

typescript// BaseButton.vue のスクリプト部分
interface Props {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

解説: 型定義で props を明示することで、どのような値を受け取るかが一目瞭然になります。オプショナルな props にはデフォルト値を設定すると良いですね。

typescript// props のデフォルト値を設定
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  disabled: false,
});

解説withDefaults を使うことで、親コンポーネントから値が渡されなかった場合のデフォルト動作を定義できます。

ステップ 2:emit でクリックイベントを通知する

ボタンがクリックされたときに、親コンポーネントへイベントを通知します。

typescript// emit の型定義
interface Emits {
  (e: 'click', event: MouseEvent): void;
}

const emit = defineEmits<Emits>();

解説: emit の型を明示することで、どのようなイベントが発火されるかを TypeScript で補完できるようになります。

typescript// クリックハンドラーを定義
const handleClick = (event: MouseEvent) => {
  if (!props.disabled) {
    emit('click', event);
  }
};

解説: ボタンが無効化されている場合はイベントを発火させないようにします。この制御をコンポーネント内部で行うことで、親コンポーネント側の処理を簡潔に保てますね。

ステップ 3:slot でボタンのラベルを柔軟にする

ボタンに表示するテキストやアイコンは、親コンポーネント側で自由に設定できるようにします。

vue<!-- BaseButton.vue のテンプレート部分 -->
<template>
  <button
    :class="buttonClass"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

解説: デフォルトの slot を使うことで、親コンポーネントから任意のコンテンツを差し込めます。テキストだけでなく、アイコンや他のコンポーネントも配置可能です。

typescript// CSS クラスを計算プロパティで生成
const buttonClass = computed(() => {
  return {
    btn: true,
    'btn-primary': props.variant === 'primary',
    'btn-secondary': props.variant === 'secondary',
    'btn-disabled': props.disabled,
  };
});

解説: computed を使って、props の値に応じた CSS クラスを動的に生成します。これにより、スタイルの切り替えがシンプルになりますね。

ステップ 4:親コンポーネントでの利用例

設計したボタンコンポーネントを親コンポーネントから利用してみましょう。

vue<!-- App.vue -->
<template>
  <div>
    <BaseButton
      variant="primary"
      @click="handlePrimaryClick"
    >
      保存する
    </BaseButton>

    <BaseButton
      variant="secondary"
      @click="handleSecondaryClick"
    >
      キャンセル
    </BaseButton>

    <BaseButton variant="primary" :disabled="true">
      送信中...
    </BaseButton>
  </div>
</template>

解説: 親コンポーネント側では、必要な props と emit ハンドラーだけを指定すれば良いので、とてもシンプルです。slot にテキストを渡すだけでボタンのラベルを設定できますね。

typescript// 親コンポーネントのスクリプト部分
const handlePrimaryClick = (event: MouseEvent) => {
  console.log('保存ボタンがクリックされました', event);
};

const handleSecondaryClick = (event: MouseEvent) => {
  console.log(
    'キャンセルボタンがクリックされました',
    event
  );
};

解説: イベントハンドラーで受け取る引数は、子コンポーネントから emit された値そのものです。型安全に扱えるため、バグを未然に防げます。

コンポーネント間のデータフロー(完成形)

最後に、設計したコンポーネントのデータフローを図で確認しましょう。

mermaidsequenceDiagram
  participant Parent as 親<br/>(App.vue)
  participant Child as 子<br/>(BaseButton.vue)

  Parent->>Child: props<br/>(variant, disabled)
  Parent->>Child: slot<br/>(ボタンラベル)
  Child->>Child: handleClick<br/>内部処理
  Child->>Parent: emit('click', event)
  Parent->>Parent: handlePrimaryClick<br/>イベント処理

図で理解できる要点

  • 親から子へ props と slot でデータ・コンテンツを渡す
  • 子はクリック時に内部処理を行い、emit で親へ通知する
  • 親はイベントを受け取って必要な処理を実行する
  • 一方向データフローが明確で、責任範囲が分離されている

このように、最小限の API で設計することで、コンポーネント間のやり取りが非常にシンプルになります。

さらに拡張する場合の考え方

もしボタンにアイコンを左右に配置したい場合は、名前付き slot を追加することも検討できます。

vue<!-- 拡張例:名前付き slot を追加 -->
<template>
  <button
    :class="buttonClass"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot name="icon-left" />
    <slot />
    <slot name="icon-right" />
  </button>
</template>

解説: 名前付き slot を使うことで、特定の位置にコンテンツを差し込めます。ただし、slot を増やしすぎると API が複雑になるため、本当に必要な場合のみ追加しましょう。

vue<!-- 親コンポーネントでの利用例 -->
<BaseButton variant="primary" @click="handleClick">
  <template #icon-left>
    <IconCheck />
  </template>
  保存する
</BaseButton>

解説#icon-leftv-slot:icon-left の省略記法です。このように、必要な箇所だけにアイコンを配置できるため、柔軟性が高まりますね。

まとめ

Vue.js のコンポーネント API 設計では、props・emit・slot の 3 つを最小限に保つことが、メンテナンス性と理解しやすさの鍵となります。

重要なポイント

  • props は必要最小限:外部から制御したいデータだけに絞る
  • emit は意図を明確に:イベント名を統一し、似たイベントは統合する
  • slot はコンテンツのカスタマイズに限定:ロジックの制御には使わない
  • 型定義を活用:TypeScript で型を明示し、補完とエラー検出を強化する
  • 一方向データフローを守る:親 → 子は props、子 → 親は emit と役割を分離する

このような原則に従って設計することで、再利用しやすく、テストしやすく、そして変更に強いコンポーネントを作ることができます。

最初は props が多くなってしまうこともあるかもしれませんが、コードレビューやリファクタリングの際に「この props は本当に必要か?」と問い直すことで、徐々にシンプルな設計に近づけていけるでしょう。

ぜひ、皆さんのプロジェクトでも最小 API の考え方を取り入れて、より保守しやすいコンポーネント設計を実現してくださいね。

関連リンク