【Vue】slotは共通基盤をシンプルに保ちつつ、親コンポーネントに柔軟性を委ねる設計を可能にする仕組み

導入

株式会社インゲージにてPD部に所属しております、fujimmm331です。 最近また卓球熱が再燃してきています。

そんなことはさておき。

UIコンポーネントを設計する際、再利用性と柔軟性のバランスを取ることは非常に重要ですよね。 例えば、サイト全体で統一されたモーダルやカードコンポーネントを作成したい場合、デザインの共通部分(ヘッダー、フッター、背景など)は再利用しつつ、 その内部に表示するコンテンツは個々のケースに合わせて変えたい、というニーズが頻繁に発生するのではないでしょうか。

ここで活躍するのがslotです。 slotは、コンポーネントの内部に外部から任意のコンテンツを挿入するための「穴」のような仕組みで、共通のデザイン基盤を保ちながら、コンテンツの柔軟な配置を可能にします。

propsが「数値や文字列を渡す」のに対し、slotは「DOMを渡す」とイメージすると理解しやすいかもしれません。 この仕組みを使いこなすことで、コンポーネント設計はよりシンプルでメンテナンスしやすいものになります。

今日はそんな slot を共通基盤のコンポーネントに使っていくことをテーマに記述していきます。

slotのおさらい

slot(デフォルトスロット)

最も基本的なslotで、コンポーネント内に子要素を1つだけ配置することができます。 親コンポーネントから子コンポーネントのタグ内に書かれたコンテンツは、このデフォルトスロットに挿入されます。

例として、BaseButton.vueをslotを使って実装してみましょう。

<button class="base-button">
  <slot />
</button>

このコンポーネントを使う際は、以下のように記述します。

// templateタグ内
<BaseButton>
  クリックしてね
</BaseButton>

// 実際に描画されるとこうなる
<button class="base-button">
  クリックしてね
</button>

slotにより、アイコン付きでボタンを表示したいといったケースにも対応できます。 子要素が渡されなかった時のフォールバックコンテンツも設定できます。

<button class="base-button">
  <slot>保存する</slot>
</button>

名前付きスロット(Named Slots)

デフォルトスロットが1つしか持てないのに対し、名前付きスロットは複数のコンテンツ挿入位置を定義したい場合に利用します。 各スロットに名前をつけることで、親コンポーネントはどのコンテンツをどのスロットに挿入するかを明確に指定できます。

<dialog class="modal">
  <header class="modal-header">
    <slot name="header" />
  </header>
  <div class="modal-body">
    <slot />
   </div>
  <footer class="modal-footer">
    <slot name="footer" />
  </footer>
</dialog>

使う時は template タグとslotの名前を#で指定することで、マークアップできます。

// templateタグ内
<BaseModal>
  <template #header>
    <h2>お知らせ</h2>
  </template>
  <p>新しい機能が追加されました。</p>
  <template #footer>
    <button>閉じる</button>
  </template>
</BaseModal>

// 実際に描画されるとこうなる
<dialog class="modal">
  <header class="modal-header">
    <h2>お知らせ</h2>
  </header>

  <div class="modal-body">
    <p>新しい機能が追加されました。</p>

  <footer class="modal-footer">
    <button>閉じる</button>
  </footer>
</dialog>

Slotの何が嬉しいか

嬉しい点は多数あるとは思いますが、 ひとことで言うと、「見た目の共通化を壊さずに、画面固有の機能を親が柔軟に差し込める」 点であると考えています。

共通コンポーネントに画面固有の機能を挿入するため、propsでフラグを渡して子側にif文を書く方法も考えられますが、子コンポーネントの責務がどんどん増え、共通基盤が肥大化してしまいます。

例として、Re:lationにおける「メール編集画面」と「LINE編集画面」を考えてみましょう。 両画面はメッセージを作成・編集するという目的は同じですが、それぞれメールとLINEという異なるドメインに属しています。

メールの編集画面
LINEの編集画面

LINEの編集画面にはスタンプを挿入できるようにしたいとしましょう。 編集画面の右ペインをタブ化し、LINEのみスタンプを選べるコンポーネントを表示するとします。

この時どう実装していきましょう? 実現方法はいろいろあると思いますが、今後のことを考えると以下のことは満たしておきたいですよね。

  • 影響範囲を狭め、検証を容易にしたい
  • 編集画面がこれから追加された時、今回のように固有のコンポーネントを使用できるようにしておきたい

先にも記述した通り、共通で使われるコンポーネントにドメイン固有のコンポーネントが混ざり込み、条件分岐が増えていくのは避けたいところ。

そこでslotを使い、上記の課題を解決していきます。

  • 共通のタブUIにslotを設けておくことで、親コンポーネントから必要な要素だけ差し込めるようにします。
  • タブを増やせるようにしておくため、名前付きslotを使います。
  • slotの名前はコンポーネントを使う時に自由に定義できるようpropsで渡せるようにします。

それらを踏まえ実装するとこのような形になります。 customTabListというpropsを定義し、このコンポーネントを使う人が以下の値を自由に定義できるようになっています。

  • tabName: タブに表示する値
  • slotName: 名前付きスロットの名前
<script setup lang="ts">
const props = defineProps<{
  customTabList: {
    tabName: string
    tabValue: string
    slotName: string
  }[],
}>();
</script>
  
<template>
  <section>
    <VTabs>
      <VTab value="one">フレーズ</VTab>
      <VTab v-for="tabItem in customTabList"
                  :value="tabItem.tabValue">
        {{ tabItem.tabName }}
      </Vtab>
    </VTabs>

    <VTabsWindow>
      <VTabsWindowItem value="phrase">
        <PhraseSelector />
      </VTabsWindowItem>

      <VTabsWindowItem v-for="tabItem in customTabList"
        :value="tabItem.tabValue">
        <slot :name="tabItem.slotName" />
      </VTabsWindowItem>
    </VTabsWindow>
  </section>
</template>

あとはLINEの親コンポーネントから名前付きslotを使い定義するだけです。

<script setup lang="ts">
const customTabList = computed(() => {
  return [
    {
      tabName: 'スタンプ',
      tabValue: 'stamp',
      slotName: 'stamp',
    }
  ];
});
</script>

<template>
  <Popup>
    <BaseTextarea />
    <PopupTabs>
      <PopupTab :custom-tab-list="customTabList">
        <template #stamp>
          <LineStampSelector />
        </template>
      </PopupTab>
    </PopupTabs>
  </Popup>
</template>

まとめ

slotは「共通基盤をシンプルに保ちながら、親に柔軟性を委ねる」仕組みです。VueでUIを設計する際の頼もしい武器として、ぜひ意識してみてください!