[Vue入門] スロット

このページは、コンポーネントの基本をすでに読んでいることを前提としています。コンポーネントを初めて使用する場合は、最初にそれをお読みください。

スロットコンテンツとその出力先

コンポーネントはpropsを受け入れることができることを学びましたね。propは色々なタイプのJavaScriptのデータを格納することができます。しかし、テンプレートコンテンツはどうでしょうか?再利用可能なコンポーネントを作成するうえで、親のコンポーネントから自在に意図した場所にデータをレンダーしたい場合にスロットが役に立ちます。

たとえば、次の<FancyButton>ような使用法をサポートするコンポーネントがある場合があるとします。

<FancyButton>
  Click me! <!-- slot content -->
</FancyButton>

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

<button class="fancy-btn">
  <slot></slot> <!-- slot outlet -->
</button>

この<slot>エレメントは、親が提供するスロットコンテンツをレンダリングする場所を示すスロットアウトレットです。

いわば枠組みを作ってあげてその中(スロット)に意図したデータを投げてあげるわけですね。

そして、最終的にレンダリングされたDOMはこのようになります。:

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

スロットを使用すると、<FancyButton>は外側(およびその派手なスタイリング)のレンダリングを担当し、<button>内側のコンテンツは親コンポーネントによって提供されます。

スロットを理解するもう1つの方法として、スロットをJavaScript関数と比較することができます。

// parent component passing slot content
FancyButton('Click me!')

// FancyButton renders slot content in its own template
function FancyButton(slotContent) {
  return (
    `<button class="fancy-btn">
      ${slotContent}
    </button>`
  )
}

スロットコンテンツはテキストだけに限定されません。有効なテンプレートコンテンツであれば何でもOKです。たとえば、複数のHTMLエレメント、または他のコンポーネントを渡すことができます。

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

スロットを使用することで、<FancyButton>をより柔軟で再利用可能なコンポーネントとして使用できるようになります。

Vueコンポーネントのスロットメカニズムは、ネイティブのWebコンポーネント<slot>要素で記載されていますが、後で説明する他の機能も備えています。

レンダリングスコープ

スロットコンテンツは、親で定義されているため、親コンポーネントのデータスコープにアクセスできます。例えば:

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

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

スロットコンテンツは、子コンポーネントのデータにアクセスできません。原則として、次の点に注意してください。

親テンプレートのすべてが親スコープでコンパイルされ、子テンプレートのすべてが子スコープでコンパイルされます。

スロット内容が指定されていない場合の初期値

コンテンツが提供されていない場合にのみレンダリングされるように、スロットのフォールバック(つまりデフォルト)コンテンツを指定できるという便利な機能があります。たとえば、<SubmitButton>コンポーネントでは次のようになります。

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

<button>親コンポーネントでスロットコンテンツを提供しなかった場合は、「Submit」というテキストをの中に表示したい場合を想定します。<slot>タグの間にフォールバックコンテンツ(スロット内容が指定されていない場合の初期値)を記載することで初期値を「Submit」にできます。

<button type="submit">
  <slot>
    Submit <!-- fallback content -->
  </slot>
</button>

親コンポーネントで使用する場合<SubmitButton>、スロットにコンテンツを提供なかった場合。

<SubmitButton />

これにより、フォールバックコンテンツ(デフォルト値)「Submit」がレンダリングされます。

<button type="submit">Submit</button>

ただし、スロットコンテンツを提供する場合:

<SubmitButton>Save</SubmitButton>

次に、提供されたコンテンツが代わりにレンダリングされます。

<button type="submit">Save</button>

複数のスロットがある場合

1つのコンポーネントに複数のスロットがあると便利な場合があります。次のテンプレート<BaseLayout>を使用するコンポーネントでは、次のようになります。

<div class="container">
  <header>
    <!-- We want header content here -->
  </header>
  <main>
    <!-- We want main content here -->
  </main>
  <footer>
    <!-- We want footer content here -->
  </footer>
</div>

このような場合、<slot>エレメントには特別な属性として、nameというものがあります。これを使用して、コンテンツをレンダリングする場所を指定できるようになり、さまざまなスロットに意図したIDを割り当てることができます。

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

例を見てわかるようにスロットネームのないデフォルト用のの<slot>があります。name slotを使用する親コンポーネント<BaseLayout>では、それぞれが異なるスロット出力先をターゲットとする複数のスロット出力先に提供する方法が必要です。ここで名前付きスロットの出番です。

名前付きスロットを渡すには、ディレクティブで<template>要素を使用してから、v-slotを使ってスロットの名前を引数として次のように渡す必要があります。

<BaseLayout>
  <template v-slot:header>
    <!-- content for the header slot -->
  </template>
</BaseLayout>

v-slotは省略して#記載できるので、<template v-slot:header>から<template #header>と記載することができます。

3つのスロットすべてのコンテンツを<BaseLayout>省略構文を使用して渡す場合は次のとおりになります。

<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>ではないノード(HTML)は自動的にデフォルトスロットのコンテンツとして扱われます。したがって、上記は次のように書くこともできます。

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

  <!-- implicit default slot -->
  <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は次のようになります。

<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関数の例を使用すると、名前付きスロットをよりよく理解するのに役立つ場合と思います。

// passing multiple slot fragments with different names
BaseLayout({
  header: `...`,
  default: `...`,
  footer: `...`
})

// <BaseLayout> renders them in different places
function BaseLayout(slots) {
  return (
    `<div class="container">
      <header>${slots.header}</header>
      <main>${slots.default}</main>
      <footer>${slots.footer}</footer>
    </div>`
  )
}

ダイナミックなスロット名

動的ディレクティブ引数はv-slotでも使用可能で、動的スロット名の定義をすることができます。

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

  <!-- with shorthand -->
  <template #[dynamicSlotName]>
    ...
  </template>
</base-layout>

式は動的ディレクティブ引数の構文制約に従うことに注意してください。

スコープスロット

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

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

これは、Propsをコンポーネントに渡すのと同じように、属性をスロットアウトレットに渡すことがで実現可能になります。:

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

単一のデフォルトスロットを使用する場合と名前付きスロットを使用する場合では、スロットプロップの受信の仕方が少し異なります。v-slot子コンポーネントタグを直接使用して、最初に単一のデフォルトスロットを使用してPropsを受け取る方法を記載します。

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

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

スコープスロットは、子コンポーネントに渡される関数と考えることができます。次に、子コンポーネントはそれを呼び出し、Propsを引数として渡します。

MyComponent({
  // passing the default slot, but as a function
  default: (slotProps) => {
    return `${slotProps.text} ${slotProps.count}`
  }
})

function MyComponent(slots) {
  const greetingMessage = 'hello'
  return (
    `<div>${
      // call the slot function with props!
      slots.default({ text: greetingMessage, count: 1 })
    }</div>`
  )
}

実際、これはスコープ付きスロットのコンパイル方法、および手動レンダリング関数でスコープ付きスロットを使用する方法に非常に近いものです。

スロット機能のシグネチャとv-slot=”slotProps”がどのように一致するかに注意してください。関数の引数と同じように、v-slot内で次の場所で各データを使用できます。

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

名前付きスコープスロット

名前付きスコープスロットも同様に機能します。スロットのPropsは、v-slotディレクティブの値としてアクセスできますv-slot:name="slotProps"。省略した記載方法を使用すると、次のようになります。

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

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

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

名前付きスロットにPropsを渡す:

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

nameスロットでnameというストリングが使用されるため、nameというプロップは制限され、Propsには含まれないことに注意してください。そのため、結果headerPropsは{ message: ‘hello’ }になります。

ファンシーリストの例

スコープ付きスロットの良い使い方は何か疑問に思われるかもしれません。次に例を記載します。<FancyList>アイテムのリストをレンダリングするコンポーネントを想像してみてください。リモートデータの読み込み、データを使用したリストの表示、さらにはページネーションや無限スクロールなどの高度な機能のロジックをカプセル化できます。ただし、各アイテムの外観に柔軟性を持たせ、各アイテムのスタイルをそれを使用する親コンポーネントに任せる必要があります。したがって、望ましい使用法は次のようになります。

<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を使用していることに注意してください)。

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

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

上で説明した<FancyList>ユースケースは、再利用可能なロジック(データのフェッチ、ページネーションなど)とビジュアル出力の両方をカプセル化し、スコープ付きスロットを介してビジュアル出力の一部をコンシューマーコンポーネントとして使用したものになります。

この概念をもう少しひねると、ロジックのみをカプセル化し、それ自体では何もレンダリングしないコンポーネントを作ることができます。ビジュアル出力は、スコープスロットを備えたコンシューマーコンポーネントに完全に任されることになります。このタイプのコンポーネントをレンダーレスコンポーネントと呼びます。

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

<MouseTracker v-slot="{ x, y }">
  Mouse is at: {{ x }}, {{ y }}
</MouseTracker>

興味深いパターンですが、レンダリングレスコンポーネントで実現できることのほとんどは、余分なコンポーネントのネストのオーバーヘッドを発生させることなく、CompositionAPIを使用してより効率的な方法で実現できます。後で、Composableと同じマウス追跡機能を実装する方法を説明します。

まとめとしてスコープスロットは、<FancyList>の例のように、ロジックの格納化とビジュアル出力の作成の両方が必要な場合で役立つことが分かりますね。