「Atomic Design × Nuxt.js」で破綻しにくい実装を目指すために決めたコンポーネント毎の責務

aumoではaRMと呼ばれる(呼んでいる)CRMツールを展開しています。既存でLaravelのモノリスで構成していたものを全面刷新のタイミングで、フロントエンドを「Atomic Design × Nuxt.js」の構成にしたのでその話をします。

去年、体脂肪を6%強減らしてから今までずっと維持し続け、次のタイ旅行までは維持することを決意し、首を長く伸ばしながら待ち疲れているaumoのサーバーサイドエンジニア村田です。

aumoでは機能、プロダクトごとにメインエンジニアが1人付き、インフラ~フロントエンドを一貫して設計から実装までその人が担当する体制を取っています。

直近、CRMツールの刷新プロジェクトがあり、わたしがメインエンジニアとして担当しました。今回はそのプロジェクトの中でフロントエンドにフォーカスし、「Atomic Design × Nuxt.js」で構成した中で、コンポーネント毎にどのように責務を分けたのかをピックアップしてお伝えします。

Atomic Designとは

まずコンポーネントパターンには

  • アルファベット順
  • 階層型
  • タイプ型
  • 目的別

などがあります。

その中の階層型で有名なのがAtomic Designです。Brad Frost氏が発案したもので、構成要素を「ATOMS(原子)、MOLECULES(分子)、ORGANISMS(有機体)、TEMPLATES(テンプレート)、PAGES(ページ)」の階層に分けた設計手法です。

詳しくはAtomic Design by Brad Frostで読むことができます。ちなみにAtomic Designは日本語だと「アトミックデザイン」ですね。

コンポーネントとディレクトリと責務

Nuxt.jsのディレクトリ構成を元にAtomic Designの構成を当てはめました。実際のディレクトリ構成は以下です。

レベルAtomic Designディレクトリ(Nuxt.js)責務
1ATOMS(原子)~components/atoms・UIパーツ
2MOLECULES(分子)~components/molecules・ATOMSを組合せたUIパーツ
3ORGANISMS(有機体)~components/organisms・複数のATOMS、MOLECULESを持つUI
・Storeとのやり取り(Dispatch/Render)
・ビジネスロジック
4TEMPLATES(テンプレート)~layouts・ページレイアウト
・PAGESで1度のみ
5PAGES(ページ)~pages・アプリケーションのルート
・router関連の処理
・認証認可
・Storeとのやり取り(Dispatchのみ)

ATOMS、MOLECULES、ORGANISMSをcomponentsディレクトリ以下に配置しています。TEMPLATESはNuxt.jsではlayoutsディレクトリがデフォルトで存在するので、そちらに配置しています。PAGESも同様にNuxt.jsではpagesディレクトリがデフォルトで存在するので、そちらに配置しています。

コンポーネントとコンポネート間のデータやりとり

各コンポーネント毎にサンプルコードをベースにし、どのように使用しているのか説明していきます。

ATOMS、MOLECULES、ORGANISMSについて

ATOMS

最小単位のパーツでボタンやラベルなどが当てはまります。細かなデザインの違い毎に分割するのではなく、CSSを用意してClassを変えることで異なるデザインが反映されるようにしています。CSSでmarginは持たせないようにします。

データのやり取りについてはslotにより親から渡されるのに限定しています。

以下、タイトルのサンプルです。

<template lang="pug">
  h1.title
    slot
</template>

<script>
export default {
  name: 'Title'
}
</script>

<style lang="scss">
.title {
  font-weight: bold;
  font-size: 21px;
  line-height: 32px;
  color: colors(primary-black);

  &--important {
    color: #ff5238;
  }
}
</style>

MOLECULES

複数のATOMSを組み合わせて、これだけで意味を成す単位のパーツです。

データのやり取りについては、propsを通して親からデータを埋込むようにしています。Storeとのやり取りをここでは行わないようにします。

以下、店舗の基本情報編集です。

<template lang="pug">
  div
    Title.title--md 基本情報
    div.mol-shop-edit-preview
      div.mol-shop-edit-preview__item
        p.mol-shop-edit-preview__text.mol-shop-edit-preview__text--bold 「{{ shop.name }}」の基本情報
        p.mol-shop-edit-preview__text
          span(v-if="shop.postal_code") 〒{{ shop.postal_code.substr(0, 3) }}-{{ shop.postal_code.substr(3, 4) }}&nbsp;
          span {{ shop.full_address }}
      div.mol-shop-edit-preview__item
        GoogleMap(
          :latitude="shop.latitude"
          :longitude="shop.longitude"
        )
      div.mol-shop-edit-preview__border
      div.mol-shop-edit-preview__button
        NuxtLink(
          :to="{ path: `/shops/${shop.id}/edit/basic_info` }"
        )
          Button.button--sm
            .button__text 基本情報を編集
</template>

<script>
export default {
  name: 'ShopEditBasic',

  props: {
    shop: {
      type: Object,
      default: () => {}
    }
  }
}
</script>

propsからデータを取得し、それを利用して店舗情報を表示しています。

ORGANISMS

ATOMS、MOLECULESを複数組み合わせたUIです。フォーム処理など、ビジネスロジックの取り扱いをさせます。

データのやり取りについては、Storeから取得、更新を行います。Storeから取得したデータをMOLECULESに渡す役目を担います。

以下、店舗トップページ編集UIです。

<template lang="pug">
  div
    Title 店舗トップページ編集
    ShopEditPhoto(
      :shop="shop"
      :shopMedias="shopMedias"
    )
    ShopEditOfficialLink(
      :shop="shop"
    )
    ShopEditBasic(:shop="shop")
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'ShopEditIndexMain',

  computed: {
    ...mapGetters('shop', ['shop']),
    ...mapGetters('shopMedia', ['shopMedias'])
  }
}
</script>

Storeからgetterを使用してデータを取得し、MOLECULESに渡すようにしています。

TEMPLATESとPAGESについて

TEMPLATES

TEMPLATESは「いくつかの有機体・分子・原子から成り立つ骨組み」であり、ページレイアウトを構成するものとしています。なのでTEMPLATESではデータのやり取りは行いません。

以下はデフォルトテンプレートです。

<template lang="pug">
  div.base
    Header
    div.base__contents
      Nuxt
    Footer
</template>

<script>
export default {
  name: 'Default'
}
</script>

PAGES

テンプレートにコンテンツを入れ込んだ状態がPAGESになります。

middlewareからデータをAPIから取得し、Storeに格納する役割を担うようにさせ、ORGANISMSがStoreから取得できるようにしています。

以下は店舗ログインパスワード再設定ページです。

<template lang="pug">
  form(
    :name="formName"
  )
    Title パスワード設定
    FormUserPassword(:saveFormName="formName")
</template>

<script>
export default {
  layout: 'ShopTemplate',

  middleware: 'fetchShop',

  data () {
    return {
      formName: 'form-user-password'
    }
  }
}
</script>

middlewareを使用してAPIからデータを取得し、Storeに格納するようにしています。

以下はmiddlewareでAPIからデータを取得し、Storeに格納する処理です。

export default function ({ store, params, query, error }) {
  const shopId = query.shop_id || params.id
  return store.dispatch('shop/getShopAction', { shopId })
}

コンポーネントと状態管理

状態管理はVuexを使用しています。コンポーネント毎にデータに関する責務を決める中で、Storeとやり取りできるコンポーネントを限定することにより、状態管理の保守性を向上させるようにしています。

さらに、状態管理に関わる概念を定義、分離し、特定のルールを敷くことで、コードの構造と保守性を向上させることができます。

Vuex とは何か?

コンポーネント間で発生するpropsのバケツリレー

コンポーネント間のデータやりとりで、propsのバケツリレーが面倒であると言われることがあるのでルール化しています。

ORGANISMSはStoreからデータを取得 -> MOLECULESはpropsでデータ受け取る -> ATOMSはslotでデータ受け取る

上記ルールでデータやりとりをするようにしているので、propsによるバケツリレーを1度に抑えるようにしてコードを追いやすくし、保守性を向上させるようにしています。

最後に

ただし、アトミックデザイン(またはその他の手法)は、そのままでは使えない場合があることを覚えておいてください。FutureLearnでは、「テンプレート」と「ページ」の用途を見つけるのに苦労しました。どのように組み合わせるかより自由度が高かったので、コンポーネントより小さめとなる要素を扱う方が好まれました。

Alla Kholmatova. Design Systems デジタルプロダクトのためのデザインシステム実践ガイド (Japanese Edition) (Kindle の位置No.2747-2749). Kindle 版.

Atomic Designに限ったことではないですが、どんな手法でもそのまま導入するというのは難しい場合があります。プロダクトに応じて柔軟に組み入れることが必要です。そこで一番重要になるのがルールの策定とそれを元に共通認識を持つことです。ここがしっかりできれば(肝に銘じる部分)、プロダクトが拡張していった際も破綻する可能性を抑えることができると思っています。

こちらに類似した内容を2021年11月に開催されたGREE Tech Conference 2021にて発表させてもらいました。当日のスライドと動画がアーカイブで残っているので、興味があればそちらもご覧ください。