😵‍💫

ボタンコンポーネントは難しいよ

2024/05/19に公開

見た目はボタン、中身は〇〇

ボタンコンポーネントと一言で言っても「見た目はボタン、中身は〇〇」のような見た目と中身が一致しないケースがしばしば見受けられます。

例えば、次のケースは日常的に特に遭遇することが多いです。

  • ボタンに見えて 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 props = defineProps<Props>();

defineSlots<{
  icon: () => HTMLImageElement;
}>();

const emit = defineEmits<{
  (e: "onClick"): void;
}>();

const onClick = () => {
  emit("onClick");
};

// buttonタグの場合に使用する属性情報と押下時の処理
const buttonProps = {
  type: props.type,
  onClick,
};

// aタグの場合に使用する属性情報
const anchorProps = {
  href: props.href,
  target: props.target,
};

// NuxtLinkコンポーネントの場合に使用する属性情報
const nuxtLinkProps = {
  to: props.to,
  target: props.target,
};

const component = computed(() => {
  switch (props.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 属性は、動的にどの要素をレンダリングするかを決定する特別な属性です。

https://ja.vuejs.org/api/built-in-special-attributes#is

また、ボタンの中にアイコン画像を配置したい場合、slot を使用することができます。
slot を使用する際は、defineSlots というコンパイラーマクロを使用し、型定義をすることができます。

https://ja.vuejs.org/api/sfc-script-setup#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 として処理を切り出すことを検討しても良いのではないかと思います。

https://ja.vuejs.org/guide/reusability/composables

親コンポーネントからは button タグ、a タグ、NuxtLink コンポーネントに関する様々な props を受け取りますが、次のように変数に分けて書くことでその要素に必要な props だけをレンダリング時に使用することができます。

// buttonタグの場合に使用する属性情報と押下時の処理
const buttonProps = {
  type: props.type,
  onClick,
};

// aタグの場合に使用する属性情報
const anchorProps = {
  href: props.href,
  target: props.target,
};

// NuxtLinkコンポーネントの場合に使用する属性情報
const nuxtLinkProps = {
  to: props.to,
  target: props.target,
};

button タグを想定したケースで誤って href 属性に関する props を渡してしまった場合であっても、button タグに href 属性が設定されてしまうことはありません。

また、button タグの場合、遷移先を設定するのではなく、defineEmits を使用し、親コンポーネントに対してイベントを発行します。

const emit = defineEmits<{
  (e: "onClick"): void;
}>();

const onClick = () => {
  emit("onClick");
};

繰り返しにはなりますが、buttonProps で定義した onClickbutton タグの場合のみ有効であり、a タグや NuxtLink コンポーネントを使用する場合にemitによってイベントが発行されることはありません。

まとめ

以上が私の考えるボタンコンポーネントの作り方についてでした。

スタイルに関しては省略してしまったため、実際にはもう少しコード量が増えると思ってください。

また、今回は Vue.js を使用しましたが、React や Svelte など他のライブラリやフレームワーク等を使用する場合は話が変わってくるかもしれません。

ぜひ皆さんの意見を教えて頂きたいです。

GitHubで編集を提案

Discussion