まえがき
本記事では、Vueを書き始めた頃には使い方のイマイチわかりづらいscoped slot(スコープ付きスロット)を使ったラジオボタングループの作り方を解説します。
またなぜそのようなコンポーネントを作るのか、という途中の過程も合わせて解説していこうと思います。
最終成果物のコードだけが見たい方はこちらから。
今回作るコンポーネント
<input type="radio">
をラップした、以下のようなシンプルなラジオボタングループを作ります。
要件は以下のように最小限に絞ります
- ラベルとして表示される文言をカスタムできる
- changeイベントで値の変更をキャッチできる
AtomicDesignで言うところのAtomに近い原始的なコンポーネントなので、再利用性がありかつある程度の柔軟なカスタムができる必要があります。
※ 余談
原始的なコンポーネントを作る際に考えることは、
「そのコンポーネントが標準のHTMLに採用されて違和感がないか?」だと思っています
- 無意味なpropsが多すぎないか
- 内部実装を隠蔽しすぎていないか
などを注意するのが基本になります
最初に考える素朴な実装
パターン1
登場するコンポーネントが最も少ないパターンを考えてみます。
Vue.jsを触り始めた方はまずこのような実装を考えるのではないかと思います
構造
実装
<template>
<div id="app">
<app-radio name="radio" :radioItems="radioItems" v-model="value" />
<p>現在の値は:{{ value }}</p>
</div>
</template>
<script>
import Radio from "./components/Radio";
export default {
name: "App",
components: {
"app-radio": Radio
},
data() {
return {
value: "2"
};
},
computed: {
radioItems() {
return [
{ value: "1", label: "item1" },
{ value: "2", label: "item2" },
{ value: "3", label: "item3" },
{ value: "4", label: "item4" },
{ value: "5", label: "item5" }
];
}
}
};
</script>
<template>
<ul class="list">
<li v-for="(radioItem, index) in radioItems" :key="index">
<input
type="radio"
:id="`name$[index}`"
:name="name"
:value="radioItem.value"
:checked="radioItem.value === value"
@change="$emit('change', radioItem.value);"
/>
<label :for="id">{{ radioItem.label }}</label>
</li>
</ul>
</template>
<script>
export default {
name: "AppRadio",
model: {
prop: "value",
event: "change"
},
props: {
id: String,
name: String,
radioItems: Array,
value: String
},
data() {
return {};
}
};
</script>
<style scoped>
.list {
list-style-type: none;
}
</style>
動作イメージ
この実装がなぜ不十分か
この実装形式ではラベルの表示のさせ方、リストの表示のさせ方がすべてRadioコンポーネントの中に隠蔽されており、
外部からカスタムすることができなくなっています。
よって、表示をカスタムしたいときにそれができない(プレーンな文字列でしか表示できない)という欠点を抱えています。
また、このように内部実装を隠蔽したコンポーネントを作った場合、内部で更に孫にあたるコンポーネントを作るような場合に、
孫に渡すためだけの不要なpropsを渡さないといけなくなり(下図)、破綻しやすくなります。
パターン2
パターン1では実装を内部に隠蔽してしまったために不都合が発生してしまいました。
標準のHTMLのことを考えてみると例えばul
とli
はこのような構成になっていません。
以下のコードのような入れ子の構造で構成されています。
<ul>
<li></li>
<li></li>
</ul>
そこでVueのslotの機能を使って、上の例で言うulに相当するRadioGroupというリストを束ねるためのコンポーネントを導入してみます
構造
実装
<template>
<div id="app">
<app-radio-group>
<app-radio
v-for="(radioItem, index) in radioItems"
:key="index"
name="radio"
:radioItem="radioItem"
:id="`radio${index}`"
:checked="radioItem.value === value"
@change="selected => (value = selected)"
/>
</app-radio-group>
<p>現在の値は:{{ value }}</p>
</div>
</template>
<script>
import Radio from "./components/Radio";
import RadioGroup from "./components/RadioGroup";
export default {
name: "App",
components: {
"app-radio": Radio,
"app-radio-group": RadioGroup
},
data() {
return {
value: "2"
};
},
computed: {
radioItems() {
return [
{ value: "1", label: "item1" },
{ value: "2", label: "item2" },
{ value: "3", label: "item3" },
{ value: "4", label: "item4" },
{ value: "5", label: "item5" }
];
}
}
};
</script>
<template>
<ul class="list">
<slot></slot>
</ul>
</template>
<script>
export default {};
</script>
<style scoped>
.list {
list-style-type: none;
}
</style>
<template>
<li>
<input
type="radio"
:id="id"
:name="name"
:value="radioItem.value"
:checked="checked"
@change="$emit('change', radioItem.value);"
/>
<label :for="id">{{ radioItem.label }}</label>
</li>
</template>
<script>
export default {
name: "AppRadio",
props: {
id: String,
name: String,
radioItem: Object,
checked: Boolean
},
data() {
return {};
}
};
</script>
<style scoped>
.list {
list-style-type: none;
}
</style>
余計なイベントやpropsを省略した場合、App.vueからみたラジオボタンは以下のような構成になっています
<app-radio-group>
<app-radio />
<app-radio />
<app-radio />
</app-radio-group>
slotを使うことで、見た目が標準のHTMLにかなり近くなりました。
また、slotなので、UIのカスタムも自由に行うことができます
この実装がなぜ不十分か
実際のアプリケーションではこれぐらいのコードを書いて満足することは多いと思いますが、しかしこの実装はまだ不十分です。
つまり<app-radio-group>
と<app-radio>
の間に関係性がなく、<app-radio-group>
はほぼ無意味なレイヤになってしまっています。
以下のようなコードでも同じ動きをするからです
<ul> // えっ
<app-radio />
<app-radio />
<app-radio />
</ul>
すべてのイベントコントロールやプロパティの受け渡しを<app-radio>
で行わないといけないのも使い勝手がよくありません
そしてslotの中で発生したイベントを<app-radio-group>
は検知することができません
理想的なコンポーネントとは?
いよいよ本題に入ります
今までの2つの実装例の不満を整理すると、
- App.vueのレイヤから
<app-radio-group>
と<radio-group>
のコントロールができるようにしたい - とはいえ
<app-radio-group>
と<radio-group>
の間に一定の関連性をもたせたい
といった点があげられます。
それを解消するのがVueのScoped Slotになります
ScopedSlotを利用すると以下のような構造を作ることができます
理想のコンポーネントを実際に作ってみる
実際のコードがこちらになります(上の例と同じ部分は省いています)
<template>
<div id="app">
<app-radio-group :radioItems="radioItems" v-model="value" name="radio">
<template slot-scope="{ radioItems, name, onchange, value }">
<app-radio
v-for="(radioItem, index) in radioItems"
:key="index"
:name="name"
:radioItem="radioItem"
:id="`radio${name}${index}`"
:checked="radioItem.value === value"
@change="onchange"
/>
</template>
</app-radio-group>
<p>現在の値は:{{ value }}</p>
</div>
</template>
<template>
<ul class="list">
<slot v-bind="slotProps"></slot>
</ul>
</template>
<script>
export default {
model: {
prop: "value",
event: "change"
},
props: {
radioItems: Array,
value: String,
name: String
},
methods: {
onchange(val) {
this.$emit("change", val);
}
},
computed: {
slotProps() {
return {
name: this.$props.name,
radioItems: this.$props.radioItems,
value: this.$props.value,
onchange: this.onchange
};
}
}
};
</script>
<template>
<li>
<input
type="radio"
:id="id"
:name="name"
:value="radioItem.value"
:checked="checked"
@change="$emit('change', radioItem.value);"
/>
<label :for="id">{{ radioItem.label }}</label>
</li>
</template>
注目してほしいのはApp.vue
の
<app-radio-group :radioItems="radioItems" v-model="value" name="radio">
<template slot-scope="{ radioItems, name, onchange, value }">
<app-radio
の部分です。一度<app-radio-group>
に渡したpropsがslotを経由して戻ってくるようなイメージです。
これで<app-radio>
に渡されるpropsは必ず<app-radio-group>
を経由し、<app-radio>
から送られるイベントは必ず
<app-radio-group>
を一度通過するような構造になります。
デフォルト実装を用意する
上の例でコードはある程度完成しましたが、
ラジオボタングループを毎回slot-scopeを使って呼び出すのは面倒な場合があります。
そのときにはscoped-slotのデフォルト実装を用意することができます。
コードは以下のようになります(変わっていないところは省略)
全体の実装が見たい方はCodeSandboxをどうぞ
<template>
<div id="app">
<app-radio-group :radioItems="radioItems" v-model="value" name="radio" />
<p>現在の値は:{{ value }}</p>
</div>
</template>
<template>
<ul class="list">
<slot v-bind="slotProps">
<app-radio
v-for="(radioItem, index) in radioItems"
:key="index"
:name="name"
:radioItem="radioItem"
:id="`radio${name}${index}`"
:checked="radioItem.value === value"
@change="onchange"
/>
</slot>
</ul>
</template>
<script>
import Radio from "./Radio.vue";
export default {
components: {
"app-radio": Radio
},
RadioGroupのslotの中に書かれたものがデフォルト実装として利用されるため、App.vueがとてもスッキリしました。
これは一番最初に例示した実装を隠蔽するパターンとほぼ同じような気がしますが、
あとになってカスタムが必要になればApp.vueのほうで、
scoped slotを利用した実装を追加してカスタムができるという大きな利点があります。
このようにデフォルト実装を用意することでコンポーネントに対して強い柔軟性をもたせながら、
手軽なコンポーネントを作ることができるようになっています
さらに踏み込んだコンポーネントを作る
よりVue実装力を上げるためにはVuetifyやMaterialUIなど世間のUIフレームワークの実装を見るのが非常に勉強になります。
実際に作るコンポーネントのインターフェイスを考える上でも、内部実装の面でも役立つことが多いと思います
今回のラベルで言えば以下のようになっています
どちらの実装でも
<radio-group>
<radio-button/>
<radio-button/>
<radio-button/>
</radio-group>
のようなインターフェイスでコンポーネントを利用できるようになっています。
(グループとボタンの関係性を作るのに、残念ながらScopedSlotは使われていません...)
MaterialUIではslotの中身をquerySelectorで検索する方式を採用しています
VuetifyではVueコンポーネントを操作する抽象レイヤがしっかり用意されていて、そこを経由して親子関係を作っています
(Vuetifyのほうがちょっと凝った作りになっていて読むのが大変)
あとがき
いかがでしたか?少しでも参考になった方がいれば幸いです。
scoped slotはそこまで頻繁に使う機能ではありませんが、選択肢として覚えておくと
特にリストやoptionのようなコンポーネントを作成するときに設計の幅がぐっと広がります。
ぜひご活用ください