ボタンコンポーネントは難しいよ
見た目はボタン、中身は〇〇
ボタンコンポーネントと一言で言っても「見た目はボタン、中身は〇〇」のような見た目と中身が一致しないケースがしばしば見受けられます。
例えば、次のケースは日常的に特に遭遇することが多いです。
- ボタンに見えて
button
タグ - ボタンに見えて
a
タグ
上記に加え、Nuxt や Next.js を使用する場合、次のようなパターンも想定することができます。
- ボタンに見えて
NuxtLink
コンポーネント - ボタンに見えて
Link
コンポーネント
他にも様々なケースを考慮することができます。
- ボタンに見えて
input
タグ - ボタンに見えて
summary
タグ
etc...
ボタンコンポーネントと一言で言っても想定されるパターンが多すぎることが分かると思います。
これらのことからボタンコンポーネントを汎用的に作るべきか?別々で作るべきか?という問題に直面しています。
もし、汎用的に作るのであればどのように書くことができるのか?というのも課題だと思います。
社内でも様々な意見を頂き、ボタンコンポーネントをどのように作るべきかは非常に難しい問題だと感じています。
個人的な意見
私個人としては、汎用的かつ拡張性が高いコンポーネントを目指すべきだと考えています。
上記で記載した通り、ボタンコンポーネントとして想定すべきパターンが多すぎること、それら全てを別々で作成および修正する場合のコストを考えると、最初から汎用的かつ拡張性が高いコンポーネントを作成したほうがコストが低いと考えています。
もちろん、別々で作成することで単一責任の原則に沿った実装ができる、テストが肥大化しないといったメリットもあるため、トレードオフの関係があると思います。
汎用化を目的とした結果、複数の要素を切り替える処理が複雑化してしまったり、コードが肥大化してしまうと本末転倒となってしまいます。
そのため、汎用的に作る場合はどのようにコンポーネントを作るかが重要になってきます。
次に紹介するコードは、私が考える汎用的かつ拡張性が高いコンポーネントの例です。
Vue.js のサンプルコード
今回は業務でも使用している Vue.js を使ってサンプルコードを用意しました。
まずはコード全体をご覧ください。
<script setup lang="ts">
type Props = {
element: "button" | "anchor-link" | "nuxt-link";
text?: string;
type?: string;
href?: string;
target?: string;
to?: string;
};
const { element = 'button', text = '', type = '', href = '', target = '', to = ''} = defineProps<Props>();
defineSlots<{
icon: () => HTMLImageElement;
}>();
const emit = defineEmits<{
(e: "onClick"): void;
}>();
const onClick = () => {
emit("onClick");
};
// buttonタグの場合に使用する属性情報と押下時の処理
const buttonProps = {
type: type,
onClick,
};
// aタグの場合に使用する属性情報
const anchorProps = {
href: href,
target: target,
};
// NuxtLinkコンポーネントの場合に使用する属性情報
const nuxtLinkProps = {
to: to,
target: target,
};
const component = computed(() => {
switch (element) {
case "button":
return {
element: "button",
props: buttonProps,
};
case "anchor-link":
return {
element: "a",
props: anchorProps,
};
case "nuxt-link":
return {
element: resolveComponent("NuxtLink"),
props: nuxtLinkProps,
};
default: {
return {
element: "button",
props: buttonProps,
};
}
}
});
</script>
<template>
<component :is="component.element" v-bind="{ ...component.props }">
<slot name="icon" />
{{ text }}
</component>
</template>
<style scoped></style>
template について
特に注目して頂きたい点が template
のシンプルさです。
たった6行のコードしか書いていません。
<template>
<component :is="component.element" v-bind="{ ...component.props }">
<slot name="icon" />
{{ text }}
</component>
</template>
v-if
や三項演算子を使用していないことが分かると思います。
例えば、v-if
を使用して同じことをしようとする場合、下記のように書くことはできますが、切り替える要素の分だけ template
の中が肥大化してしまいます。
<template>
<button v-if="element === 'button'" :type="type">
<slot name="icon" />
{{ text }}
</button>
<a v-else-if="element === 'anchor-link'" :href="href" :target="target">
<slot name="icon" />
{{ text }}
</a>
<NuxtLink v-else :to="to" :target="target">
<slot name="icon" />
{{ text }}
</NuxtLink>
<template>
要素の切り替えには is
属性を使用します。
is
属性は、動的にどの要素をレンダリングするかを決定する特別な属性です。
また、ボタンの中にアイコン画像を配置したい場合、slot
を使用することができます。
slot
を使用する際は、defineSlots
というコンパイラーマクロを使用し、型定義をすることができます。
script について
button
タグ、a
タグ、NuxtLink
コンポーネントのうち、どの要素をレンダリングするかは props
を使用し、switch case で決定します。
TypeScript を使用することで button
or anchor-link
or nuxt-link
以外の文字列を渡そうとした場合は型エラーにすることができます。
もし、button
タグか a
タグかという2択を考えるだけで済む場合は template
内で v-if
や三項演算子を使用して判定することを考えてもいいかもしれません。
しかし、今回考慮するべきケースは button
タグ、a
タグ、NuxtLink
コンポーネントの3パターンです。
そして、拡張性についても配慮するとなると switch case を使用するほうが template
内がスッキリすると考えました。
もし、考慮するケースが増え、switch case が肥大化してしまう場合、composables として処理を切り出すことを検討しても良いのではないかと思います。
親コンポーネントからは button
タグ、a
タグ、NuxtLink
コンポーネントに関する様々な props
を受け取りますが、次のように変数に分けて書くことでその要素に必要な props
だけをレンダリング時に使用することができます。
// buttonタグの場合に使用する属性情報と押下時の処理
const buttonProps = {
type: type,
onClick,
};
// aタグの場合に使用する属性情報
const anchorProps = {
href: href,
target: target,
};
// NuxtLinkコンポーネントの場合に使用する属性情報
const nuxtLinkProps = {
to: to,
target: target,
};
button
タグを想定したケースで誤って href
属性に関する props
を渡してしまった場合であっても、button
タグに href
属性が設定されてしまうことはありません。
また、button
タグの場合、遷移先を設定するのではなく、defineEmits
を使用し、親コンポーネントに対してイベントを発行します。
const emit = defineEmits<{
(e: "onClick"): void;
}>();
const onClick = () => {
emit("onClick");
};
繰り返しにはなりますが、buttonProps
で定義した onClick
は button
タグの場合のみ有効であり、a
タグや NuxtLink
コンポーネントを使用する場合にemitによってイベントが発行されることはありません。
まとめ
以上が私の考えるボタンコンポーネントの作り方についてでした。
スタイルに関しては省略してしまったため、実際にはもう少しコード量が増えると思ってください。
また、今回は Vue.js を使用しましたが、React や Svelte など他のライブラリやフレームワーク等を使用する場合は話が変わってくるかもしれません。
ぜひ皆さんの意見を教えて頂きたいです。
Discussion