🐭

【React/Vue.js】UIコンポーネントのProps設計と具体的な作り方 | Offers Tech Blog

2022/06/13に公開


概要

こんにちは、Offers を運営している株式会社 overflow の Software Engineer(主戦場はフロントエンド)の Kazuya です。今回は、UI コンポーネントの Props 設計について紹介します。

コンポーネントを初めて作る方や作り慣れていない方は、どのような Props 設計にすれば、汎用的にできるのか、どこまで Props に持たせるべきか悩んだことがあるのではないでしょうか。本記事では、具体的な実装例を元に解説していきますので、ぜひ参考にしてもらえればと思います。

おすすめの記事

https://zenn.dev/offers/articles/20220523-component-design-best-practice

はじめに

本記事では、UI コンポーネントの Props 設計と具体的な作り方を紹介します。基本的に他のフレームワークや言語でも活用できますが、チームメンバーのスキルアセット、要件定義など様々な要因で本記事で紹介する内容とマッチしない場合があります。今回は設計の一例であることをご理解の上、参考にしていただけると幸いです。

Props とは?

Props とは、コンポーネントに対して定義できる値で、親コンポーネントから子コンポーネントに渡すことができます。TypeScript が導入されているフレームワークなどでは Props に型を定義可能です。

Props 設計におけるポイント

必要最低限の要素だけ含める

Props 設計だけでなく、プログラミングやデザインにも当てはまりますが、必要最低限の要素だけ含めるようにすることで、保守・運用のしやすいコンポーネントになります。Props を最小の構成にして責務を明確にしておくことで、汎用的にコンポーネントを扱いやすくなり、変更時にも影響を最小限に抑えることができます。

特定箇所でしか扱わないものは含めない

前述と類似していますが、特定のページやコンポーネントでしか扱わない Props を極力含めないようにすることで、Props の肥大化や保守性の低下を防げます。Props の定義には、厳密なルールが存在していないため、最低限のルールを定めておかないと Props がどんどん増えていき、保守するのが難しくなります。特定ページでしか使わない Props は、別コンポーネントとして切り出すかコンポーネントを細分化させて、できる限り Props 以外の方法で実装するようにしましょう。(大変ですが、中長期的に見た時に非常に効果的です ← 筆者の実体験より)

HTML がデフォルトで持っている Props を含める

Props に、HTML がデフォルトで持つ onClickonChange なども含めることで、より扱いやすい汎用的なコンポーネントになります。これに関しては、ケースバイケースにはなりますが、ボタンやカードなどUI の表示を責務としているコンポーネントには、基本的に含めて良いと思います。この際、コンポーネント毎に HTML の Props を定義するのは手間がかかるため、別で定義しておいて呼び出す構成にしておくとコードの冗長化を防げます。

グループ化できるものはオブジェクトにする

グループ化できるものはオブジェクトにすることで、コードの可読性と保守性を担保しやすくなります。Props に定義できる数は無制限ですが、数が多いと親コンポーネントから Props を渡す際にコードが冗長化してしまい、コード量と可読性を低下させる要因になってしまいます。これらの課題は、適切にオブジェクト化させておくことで改善できますが、オブジェクトのネストが深くなると逆効果になることもあるので、オブジェクト中にオブジェクトを入れすぎないように注意しましょう。

デザインパターンに対応できる構成を考える

コンポーネントによっては、デザインに影響を及ぼす Props が必要となるケースも存在します。例えば、ボタンコンポーネントでテキストの前にアイコンを入れたい場合など、デザインパターンが存在する際はこれも Props に含める必要があります。ここの線引がやや難しいのですが、デザインの切り替えを見境なく Props で制御すると、前述の「必要最低限の要素だけ含める」に反してしまうため、デザイン差分が大きくないものに限定する運用をすると良いと思います。(筆者もやや感覚的になっている節があります)

汎用的に扱える要素と型の定義にする

UI の表示を責務としているコンポーネントは、基本的に自身で定義した Props を使用することで、拡張性や保守性を担保しやすくなります。コードジェネレーターなどで自動生成した型定義をそのまま Props にすると、コンポーネントが型定義ファイルに依存してしまいます。依存してしまうと、コンポーネントの拡張や変更にも影響がでてしまうため、汎用的に扱いづらいコンポーネントになってしまうことがあります。

UI コンポーネントの作成フロー

① Props を定義する

まず、コンポーネントを作成するにあたって最初にすべきことは Props 設計です。Props を定義することで表示させる要素が明確になり、以降の実装もスムーズに進めることができます。Props については、前述のポイントを参考にして設計してみてください。

② HTML をマークアップする

取り扱うデータを Props で定義できたら、次に HTML をマークアップしていきます。HTML をマークアップしていく際に、Props で定義した値も組み込んでいきます。表示にロジックが絡むものがあると思いますが、この段階では後回しにします。ここではコンポーネントのフレームを作ることに注力します。

③ CSS でスタイルをあてる

大枠の HTML が組み上がったら、CSS を書いてスタイルを当てていきます。レスポンシブデザインが存在する場合も、この段階で対応しておくと良いです。レスポンシブデザインの後追い実装は、HTML の構造から見直しが入ることが多いため、この段階で調整しておくと未来の自分たちが幸せになります。

④ ロジックを組み込む

最後にロジックを組み込みます。なお、UI コンポーネントにはできる限りロジックは組み込まない設計にするのが、好ましいです。組み込むものは表示制御などの「UI を表示させる」という責務に収まるロジックに限定しておくと、コンポーネントが魔境になることを防げます。

コンポーネントの具体的な作り方

今回はボタンのコンポーネントを上記のポイントとフローを参考に作っていこうと思います。今回は、弊社でも扱っている Vue3 で解説していきますが、ReactVue2 でも基本的な考え方は変わらないので、参考にしてもらえると思います。

① 必要な Props を考える

まずは Props の設計から始めます。ボタンに必要な要素を洗い出していきます。

Name Type Overview
default slot ボタンのテキスト
size string ボタンのサイズ
color string ボタンの色

この辺はすぐに出てくると思います。これだけでも最低限はコンポーネントとして使用できそうですが、もう少しカスタマイズ性を持たせて、より汎用的に扱えるようにしておきたいです。例えば、テキストの前後にアイコンをオプションとして置けるようにしたり、横幅いっぱいに表示させるフラグなど汎用的に扱えそうなものを追加していきます。(この辺は、実装者の柔軟性が試されるところになります)

Name Type Overview
prefix slot テキストの前に付与する要素
suffix slot テキストの後に付与する要素
skeleton boolean ボタンを白抜きにするフラグ
rounded boolean ボタンを丸くするフラグ
wide boolean ボタンを横に伸ばすフラグ

これらは汎用的且つ「UI 表示」の責務範囲内の要素であるため、Props に含めても問題ないと思います。一旦、Props の定義ができたのでこの内容をコードに書き出してみます。

Button.vue
<script setup lang="ts">
interface Props {
  size?: 'sm' | 'md' | 'lg';
  color?: string;
  skeleton?: boolean;
  rounded?: boolean;
  wide?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  color: '#333'
});
</script>

<template><slot /></template>

<style lang="scss" scoped></style>


② HTML をマークアップしていく

次は、HTML をマークアップしていきます。今回はシンプルなボタンのコンポーネントなので、そこまで複雑な構造にはならないと思います。Slot はこの段階で、一緒に組み込んでしまいます。ここに関しては他に語ることもないので、サクッとコードを出して次のフェイズに進もうと思います。

Button.vue
<script setup lang="ts">
interface Props {
  size?: 'sm' | 'md' | 'lg';
  color?: string;
  skeleton?: boolean;
  rounded?: boolean;
  wide?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  color: '#333'
});
</script>

<button :class="className">
  <div v-if="$slots.prefix" class="prefix">
    <slot name="prefix" />
  </div>
  <slot />
  <div v-if="$slots.suffix" class="suffix">
    <slot name="suffix" />
  </div>
</button>

<style lang="scss" scoped></style>


③ CSS を書いてスタイルをあてる

HTML を組み上げたら CSS を書いてスタイルを当てていきます。今回は Vue に組み込まれているスコープ CSS で書いていますが、CSS   Module などで書いてもらっても大丈夫です。この辺りの CSS については、別記事で解説していく予定ですので、ここでは割愛させてもらいます。スタイルを当てたものが以下のコードになります。

Button.vue
<script setup lang="ts">
interface Props {
  size?: 'sm' | 'md' | 'lg';
  color?: string;
  skeleton?: boolean;
  rounded?: boolean;
  wide?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  color: '#333'
});
</script>

<button :class="className">
  <div v-if="$slots.prefix" class="prefix">
    <slot name="prefix" />
  </div>
  <slot />
  <div v-if="$slots.suffix" class="suffix">
    <slot name="suffix" />
  </div>
</button>

<style lang="scss" scoped>
.button {
  width: v-bind(width);
  display: inline-flex;
  justify-content: center;
  align-items: center;
  border-radius: v-bind(borderRadius);
  cursor: pointer;
  white-space: nowrap;
  background-color: v-bind(color);
  border: 1px solid v-bind(color);
  &.skeleton {
    background-color: transparent;
    border: 1px solid v-bind(color);
    &:hover :slotted(*) {
      color: #fff;
    }
  }
  &.size {
    &_sm {
      padding: 4px 16px;
    }
    &_md {
      padding: 8px 24px;
    }
    &_lg {
      padding: 12px 32px;
    }
  }
  &:hover {
    background-color: v-bind(hover);
  }
  &:disabled {
    background-color: v-bind(disabled);
  }
}
.prefix {
  margin-right: 8px;
}
.suffix {
  margin-left: 8px;
}
</style>


④ 最後にロジックを組み込む

最後に表示に関するロジックを組み込んでしまいます。今回は、Props で定義した値を元にスタイルを切り替え必要があるので、それに関係するロジックを書いています。Vue3 では CSS に JavaScript の変数を受け渡す CSS in JS のような機能があったので、そちらを使用した書き方にしていますが、クラス名で付与するパターンでも再現できると思います。完成したコードが以下です。

Button.vue
<script setup lang="ts">
import { darken, rgba } from 'polished';
interface Props {
  tag?: string;
  size?: Size;
  color?: string;
  skeleton?: boolean;
  rounded?: boolean;
  wide?: boolean;
}

type Size = 'sm' | 'md' | 'lg';

const props = withDefaults(defineProps<Props>(), {
  tag: 'button',
  size: 'md',
  color: '#333'
});

const width = props.wide ? '100%' : 'auto';
const borderRadius = props.rounded ? '50em' : '4px';
const hover = darken(0.05, props.color);
const disabled = rgba(props.color, 0.5);
const classNames = ['button', `size_${props.size}`];
if (props.skeleton) classNames.push('skeleton');
const className = classNames.join(' ');
</script>

<template>
  <Component :is="tag" :class="className">
    <div v-if="$slots.prefix" class="prefix">
      <slot name="prefix" />
    </div>
    <slot />
    <div v-if="$slots.suffix" class="suffix">
      <slot name="suffix" />
    </div>
  </Component>
</template>

<style lang="scss" scoped>
.button {
  width: v-bind(width);
  display: inline-flex;
  justify-content: center;
  align-items: center;
  border-radius: v-bind(borderRadius);
  cursor: pointer;
  white-space: nowrap;
  background-color: v-bind(color);
  border: 1px solid v-bind(color);
  &.skeleton {
    background-color: transparent;
    border: 1px solid v-bind(color);
    &:hover :slotted(*) {
      color: #fff;
    }
  }
  &.size {
    &_sm {
      padding: 4px 16px;
    }
    &_md {
      padding: 8px 24px;
    }
    &_lg {
      padding: 12px 32px;
    }
  }
  &:hover {
    background-color: v-bind(hover);
  }
  &:disabled {
    background-color: v-bind(disabled);
  }
}
.prefix {
  margin-right: 8px;
}
.suffix {
  margin-left: 8px;
}
</style>


まとめ

今回も長くなってしまいましたが、コンポーネントの Props 設計におけるポイントと具体的な実装方法について紹介しました。コンポーネント作成するには、以前紹介したフレーム設計、今回紹介した実装設計など考えるべきことが多くて難しいと思います。本記事で紹介した内容が、少しでもお役に立てれば嬉しいです。

本記事を最後まで読んで頂き、ありがとうございました。「こんな記事を書いてほしい!」などありましたらコメントいただけると幸いです。

関連記事

https://zenn.dev/offers/articles/20220523-component-design-best-practice
https://zenn.dev/offers/articles/20220418-what-is-bff-architecture
https://zenn.dev/offers/articles/20220519-thinking-about-dark-mode

Offers Tech Blog

Discussion