スロット — 描画の一部を親に委ねる
props は値を渡す仕組みでした。10 章の v-model も、突き詰めれば prop とイベントの組です。渡せるのは値なので、子の内側にどんなマークアップを置くかは子が決めます。
スロットはこれを裏返します。置き場所を子が決め、そこに入る中身を親が書きます。 中身が親のコードとして書かれている点が、この章の話のほとんどを決めます。
この章で学ぶこと
<slot>が親のどのコードに対応するか- フォールバックが出る条件を「渡されなかったとき」より正確に言える
- スロットが渡されたかを子から判定する方法と、その判定が外れる場所
- 子の持つデータを親のマークアップへ渡せる
defineSlotsが型検査に足すものと足さないものを区別できる- 親の状態が変わったときに、どちらのコンポーネントが描き直されるか
中身を差し込む場所を作る
<slot> は中身が入る場所を示す要素です。名前を付ければ複数置けます。
<template>
<div class="card">
<div class="card-header">
<slot name="header">名前のない記事</slot>
</div>
<div class="card-body">
<slot />
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<slot> の中に書いたものはフォールバックです。名前を付けない <slot /> が既定スロットになります。
親は <template> に名前を書いて中身を渡します。
<script setup lang="ts">
import BaseCard from './BaseCard.vue'
const title = 'スロット入門'
</script>
<template>
<BaseCard>
<template #header>
<h2>{{ title }}</h2>
</template>
<p>本文はここに入ります。</p>
<template #footer>
<button type="button">閉じる</button>
</template>
</BaseCard>
</template>
#header は v-slot:header の省略形です。<template> で囲んでいないトップレベルのノード (ここでは <p>) は既定スロットの中身として扱われます。名前付きスロットと素の中身を並べて書けるのはこのためです。
書き方の誤りはビルドを止める
親側の書き方でよく踏むのは次の 4 つです (<slot> 出口へのカスタムディレクティブにも別の指摘があり、12 章で扱います)。
| 書き方 | 出るメッセージ |
|---|---|
| 同じスロット名を 2 回 | Duplicate slot names found. |
<template #default> と素の中身を併記 | Extraneous children found when component already has explicitly named default slot. These children will be ignored. |
<template> でない要素に v-slot | v-slot can only be used on components or <template> tags. |
component タグ直の v-slot と名前付き <template> の混在 | Codegen node is missing for element/if/for node. |
4 つはいずれもビルドを止めます。 コンパイラ自体は 4 つのうち 3 つを記録して先へ進み、コードを返します。止めているのは、その記録を致命として扱うビルド側です。2 番目は「これらの子は無視されます」と読める文面ですが、無視して描画が続くわけではありません。v-slot で受けた変数そのものを 10 章のように v-model へ渡すのも、書き換えられないので止まります (v-model cannot be used on v-for or v-slot scope variables ...)。v-model="item.name" のようにプロパティを指せば通り、親が渡した配列の要素をそのまま書き換えます。
4 番目のメッセージは何が悪いのかを説明していません。コンパイラは Mixed v-slot usage on both the component and nested <template> という文面を持っていて、混在を見つけた時点でこれを記録します。ただし記録した直後に処理を打ち切るため、後段のコード生成が未処理のノードに当たって別の例外を投げます。記録した指摘は戻り値に乗って返るはずのものなので、先に例外が飛べば捨てられます。 ビルドで見えるのはコード生成側の文面なので、これを見たら v-slot の混在を疑ってください。
中身は親のスコープで評価される
親が書いた中身は、コンパイル後に名前をキーにした関数になります。
_createBlock(BaseCard, null, {
header: _withCtx(() => [
_createElementVNode("h2", null, _toDisplayString(title))
]),
footer: _withCtx(() => [ /* <button type="button">閉じる</button> */ ]),
default: _withCtx(() => [ /* <p>本文はここに入ります。</p> */ ]),
_: 1 /* STABLE */
})
並びが書いた順と違うのは、<template> で囲んでいない中身が、名前付きスロットを積み終えた後に既定スロットとして足されるためです。<template #default> と明示すれば書いた順のまま並びます。子側は renderSlot の呼び出しになります。
_renderSlot(_ctx.$slots, "header", {}, () => [ /* 名前のない記事 */ ]),
_renderSlot(_ctx.$slots, "default"),
_renderSlot(_ctx.$slots, "footer")
第 3 引数がスロットへ渡す値、第 4 引数がフォールバックです。この BaseCard.vue のように子が <style scoped> を持たなければ、書いていない引数は出ません。
関数の中身は親のコードとして書かれているので、読めるのは親のスコープです。 title はコンパイル後も親のスコープの名前をそのまま参照しています (開発ビルドでは $setup.title)。名前を解決しているのは JavaScript の字句閉包で、公式ガイドもレキシカルスコープと同じだと説明しています。子が内部で持っている名前は、親の <template #header> からは見えません。_withCtx が包んでいるのはテンプレート変数の解決とは別の役目で、scope 属性の配り方に効いてきます (この章の最後で扱います)。
フォールバックが出る条件
公式ガイドはフォールバックを「中身が渡されなかったときにだけ描かれるもの」と書いています。実装はもう少し広く、渡された中身が何も描かなかったときにも出ます。
// 描いた結果が「無い」ならフォールバックへ落ちる
const validSlotContent = slot && ensureValidVNode(slot(props))
// ... createBlock(Fragment, { key }, validSlotContent || (fallback ? fallback() : []))
function ensureValidVNode(vnodes) {
return vnodes.some((child) => {
if (!isVNode(child)) return true
if (child.type === Comment) return false
if (child.type === Fragment && !ensureValidVNode(child.children)) return false
return true
}) ? vnodes : null
}
空配列 (some が偽になる) / コメントノードだけ / 中身が空の Fragment だけ、のいずれもここで「無い」と数えられます。v-if が偽のときに残るのはコメントノードなので、次の書き方ではフォールバックの「名前のない記事」が出ます。
<BaseCard>
<template #header><h2 v-if="showHeader">見出し</h2></template>
</BaseCard>
v-for の対象が空配列のとき、中身のない <template #header></template> を渡したときも同じです。フォールバックは「親が中身を書いたか」ではなく描画結果を見ています。
スロットが渡されたかを判定する
囲みの div ごと消したいときは、$slots を v-if と組み合わせます。公式ガイドが挙げている書き方です。
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
</div>
</template>
この判定が見ているのは、スロットの関数が渡っているかどうかだけです。 前節の「描画結果が空」とは別の基準なので、親の書き方によって結果が食い違います。
<!-- スロット自体が渡らない: $slots.header は undefined -->
<template v-if="showHeader" #header><h2>見出し</h2></template>
<!-- スロットは渡る: $slots.header は関数のまま -->
<template #header><h2 v-if="showHeader">見出し</h2></template>
上は <template> そのものが条件付きなので、偽のときはスロットのキーごと存在しません。判定も偽になり、card-header の div は出ません。
下はスロットの関数が必ず渡ります。判定が真になるので div は描かれ、中の見出しだけが消えた空の囲みが残ります。$slots.header を呼ぶとコメントノード 1 件が返ってくる状態です。v-if に限らず、v-for の対象が空配列でも同じです。
囲みを消したいなら条件は <template> の側に置きます。判定を子に任せる設計にするなら、囲みを消すのではなくフォールバックで埋めるほうが食い違いません。
子のデータを親へ渡す
スロットの出口には、コンポーネントに props を渡すのと同じ書き方で値を渡せます。
<script setup lang="ts">
type Item = { id: number; name: string }
defineProps<{ items: Item[] }>()
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index">{{ item.name }}</slot>
</li>
</ul>
</template>
親は v-slot の値としてこれを受け取ります。分割代入がそのまま書けます。
<script setup lang="ts">
import ItemList from './ItemList.vue'
const items = [
{ id: 1, name: 'りんご' },
{ id: 2, name: 'ばなな' },
]
</script>
<template>
<ItemList :items="items">
<template #item="{ item, index }">
<span>({{ index }}) {{ item.name }}</span>
</template>
</ItemList>
</template>
前節のコンパイル出力で header: _withCtx(() => [...]) だった部分が、item: _withCtx(({ item, index }) => [...]) になります。スロットへ渡した値は、親が書いた関数の引数です。 { item, index = 0 } のように既定値も書けます。index を items.indexOf(item) で求めると、要素ごとに配列を走査するので要素数の 2 乗に比例した時間がかかります。indexOf は厳密等価で探すので、同一の参照が 2 回入っている配列や、値が重複するプリミティブの配列では、2 つ目でも 1 つ目の位置が返ります。v-for の第 2 引数から取ります。
既定スロットだけを使うなら、<template> を省いて component タグに v-slot を書けます。
<ItemList :items="items" v-slot="{ item }">{{ item.name }}</ItemList>
ただし名前付きスロットと混ぜると前掲のコンパイルエラーになります。 どちらの v-slot の値を指しているのか決まらなくなるためです。混ぜるときは既定スロットも <template #default> で書きます。
論理だけを持ち、描画をすべて親に委ねるコンポーネントも書けます。公式ガイドはこれを Renderless Component と呼んでいます。ただし状態と論理の共有だけが目的なら、8 章の Composable のほうが素直です。
型を宣言する
スロット props の型は、宣言を書かなくても子のテンプレートから推論されます。前掲の ItemList.vue を使う側で {{ item.nope }} と書くと、vue-tsc が Property 'nope' does not exist on type 'Item' で落とします。
defineSlots を書くと、この推論を宣言で置き換えられます。3.3 以降で使えます。
<script setup lang="ts">
type Item = { id: number; name: string }
defineProps<{ items: Item[] }>()
defineSlots<{
item(props: { item: Item; index: number }): unknown
empty?(): unknown
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
宣言は子自身の <slot> にも効きます。宣言から index を外すと、子の <slot :index="index"> の側も「宣言に無いプロパティ」で落ちます。片方だけ直して食い違ったままにはできません。
一方で、次の 2 つは捕まえません。宣言に無い名前のスロットを渡してもエラーになりませんし、? を付けていないスロットを渡さなくてもコンパイルが通ります。defineSlots は実行時にも何も宣言しません。上のように戻り値を受け取らずに書くと、コンパイル後は呼び出しごと消えます (const slots = defineSlots<...>() と受け取ったときだけ useSlots() の呼び出しが残ります)。props と違い、宣言そのものはどこにも残りません。
渡し漏れは型でも実行時でも検出されないので、v-if="$slots.item" のように子側で確かめるか、フォールバックを用意しておきます。useSlots() を直接呼んでも同じオブジェクトが返りますが、型を書けるなら defineSlots のほうが公式の推奨です。
スロットを下へ流す
包んで既定値を足すようなコンポーネントでは、受け取ったスロットをそのまま下へ渡したくなります。$slots を回せば名前を列挙せずに書けます。
<script setup lang="ts">
import BaseCard from './BaseCard.vue'
</script>
<template>
<BaseCard>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps ?? {}" />
</template>
</BaseCard>
</template>
実行時はこれで通ります。ただし型は落ちます。 この書き方を通したスロット props は any になるので、CardWrapper を使う側で存在しないプロパティを読んでも型検査が素通りします。かわりに defineSlots で名前を並べて宣言すると、今度は動的な名前で索引できなくなり No index signature with a parameter of type 'string' で落ちます。名前を列挙した宣言と $slots の走査は両立しません (Record<string, ...> のように索引シグネチャで宣言すれば通りますが、それでは名前の検査を捨てることになります)。名前を明示して転送すれば両方が通ります。
<template #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
更新はどこまで伝わるか
スロットの中身は親のコードですが、評価されるのは子が renderSlot を呼んだときです。そのため依存を追跡するのは子の描画です。7 章で、スロットの中身を変えたときに走る更新フックが子側だけだったのも同じ理由です。
親の 2 つの ref のうち片方をスロットの中身だけで読み、もう片方を親のテンプレートだけで読む構成で、描画回数を数えるとこうなります。props はどの行でも変えていません。
| 変えた親の状態 | スロットの渡し方 | 親の描画 | 子の描画 |
|---|---|---|---|
スロットの中身だけで読んでいる ref | 素の中身 (_: 1) | 0 回 | 1 回 |
親のテンプレートだけで読んでいる ref | 素の中身 (_: 1) | 1 回 | 0 回 |
親のテンプレートだけで読んでいる ref | <template v-if> (_: 2) | 1 回 | 1 回 |
スロットの中身を変えても親は描き直しません。 親のテンプレートはその値を読んでいないので、依存に入っていないためです。逆向きも成り立ち、親が描き直しても props が変わらなければ子は描き直しません。前掲の _: 1 /* STABLE */ がこの判定に効いていて、スロットの差し替えも省かれます。関数は親のスコープを閉じ込めたままなので、次に呼ばれた時点で新しい値を読みます。
3 行目が例外です。<template> に v-if や v-for を付けたり #[name] で名前を動的にすると、この印が _: 2 /* DYNAMIC */ になり DYNAMIC_SLOTS という patchFlag が付きます。親が描き直すと、スロットと関係のない変更でも子が描き直します。
条件を何も書かなくても同じ状態になります。スロットの中身か v-slot の値が、その子を囲むスコープの変数を読んでいるときです。囲む v-for の変数と、さらに外側から受けているスロット props がこれに当たります。v-for の中で <Child><template #default>{{ row.name }}</template></Child> と書くと _: 2 になり、同じ v-for の中でもどちらも読んでいなければ _: 1 のままです。自分がその子から受け取るスロット props と、中身の内側で導入した変数は含みません — 前掲の ItemListUser.vue は item と index を読んでいますが _: 1 です。前節の名前を明示した転送も行き着く先は同じで、こちらは _: 3 /* FORWARDED */ になり、上から _: 1 のスロットが渡ってこない限り実行時に _: 2 へ昇格します。何も渡ってこないときもここに入ります。重い子を条件付きスロットで包んでいるときに効いてくるので、条件を <template> の外へ出せるなら出しておきます。
scoped スタイルとスロットの中身
1 章で、単一のルート要素を返す子には、そのルート要素に親の scope 属性も付くことを見ました。スロットの中身では組み合わせが変わります。どちらも親と子がともに <style scoped> を持つ場合です。
| 要素 | 付く scope 属性 |
|---|---|
| 単一ルートの子のルート要素 | 親の data-v-xxx と子の data-v-yyy |
| スロットの中身 | 親の data-v-xxx と、子の data-v-yyy-s |
親の属性が付くのは、_withCtx がスロットの関数を呼ぶ間だけ描画文脈を親へ差し替えていて、その中で作られる要素が親の scopeId を受け取るからです。子の素の属性は付きません。 代わりに renderSlot が末尾に -s を足した属性を配ります。
if (!noSlotted && rendered.scopeId) {
rendered.slotScopeIds = [rendered.scopeId + "-s"]
}
つまり、親の <style scoped> はスロットの中身にそのまま当たり、子の <style scoped> は当たりません。公式ドキュメントも「スロットの中身は渡してきた親のものと見なす」と説明しています。子から中身に当てたいときは :slotted() 擬似クラスを使います。
/* 子の <style scoped> に書く */
:slotted(.pill) {
font-weight: bold;
}
これが .pill[data-v-yyy-s] へ展開されるので、-s 付きの属性に当たります。
-s 付きの属性は、親が <style scoped> を持っているかどうかに関係なく付きます。一方で子の側には条件があり、その <style scoped> が :slotted() を使っていなければ配られません。使っていない子では <slot> の呼び出しに「配らない」印が付くので、スロットの中身に残るのは親の属性だけです。前掲の表は、子が :slotted() を持つ場合の並びです。
まとめ
<slot>は中身が入る場所を示す要素です。名前を付ければ複数置けます。#nameはv-slot:nameの省略形で、<template>で囲んでいないトップレベルのノードは既定スロットへ入ります- 親側の書き方の誤りはビルドを止めます。止めているのは記録を致命として扱うビルド側で、コンパイラの側は記録して先へ進みます。
v-slotの混在だけは処理を打ち切るので、記録が返らずコード生成側の文面が表に出ます - 親が書いた中身は名前をキーにした関数になります。中身は親のコードなので読めるのは親のスコープで、子が持っている名前は見えません
- フォールバックが出るのは「渡されなかったとき」ではなく「描画結果が空のとき」です。 空配列 / コメントノードだけ / 中身が空の Fragment だけがここに当たるので、
v-ifが偽の中身を渡してもフォールバックが出ます $slotsの判定が見ているのは関数が渡っているかどうかだけです。フォールバックの基準とは別なので、<template>に条件を置けば囲みごと消え、中身に条件を置けば空の囲みが残ります- スロットの出口へ渡した値は、親が書いた関数の引数として届きます。分割代入と既定値がそのまま書けます。既定スロットだけなら component タグに
v-slotを書けますが、名前付きスロットと混ぜるとコンパイルエラーになります - スロット props の型は宣言を書かなくても子のテンプレートから推論されます。
defineSlotsはこれを置き換え、子自身の<slot>も検査対象にします。ただし宣言に無い名前のスロットも、必須スロットの渡し漏れも捕まえません。実行時には何も宣言しません $slotsを回す転送は実行時には動きますが、スロット props がanyに落ちます。名前を列挙したdefineSlotsと併用すると索引ができず型検査が落ちるので、両方が要るなら名前を明示して転送します- スロットの中身が評価されるのは子の描画のときです。中身だけで読んでいる親の状態を変えると、親は描き直さず子だけが描き直します
<template>の条件付きや動的な名前は_: 2 /* DYNAMIC */になり、親が描き直すと props が変わっていなくても子が描き直します。条件を書かなくても、スロットの中身かv-slotの値がその子を囲むスコープの変数を読んでいれば同じになり、名前を明示した転送は上から_: 1のスロットが渡ってこない限り昇格します- スロットの中身に付く scope 属性は親のものと、子のものに
-sを足したものです。子の素の属性は付きません。-s付きが配られるのは子の<style scoped>が:slotted()を使っているときだけで、親の属性は親が<style scoped>を持っているときだけ付きます
- Vue — Slots — 名前付き / スコープ付き / 条件付きスロットの書き方
- Vue — defineSlots() — シグネチャと型
- Vue — SFC CSS Features —
:slotted()の仕様
次に読む
- Provide / Inject とカスタムディレクティブ — props を経由しない受け渡し
- Teleport と Suspense — 描画先を DOM の別の場所へ移す