一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

フロントエンドの画面実装をボトムアップに行う

概要

初めまして、CTO室のいがにんこと山口(@igayamaguchi)です。一休.com/Yahoo!トラベルのフロントエンドの開発を担当しています。

この記事ではWebアプリケーションのフロントエンドの画面実装をボトムアップに実装することのメリットと、その方法を紹介します。

ボトムアップに画面を実装する

ボトムアップに画面を実装する、というのは小さなコンポーネントや処理から実装をしていき、それを組み合わせて徐々に大きなコンポーネントを作り、最終的に画面を作る実装方法です。

昨今のWebアプリケーションの実装で使用するReactやVueといったフレームワークはHTML、CSS、JavaScriptなどをディレクトリ、コンポーネントとしてまとめて実装することができます。この機能を利用し、

  • input、ボタンといった小さなコンポーネントを作成
  • 上記のコンポーネントを使用しさらに大きなコンポーネントを作成
  • さらに上記のコンポーネントを使用しページを作る

という開発の進め方がボトムアップに画面を実装する方法です。

これらのコンポーネントにはビジネスロジックが入り込んできたり、表示に必要なデータが変わります。そういったものをどう実装するのかも重要であり、それらもボトムアップにすることでよいものになります。

Webサイトはトップダウンで実装されがち

ボトムアップの逆のアプローチであるトップダウンに実装する方法とその問題点を整理します。

トップダウンに実装する、というのは画面を上から順に作成する方法です。ベースとなるHTML、CSS、JavaScriptのファイルを作成し、デザインを見て上から順に実装していきます。従来のWeb開発は、1ページ単位でHTML、CSS、JavaScriptをまとめて作るスタイルが主流でした。デザインが1枚のページとして作成され、そのデザインに対となるように1つのHTML、CSS、JavaScriptを作成して開発をするというのは、昔は一般的なアプローチだったと思います。

しかしこの方法では、以下の問題が起こりがちです。

  • 1ファイルに大量のコードが混在し、把握しにくくなる
    • 異なる業務処理が密結合し、影響範囲が不明確になる
    • 一部だけ改修したくても、他に思わぬ影響が及ぶ
  • サイト全体で使いまわせるようなものに考えが及びにくく、長期的によいコンポーネント設計、切り出しが行いにくい
  • コンポーネントを切り出すにしても、サイト全体で使いまわすものとページ固有で使うものを同時に考えなくてはならず、コンテキストの切り替えが困難
  • チーム開発でコンフリクトが頻発する

特に、1画面で複数の機能があるページではより深刻な問題になります。

こういった問題意識のもとでは、ボトムアップに実装することの重要性が高まってきます。

昨今のフロントエンドのフレームワークではコンポーネントを作ることができるので丸々1ページを1コンポーネントで作ることはないと思いますが、それでも、ある程度大きいコンポーネントを作ってしまいがちです。責務を複数持つような大きなコンポーネントを作成した場合、上述したトップダウンの開発と同様の問題が発生します。

例えば、実現したいことやワークフローをベースに設計を考えたり、デザインを面で見る思考に囚われると、コンポーネントも大きくなりがちだったりします。特定のinputやボタン/カードをコンポーネント化せず、ページや大きなコンポーネントにそのままHTML/CSSを書いていたりはしていないでしょうか。

実例の紹介

では実際に実例を見ながらどうやって問題が解決されるかを説明していきます。まずは大枠の流れです。

  • 全体のコンポーネント抽出、設計
  • 汎用的に使用できるUIのコンポーネントを実装
  • ベースとなるワークフロー、画面の仮実装
  • 各グループごとのコンポーネントの実装

この流れ通りに説明をしていきます。

実際に作成される画面はこちらです。

全体のコンポーネント抽出、設計

まず最初に画面を見てコンポーネントを抽出し、設計を考えます。ボトムアップといえど、全体としてどのようなコンポーネントが必要になるのか、各所でどうやって使いまわすかの見通しを立てておきます。これにより、ある箇所のために作ったコンポーネントが局所最適になり、他のページで使えなくなるということを防ぎます。

これらのコンポーネントはいくつかに分類できます。

  • 1.どんなサイトでも使用できる汎用UI要素
    • 例: input、チェックボックス、ボタン
  • 2.サイト内で汎用的に使用できるサイト特有のUI要素
    • 例: キャンセルポリシー、料金情報
  • 3.そのページ内で使いまわすページ固有のUI要素
    • 例: そのページ用のお知らせ、エラー表示、固有のレイアウトを実現するための箱
  • 4.ページを業務処理ごとにグルーピングしたUI要素
    • 例: カード決済や予約者情報といったフォーム、予約時の注意事項

分けたコンポーネントはそれぞれ前のもののみに依存し、次のものには依存しないように分けます。これらを順に実装していきます。 最初の3つが汎用的に使用できるUIのコンポーネント、最後の4つ目がページを実際に作るためのコンポーネントとなります。

汎用的に使用できるUIのコンポーネントを実装

どんなコンポーネントを実装するか計画したら実装に移っていきます。 汎用的に使用できるUIのコンポーネントから実装を始めていきます。

どんなサイトでも使用できる汎用UI要素

最初に実装するコンポーネントは、どんなサイトでも使用できる汎用UI要素です。例えば、input、ボタンといったHTMLのサブセットのようなUI要素や、テーブル、モーダルといったUI要素です。

各コンポーネントに応じてどんな表示パターンがあるかをしっかり考えて実装します。inputであれば通常表示、値が入った時、エラー時、disable時などいろいろなパターンが考えられます。

コンポーネントのコードに業務仕様が入らないようにして、どのサイトでも使えるように設計することで、そのサイト内でも使いまわしやすいものにできます。

最初にサイト全体のベースとなるUI要素を作ることで、そのコンポーネントがサイト全体で使い勝手の良いものになっているかをUIに焦点を当てて考えることができます。

サイト内で汎用的に使用できるサイト特有のUI要素

次に実装するコンポーネントはサイト内で汎用的に使用できるサイト特有のUI要素です。例えば、一休.comであればいたるところにある料金情報やキャンセルポリシー、ホテルのリンクカードなどです。

業務仕様を含みつつもサイト内で使いまわしが効き、まとまっているとメンテナンスが楽になるもの、変更タイミングが同じものをコンポーネントとして実装します。いくつかレイアウトのパターンがあるものはコンポーネントのpropsで調整できるよう設計しておきます。

後々触れますが、一休.comではGraphQLを使用しているので、こういったコンポーネントにはfragmentを定義して必要なデータの取得を強制することで、間違った値が設定されることも防いでいます。

ページ内で使いまわすページ固有のUI要素

3つ目に実装するコンポーネントは、ページ内で使いまわすページ固有のUI要素です。例えば、そのページ用のお知らせ、エラー表示、固有のレイアウトを実現するための箱です。

ここまで実装をすると、既存のコンポーネントを組み合わせるだけで、ある程度の画面デザインを作成することが可能になります。後続で説明する機能のUIを実装するときに、いちいちサイトやページ全体のUIコンポーネントの設計に頭を切り替える必要がなくなり、業務のUIの実装に集中できる状態を作ることができます。たとえば、カード決済の入力欄を実装するとき、inputなどのUIコンポーネントの使いやすさに思考を切り替えることなく、カード決済の業務のみに向き合うことができるようになります。

これを守るために、ページの業務のUIをいきなり作り始めないことが大切です。

ベースとなるワークフロー、画面の仮実装

画面表示のためのUIコンポーネントがそろったら、次に行うのは大枠のワークフローの実装です。ここでは少しだけトップダウンに実装を考えていきます。

まず各業務コンポーネントを表示するために必要なデータを取得するフローを組みます。一休.comではGraphQLを使用しているため、必要なqueryを投げる処理を組んであげます。 以下は簡易的に書いた例です。

const graphqlQuery = graphql(`
  query DraftOrder($draftOrderId: DraftOrderIdScalar!) {
    draftOrder(id: $draftOrderId) {
      id
      # ここにfragmentを追加予定
    }
  }
`)
export function useBookingForm() {
  const variables = computed(() => ({/* 様々な値 */}))
  const { data } = await useAsyncQuery(graphqlQuery, variables)
  const draftOrder = computed(() => data.value.draftOrder)
  return {
    draftOrder,
  }
}

上記のように、メインのデータ取得となるqueryでは各コンポーネント用のfragmentを読み込む想定で作成します。 さらに、ページのルートとなるコンポーネントから各コンポーネントへ、取得したデータをpropsへ流し込めるようにしておきます。

<script setup lang="ts">
const { draftOrder } = await useBookingForm()
</script>
<template>
  <div>
    <!-- この段階ではまだコンポーネント呼び出しはないが、以下のようにpropsに設定するイメージ -->
    <PaxProfile :draftOrder />
  </div>
</template>

これにより、後は各コンポーネント実装時に必要なデータをfragmentで記述し、fragmentとコンポーネントをベースの実装に差し込むだけでページ、コンポーネントが機能するようになります。

次からは各グループの業務処理、UIを実装していくことになります。

ページを業務処理ごとにグルーピングしたUI要素

UIコンポーネントとページのデータ連携の仕組みがそろったところで、ページに含まれる複数の業務をグルーピングし、それぞれのグループのUIを実装することで、ページを作り上げていきます。例えば支払方法を選択するUI要素です。

すでにUIコンポーネントがそろっている状況なので、各UIを実装するときにはコンポーネントを組み合わせて少しスペーシングを調整するくらいで済むようになっているはずです。いちいちinputの実装をしたりする必要はありません。ただどんなプロパティを使ってコンポーネントを呼び出し、どうやって並べるか、だけを考えればよいです。

<template>
  <Box>
    <SectionHeader
      title="お支払い方法"
      class="mb-6"
      size="2xl"
      tag="h2"
    />
    <InvalidArgumentErrorBox :errors="errors" />
    <div class="mb-4">
      <ErrorBox v-if="errorMessage">{{ errorMessage }}</ErrorBox>
    </div>
    <section class="pc:p-6 rounded border border-gray-300 bg-gray-100 p-4">
      <div class="flex items-baseline justify-between">
        <InputLabel :id="cardNumber.name" text="カード番号" required />
        <ul class="relative flex gap-x-2">
          <li v-for="company in cardCompanies" :key="company.name">
            <Component :is="company.svg" height="28" width="28" />
          </li>
        </ul>
      </div>
      <Input
        :id="cardNumber.name"
        v-model.numberText="cardNumber.value"
        placeholder="1234 1234 1234 1234"
        :error-message="cardNumber.errorMessage"
        :input-props="{
          autocomplete: 'cc-number',
          inputmode: 'numeric',
        }"
      />
      <!-- ... -->
    </section>
  </Box>
</template>

必要なデータについてもfragmentを定義し、propsに設定して、

<script>
export const fragment = graphql(`
  fragment CreditCardInputDraftOrder on DraftOrder {
    checkOutDate
    cardSalesDate
  }
`)
</script>
<script setup lang="ts">
defineProps<{
  draftOrder: FragmentType<typeof fragment>
}>
</script>
<template>
  <!-- 上記のtemplate... -->
</template>

ベースのqueryにfragmentを追加、

const graphqlQuery = graphql(`
  query DraftOrder($draftOrderId: DraftOrderIdScalar!) {
    draftOrder(id: $draftOrderId) {
      id
      # 追加
      ...CreditCardInputDraftOrder
    }
  }
`)

propsに流し込んだら自動で取得してコンポーネントが描画できます。

<script setup lang="ts">
const { draftOrder } = await useBookingForm()
</script>
<template>
  <div>
    <PaxProfile :draftOrder />
    <!-- 追加 -->
    <CreditCardInput :draftOrder />
  </div>
</temlate>

このとき注目してほしいのは、ベースの実装はこの結合部分のみを意識しており、各業務コンポーネントについては関知しないことです。ベースの実装が知っているのはコンポーネントを使うこと、そのコンポーネントに必要なfragmentのみです。こうすることで、各コンポーネントの実装に集中しつつ、fragmentとコンポーネントの呼び出しだけで簡単にページにコンポーネントを組み込むことができるようになっています。

これを繰り返すことで画面がどんどん組みあがっていきます。

さらに業務ごとにコンポーネントを切って実装することでその特定業務のコンポーネントに集中できます。例えばカード決済の入力欄を実装するときに、予約者情報など別の業務について考えずに済みます。

さらにさらにチームで開発をするときにも、他メンバーが別の箇所を実装していても、自分の箇所と切り離して考えることができます。ベースの土台の実装にのっとった実装となっていれば影響を受けずに並列での開発が可能です。

まとめ

ボトムアップに画面を実装することで以下の良いことがあります。

  • 汎用的なUI要素を最初に実装することで
    • サイト全体で見て、使い勝手の良いものになっているかをUIに焦点を当てて考えることができる
    • 各業務コンポーネントを作るときに、いちいち土台のコンポーネント設計に頭を切り替える必要がなくなる
      • 例えばカード決済の入力欄を開発する、となったときにinputの見た目がどうとかを考えずに済む
  • ベースを実装し枠組みを作ることで
    • そのワークフローに乗るだけで各業務の実装に集中することができる
  • グループごとの実装を分けることで
    • 各業務コンポーネントを作るときに、その業務のコンポーネントに集中できる
      • 例えばカード決済の入力欄を開発するときに、予約者情報など別の業務について考えずに済む
    • 他メンバーが別の箇所を実装していても、自分の箇所と切り離して考えることができる
    • ベースの土台の実装にのっとった実装となっていれば影響を受けずに並列での開発が可能

ぜひみなさんもボトムアップに実装をしていきましょう。