本文へジャンプ

スロット

このページは、すでにコンポーネントの基礎を読んでいることを前提にしています。初めてコンポーネントに触れる方は、まずそちらをお読みください。

スロットコンテンツとスロットアウトレット

これまでに、コンポーネントが props を受け取れること、そして props はどんな型の JavaScript の値でも取りうることを学びました。しかし、テンプレートのコンテンツについてはどうでしょうか? 場合によっては、テンプレートのフラグメントを子コンポーネントに渡して、子コンポーネントに自身のテンプレート内でそのフラグメントをレンダリングしてもらいたい場合があるかもしれません。

たとえば、次のような使い方をサポートする <FancyButton> コンポーネントがあるとします:

template
<FancyButton>
  Click me! <!-- スロットコンテンツ -->
</FancyButton>

<FancyButton> のテンプレートは次のようになります:

template
<button class="fancy-btn">
  <slot></slot> <!-- スロットアウトレット -->
</button>

<slot> 要素は、親が提供した スロットコンテンツ をレンダリングすべき場所を示す スロットアウトレット です。

slot diagram

最終的にレンダリングされた DOM は以下のようになります:

html
<button class="fancy-btn">Click me!</button>

スロットを利用することで、<FancyButton> は外側の <button>(およびファンシーなスタイル)をレンダリングする責務を持ちながらも、内側のコンテンツは親コンポーネントから提供できるようになります。

スロットを理解するもう 1 つの方法は、次のような JavaScript の関数と比べることです:

js
// 親コンポーネントがスロットコンテンツを渡す
FancyButton('Click me!')

// FancyButton がスロットコンテンツを自身のテンプレート内でレンダリングする
function FancyButton(slotContent) {
  return `<button class="fancy-btn">
      ${slotContent}
    </button>`
}

スロットコンテンツはテキストだけに限定されるわけではありません。任意の有効なテンプレートコンテンツを渡せます。たとえば、複数の要素を渡したり、さらに他のコンポーネントを渡すことさえできます。

template
<FancyButton>
  <span style="color:red">Click me!</span>
  <AwesomeIcon name="plus" />
</FancyButton>

スロットを利用することで、<FancyButton> はより柔軟で再利用可能になりました。これで、他の場所で異なる内部コンテンツとともに利用できるようになり、すべてのコンテンツには同一のファンシーなスタイルが適用されます。

Vue コンポーネントのスロットの仕組みは、ネイティブの Web Component の <slot> 要素に着想を得たものですが、後で見るように追加の機能もあります。

レンダースコープ

スロットコンテンツは親で定義されているため、親コンポーネントのデータスコープへアクセスできます。たとえば、次のような例があるとします:

template
<span>{{ message }}</span>
<FancyButton>{{ message }}</FancyButton>

ここでは、両方の {{ message }} が同じコンテンツをレンダリングします。

スロットコンテンツは子コンポーネントのデータへはアクセスできません。Vue テンプレート内の式は、JavaScript のレキシカルスコープと同様に、その式が定義されたスコープ内のみアクセスできます:

親テンプレートにある式は親のスコープにのみアクセスでき、子のテンプレートにある式は子のスコープにのみアクセスできます。

フォールバックコンテンツ

スロットには、何もコンテンツが与えられなかった場合にのみレンダリングされるフォールバック(つまりデフォルト)コンテンツを指定すると便利な場合があります。たとえば、次のような <SubmitButton> コンポーネント内で:

template
<button type="submit">
  <slot></slot>
</button>

スロットコンテンツに何も与えなかった場合に <button> 内に "送信" というテキストをレンダリングしたくなるかもしれません。"送信" をフォールバックコンテンツにするには、そのテキストを <slot> タグの間に置きます:

template
<button type="submit">
  <slot>
    送信 <!-- フォールバックコンテンツ -->
  </slot>
</button>

これで、スロットに何もコンテンツを提供せずに <SubmitButton> を親コンポーネント内で使用すると:

template
<SubmitButton />

フォールバックコンテンツの "送信" がレンダリングされます:

html
<button type="submit">送信</button>

しかし、コンテンツを与えた場合:

template
<SubmitButton>保存</SubmitButton>

与えたコンテンツが代わりにレンダリングされます:

html
<button type="submit">保存</button>

名前付きスロット

1 つのコンポーネント内に複数のスロットアウトレットがあると便利なときがあります。たとえば、次のようなテンプレートを持つ <BaseLayout> コンポーネントがあるとします:

template
<div class="container">
  <header>
    <!-- ここに header コンテンツが必要 -->
  </header>
  <main>
    <!-- ここに main コンテンツが必要 -->
  </main>
  <footer>
    <!-- ここに footer コンテンツが必要 -->
  </footer>
</div>

このような場合のために、<slot> 要素には特別な属性 name があり、異なるスロットにユニークな ID を割り当てて、コンテンツをレンダリングするべき場所を指定するために使用できます:

template
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

name を持たない <slot> アウトレットは、暗黙的に "default" という name を持つものとされます。

<BaseLayout> を使用している親コンポーネント内では、それぞれが別のスロットアウトレットをターゲットとする複数のスロットコンテンツのフラグメントを渡す手段が必要です。これこそ 名前付きスロット が役に立つ場面です。

名前付きスロットを渡すためには、v-slot ディレクティブを持つ <template> 要素を使い、v-slot にスロットの名前を引数として渡す必要があります:

template
<BaseLayout>
  <template v-slot:header>
    <!-- header スロットのためのコンテンツ -->
  </template>
</BaseLayout>

v-slot には専用の省略表記 # があるため、<template v-slot:header> は単に短く <template #header> と書けます。これは「このテンプレートフラグメントを子コンポーネント内の 'header' スロット内にレンダリングする」という意味だと考えてください。

名前付きスロットのダイアグラム

こちらは、省略構文を使用して <BaseLayout> の 3 つすべてのスロットに対してコンテンツを渡しているコードです:

template
<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <template #default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

コンポーネントがデフォルトスロットと名前付きスロットの両方を受け入れる場合、すべてのトップレベルの <template> 以外のノードは暗黙的にデフォルトスロットに対するコンテンツとして扱われます。そのため、上の例は次のようにも書けます:

template
<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <!-- 暗黙的なデフォルトスロット -->
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

これで <template> 要素内のすべては対応するスロットに渡されます。最終的にレンダリングされる HTML は次のようになります:

html
<div class="container">
  <header>
    <h1>Here might be a page title</h1>
  </header>
  <main>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </main>
  <footer>
    <p>Here's some contact info</p>
  </footer>
</div>

繰り返しになりますが、JavaScript の関数と比べると、名前付きスロットをよりよく理解する助けになるかもしれません:

js
// 複数のスロットフラグメントを異なる名前で渡す
BaseLayout({
  header: `...`,
  default: `...`,
  footer: `...`
})

// <BaseLayout> がそれらを異なる場所でレンダリングする
function BaseLayout(slots) {
  return `<div class="container">
      <header>${slots.header}</header>
      <main>${slots.default}</main>
      <footer>${slots.footer}</footer>
    </div>`
}

条件付きスロット

スロットが存在するかどうかに基づいて何かをレンダリングしたい場合があります。

これを実現するには、$slots プロパティと v-if を組み合わせて使用します。

以下の例では、headerfooter という 2 つの条件付きスロットを持つ Card コンポーネントを定義します。 そしてヘッダー/フッターが存在する場合に、スタイル追加のためにスロットをラップしています:

template
<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    
    <div class="card-content">
      <slot />
    </div>
    
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

Playground で試す

動的なスロットの名前

動的なディレクティブの引数v-slot でも機能します。これにより、動的なスロットの名前の定義が可能になります:

template
<base-layout>
  <template v-slot:[dynamicSlotName]>
    ...
  </template>

  <!-- 省略表記 -->
  <template #[dynamicSlotName]>
    ...
  </template>
</base-layout>

式は、動的なディレクティブの引数の構文上の制約の対象となることに注意してください。

スコープ付きスロット

レンダースコープで説明したように、スロットのコンテンツは子コンポーネント内の状態にアクセスできません。

しかし、スロットのコンテンツが親のスコープと子のスコープの両方から来たデータを利用できると便利な場合があります。これを実現するためには、レンダリング時に子がデータをスロットに渡す手段が必要です。

実際、まさにその通りのことが可能で、props をコンポーネントに渡すときと同様に、属性をスロットアウトレットに渡すことができます:

template
<!-- <MyComponent> template -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

スロット props を受け取るのは、デフォルトスロットと名前付きスロットを使用するのとは少し異なります。子コンポーネントのタグ上で v-slot を直接使うことによって、単一のデフォルトスロットを使って props を受け取る方法を最初に示します:

template
<MyComponent v-slot="slotProps">
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

スコープ付きスロットのダイアグラム

子によってスロットに渡された props は、対応する v-slot ディレクティブの値として利用できます。この値は、スロット内の式からアクセスできます。

スコープ付きスロットは、子コンポーネントに渡された関数として考えられます。その後、子コンポーネントはその関数を呼び、props を引数として渡します:

js
MyComponent({
  // デフォルトスロットを関数として渡す
  default: (slotProps) => {
    return `${slotProps.text} ${slotProps.count}`
  }
})

function MyComponent(slots) {
  const greetingMessage = 'hello'
  return `<div>${
    // スロット関数を props つきで呼びだす!
    slots.default({ text: greetingMessage, count: 1 })
  }</div>`
}

実際、これはスコープ付きスロットがコンパイルされる方法や、スコープ付きスロットを手動で レンダー関数に渡す方法に非常に近いものです。

v-slot="slotProps" がスロット関数のシグネチャーにどのように対応しているかに注目してください。関数の引数のように、v-slot でもオブジェクトの分割が利用できます:

template
<MyComponent v-slot="{ text, count }">
  {{ text }} {{ count }}
</MyComponent>

名前およびスコープ付きスロット

名前付きスロットも同じように動作します。スロット props は v-slot ディレクティブの値として v-slot:name="slotProps" のようにアクセスできます。省略表記を使うと、次のようになります:

template
<MyComponent>
  <template #header="headerProps">
    {{ headerProps }}
  </template>

  <template #default="defaultProps">
    {{ defaultProps }}
  </template>

  <template #footer="footerProps">
    {{ footerProps }}
  </template>
</MyComponent>

props は名前付きスロットに次のように渡します:

template
<slot name="header" message="hello"></slot>

スロットの name は予約されているため、props には含まれないことに注意してください。そのため、headerProps{ message: 'hello' } となります。

名前付きスロットとデフォルトのスコープ付きスロットを混在させる場合は、デフォルトスロットに明示的に <template> タグを使用する必要があります。v-slot ディレクティブを直接コンポーネントに配置しようとすると、コンパイルエラーになります。これは、デフォルトスロット props のスコープが曖昧にならないようにするためです。例えば:

template
<!-- このテンプレートはコンパイルされません -->
<template>
  <MyComponent v-slot="{ message }">
    <p>{{ message }}</p>
    <template #footer>
      <!-- message はデフォルトスロットに属しており、ここでは使用できません -->
      <p>{{ message }}</p>
    </template>
  </MyComponent>
</template>

デフォルトスロットに明示的に <template> タグを使用することで、message props が他のスロット内では使用できないことを明確にできます:

template
<template>
  <MyComponent>
    <!-- 明示的なデフォルトスロットを使用する -->
    <template #default="{ message }">
      <p>{{ message }}</p>
    </template>

    <template #footer>
      <p>Here's some contact info</p>
    </template>
  </MyComponent>
</template>

Fancy List の例

スコープ付きスロットのよいユースケースは何かと疑問に思うかもしれません。以下に例を示します: アイテムのリストをレンダリングする <FancyList> コンポーネントがあるとします。そのコンポーネントは、リストを表示するのに使用するリモートのデータを読み込むロジックや、ページネーションや無限スクロールのような発展的な機能をカプセル化しているかもしれません。ただし各アイテムのスタイルは、使用する親コンポーネントに任せて柔軟に対応できるようにしたいと考えています。そのため、期待される利用例は次のようになるかもしれません:

template
<FancyList :api-url="url" :per-page="10">
  <template #item="{ body, username, likes }">
    <div class="item">
      <p>{{ body }}</p>
      <p>by {{ username }} | {{ likes }} likes</p>
    </div>
  </template>
</FancyList>

<FancyList> の内側では、同一の <slot> を異なるアイテムデータを使用して複数回レンダリングできます(v-bind を使用してオブジェクトをスロット props として渡していることに注意してください):

template
<ul>
  <li v-for="item in items">
    <slot name="item" v-bind="item"></slot>
  </li>
</ul>

レンダーレスコンポーネント

上で説明した <FancyList> のユースケースは、再利用可能なロジック(データフェッチやページネーションなど)と視覚的な出力の両方をカプセル化していますが、視覚的な出力の一部は、使用されるコンポーネント側にスコープ付きスロットを介して移譲しています。

この概念をさらに少し広げると、ロジックだけをカプセル化し、自身では何もレンダリングしないコンポーネントを考えることができます。つまり、視覚的な出力を利用する側のコンポーネントに完全に移譲したコンポーネントです。このような種類のコンポーネントを、レンダーレスコンポーネントと呼びます。

レンダーレスコンポーネントの一例としては、現在のマウス位置をトラッキングするロジックをカプセル化したコンポーネントがあります:

template
<MouseTracker v-slot="{ x, y }">
  マウスの座標: {{ x }}, {{ y }}
</MouseTracker>

面白いパターンではありますが、レンダーレスコンポーネントで実現できるほとんどのことは、Composition API を利用することで、追加のコンポーネントのネストによるオーバーヘッドを起こすことなく、より効率的な方法で実現できます。後ほど、同様のマウストラッキング機能をコンポーザブルとして実装する方法を説明します。

そうは言っても、スコープ付きスロットは、<FancyList> の例のように、ロジックのカプセル化と視覚的な出力の作成の両方が必要な場合には十分役に立ちます。

スロットが読み込まれました