Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

変更に強いコンポーネント設計の方針と規約(Webフロントエンド)

技術本部 データ戦略部 Newsグループの木田です。

最近、初めて自作キーボードに挑戦しました。ちょうど2枚目のモニターも買ったので、モニター2台と自作キーボードで快適に記事を書いています。

予めお断りしておきますが、この記事は元々、社内向けに設計方針や規約・ツールなどについて共有するために書いたものでした。最近、他チームの参考資料として役立ったこともあり、社外向けに手を加えて公開する運びとなりました。

はじめに

突然ですが、アプリケーションを設計する上で大切にしていることはありますか?私は、変更に強いか を常に意識しています。変更に強いアプリケーションを構築するためには、ソースコードや規約を通して 意思と意図が明確なレールを敷く ことが重要だと考えています。

また、本記事は「Webアプリケーションの開発経験がある方」を想定して書きました。具体的な実装の話はあまり出てこないので、Webフロントエンド経験がなくても大丈夫かと思います。1

特に「イチからWeb フロントエンドアプリケーションを構築する予定だが、どのような構成にするか迷っている」場合などの参考となったら嬉しいです!

機構改革・人事異動情報(β) とは

実験的な機能をいち早く利用できる Sansan Labs で提供している機能の1つです。WEBで公開されている、企業の機構改革や人事異動の情報を、Sansan上で会社名や業界・キーワードから検索できます。

今回は、この機能の Web フロントエンドアプリケーションを題材にご説明していきます。

f:id:mokuoz:20211223160045p:plain

⚛️ Atomic Design に従う

早速ですが、ここから本題に入ります。

機構改革・人事異動情報(β) のフロントエンドでは、Atomic Design に従ってコンポーネントを分割しています。

⚛️ Atomic Design とは

Atomic Design ~堅牢で使いやすいUIを効率良く設計する という本(以降 Atomic Design 本 と呼びます)の 66 ページには、

Atomic Design は、どんな単位で UI をコンポーネント化すればよいかを示してくれるとてもシンプルなフレームワークです。

と紹介されています。

また、Atomic Design のメリットについては以下のように説明されています。( Atomic Design 本 4ページ)

  • 複雑なUIも確実に組み立てることができる
  • しっかりとコンポーネントごとに分けられたUIの機能は再利用性が高い
  • 多くの画面に対して少ないコードで実装できる
  • 再利用性が高いコンポーネントは、統一された使い勝手をユーザーに提供できる
  • 画面別ではなく機能別に分けられたUI設計が複数人の並行実装を実現し、開発速度がアップ

独立した再利用性の高いコンポーネントを組み立てていくことで、変更に強い SPA アプリケーションを構築することができます。

ここでは、Atomic Design に登場する5種類のコンポーネントについて、簡単にご紹介します。

Atoms (原子) Molecules (分子) Organisms (有機体)
Templates
Pages
・それ以上分割できない最小要素
・「具体的にどんな処理をするか」までは分からない
・デザインの統一感を支える  => ユーザーの使いやすさにつながる
・ユーザーの動機に対する機能を提供する要素
・入力フォームなど
・独立したコンテンツとして成り立つ要素 ・データを流し込む前の、ページ全体を表す要素 ・Templates にデータを流し込んだもの
f:id:mokuoz:20211223160826p:plain:w300 f:id:mokuoz:20211223160856p:plain:w300 f:id:mokuoz:20211223160922p:plain:w300 ※イメージなし。データを流し込むと Pages になります。 f:id:mokuoz:20211223160045p:plain:w300

コンポーネントは Pages => Templates => Organisms => Molecules => Atoms の方向(自分より小さい階層のコンポーネントに対して)に依存します。ただし、Organisms と Molecules は自分と同じ階層のコンポーネントにも依存できます。

(チームメンバーから 原子の再定義 - Atomic ReDesign - という記事を紹介していただいたので、そちらも参考にしてみてください。Atomic Design とは少し異なった概念になっています。)

⚛️ Molecules と Organisms の分け方

よくある問題として、コンポーネントを Molecules と Organisms のどちらに分類するか迷ってしまうことがあります。 Atomic Design 本 の 88 ページには

  • Molecules: 独立して存在できるコンポーネントではなく、ほかのコンポーネントの機能を助けるヘルパーとしての存在意義が強いコンポーネント
  • Organisms: 独立して存在できるスタンドアローンなコンポーネント

と書かれています。

冒頭でご説明したアプリケーションでは、検索フォームと一覧画面の行部分をどちらに分類するか迷いました。結論としては、以下のように分類しました。

検索フォーム => Molecules テーブルの行 => Organisms
理由 一覧表示があって初めてコンテンツとして成り立つから コンテンツとして成り立っている(一人分の人事異動情報を表示している)から
画像
イメージ
f:id:mokuoz:20211223160856p:plain:w300 f:id:mokuoz:20211223161857p:plain:w300

⚛️ コンポーネントの設計方法

どのようにコンポーネントに切り出すか、デザインモックに書き込んで検討しました。私は、だいたいこのようなやり方で設計することが多いです。

もちろん、実装し始めてからコンポーネントの切り出し方を変えることもあります。コンポーネント設計と実装は、行ったり来たりして良いものだと思っています。

一覧画面(Atoms) 一覧画面(Molecules) 一覧画面(Organisms)
f:id:mokuoz:20211223164409p:plain:w300 f:id:mokuoz:20211223164424p:plain:w300 f:id:mokuoz:20211223164504p:plain:w300

📝 規約

ここからは、チーム内での決め事についてご紹介します。

📝 Component と Container を分ける

Component(Presentational コンポーネント) はなるべく Props から値を受け取るだけに留めます。useState, useEffect, useContext, カスタムフック などは Container コンポーネントで使うようにします。

DocumentListSearch という Organisims コンポーネントを例に説明します。(※説明のためにコードを一部省略しています。)

ディレクトリ構成は以下になります。

organisms
├── DocumentListSearch
    ├── Component.stories.tsx
    ├── Component.tsx
    ├── index.tsx

↓↓は Containerコンポーネントです。useDocuments というカスタムフックの中で、バックエンドの API に対してドキュメントの検索と結果の取得を行っています。

// src/components/organisms/DocumentListSearch/index.tsx

import useDocuments from "hooks/useDocuments";
import React from "react";

import Component from "./Component";

const Container: React.FC = () => {
  const result = useDocuments();

  return (
    <Component
      searchForm={result.searchForm}
      mergeSearchForm={result.mergeSearchForm}
      resetSearchFormDetail={result.resetSearchFormDetail}
      updateAndSearch={result.updateAndSearch}
      documents={result.documents}
      currentPage={result.currentPage}
      total={result.total}
      isLoading={result.isLoading}
      error={result.error}
      initialized={result.initialized}
    />
  );
};

export default Container;

useDocuments で API リクエストを行うということは、テストなどの際も API サーバーを用意しないと行けないのでしょうか?答えは No です。

以下の GIF アニメーションと Component.stories.tsx ファイルを御覧ください。Container コンポーネントではなく Presentational コンポーネントを使うことで、API リクエスト部分をモックして Storybook で動作確認することができています。

f:id:mokuoz:20211209174542g:plain

// src/components/organisms/DocumentListSearch/Component.stories.tsx

import Document from "domain/models/Document";
import SearchForm from "domain/models/SearchForm";
import { Story } from "@storybook/react";
import { document1, document2 } from "components/__fixtures__/data";
import useSearchForm from "hooks/useSearchForm";
import React, { useState } from "react";

import Component from "./Component"; // Presentational コンポーネントを import して使う

export default {
  title: "Organisms/DocumentListSearch",
  component: Component,
  argTypes: {
    updateAndSearch: { action: "検索!!" },
  },
  args: {
    initialized: true,
    isLoading: false,
  },
};

type ContainerProps = {
  documents: Document[];
  updateAndSearch: (page: number, searchForm?: Partial<SearchForm>) => Promise<void>;
  total: number;
  isLoading: boolean;
  error: Error | null;
  initialized: boolean;
};

const Container: React.FC<ContainerProps> = (props) => {
  const [searchForm, mergeSearchForm, resetSearchFormDetail] = useSearchForm();
  const [currentPage, _setCurrentPage] = useState(1);

  return (
    <Component
      {...props}
      searchForm={searchForm}
      mergeSearchForm={mergeSearchForm}
      resetSearchFormDetail={resetSearchFormDetail}
      currentPage={currentPage}
    />
  );
};

const Template: Story<ContainerProps> = (props) => <Container {...props} />;

export const Documents = Template.bind({});
Documents.args = {
  documents: [document1, document2],
  total: 100,
  error: null,
};

export const NoDocument = Template.bind({});
NoDocument.args = {
  documents: [],
  total: 0,
  error: null,
};

export const ServerError = Template.bind({});
ServerError.args = {
  documents: [],
  total: 0,
  error: new Error("サーバーエラー"),
};

📝 データの繋ぎ込みは Organisms 以上で行う

state を持たせたり、API リクエストを行う層を限定することで、階層毎の役割を明確にします。

Atomic Design を厳密に実践するなら、「Pages からしかデータを繋ぎ込まない」となりそうですが、利便性も考慮して、Organisims からも OK としました。他社でもそのような事例が多いイメージがあります。

3年ほど前の資料ですが、自分が参加したイベントで拝見したスライドを載せておきます。参考までに。 ZOZOのGlobal ECを支えるフロントエンド / Frontend of ZOZO Global EC - Speaker Deck

💡 Tips

その他、役立ちそうなことをご紹介します。

💡 Atoms はタグ本来の Props やクラス名を外から渡せるようにする

全ての Atoms コンポーネントではなく、必要なコンポーネントのみで行なっています。

atoms の「制御・非制御」をどう作るのか を参考にしたのですが、本来はデザインシステム(複数のアプリケーションから使われる想定のコンポーネント集)を構築する際に有効な方法かもしれません。

機構改革・人事異動情報(β) では、デザインチームが HTML&CSS のコーディングまで行なってくれました。CSS 設計がしっかりされていたので、React に落とし込む際に SCSS ファイルをほぼそのまま使用しつつ、コンポーネントにクラス名を付けてスタイルを当てました。こういう背景もあって取り入れてみました。

import React from "react";

// NOTE: タグ本来の Props を適用できる様にしておく
// ref: https://zenn.dev/takepepe/articles/controlleable-of-atoms#%E3%82%BF%E3%82%B0%E6%9C%AC%E6%9D%A5%E3%81%AE-props-%E3%82%92%E9%81%A9%E7%94%A8%E3%81%A7%E3%81%8D%E3%82%8B%E6%A7%98%E3%81%AB%E3%81%97%E3%81%A6%E3%81%8A%E3%81%8F
export type Props = React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>;

const Component: React.FC<Props> = ({ children, htmlFor, ...props }) => {
  return (
    <label className="search__select-label" htmlFor={htmlFor}>
      {children}
    </label>
  );
};

export default Component;

💡 Props の型を抽象的にする

ボタンがクリックされた時や input フィールドに入力された時の処理(イベントハンドラ)を Props から渡すのはよくあると思います。その時に、以下の handleClick: (value: string) => void のように、抽象的な型を指定するようにしています。

import React from "react";

export type Props = {
  handleClick: (value: string) => void;
};

const Component: React.FC<Props> = ({ children, handleClick }) => {
  return (
    <dd className="search__keyword-item">
      <a
        href="#"
        onClick={(e) => {
          e.preventDefault();
          handleClick(children as string);
        }}
      >
        {children}
      </a>
    </dd>
  );
};

export default Component;

具体的にしようとすれば↓のように書くこともできると思いますが、あえて↑のように抽象的にすることで、変更に強く props を差し替えやすくなると思います。

export type Props = {
  handleClick: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void
}

💡 どこまで厳密にコンポーネントを分割するか?

例えば Atoms について、厳密にやるなら「UI の最小単位は必ず Atoms に切り出す」ということになりそうですが、無理しなくても良いかなと思ってます。

  • コンポーネントが大きくなって辛く感じたら切り出す
  • 2, 3回同じ記述が出てきたら共通化する

くらいな温度感でも良いかもしれません。

🗂 ディレクトリ構成

参考情報として、本アプリケーションのsrc/ 以下のディレクトリ構成を載せておきます。 create-react-app を使用して一般的な構成になっているかと思いますので、src/ 以下に絞って掲載します。

src
├── components
│   ├── __tests__ 
│   ├── atoms
│   ├── molecules
│   ├── organisms
│   └── templates
├── domain
│   └── models # 主にモデルの型を定義
├── hooks # カスタムフック
├── lib
│   └── generated-client # OpenAPI Generator で自動生成した API クライアント
├── pages # Page コンポーネント
└── repositories # リポジトリ層(主に lib/generated-client/ を使って API リクエストする)

domain/, repositories/ は DDD の戦略的設計を意識した構成になっています。本記事の趣旨から外れるため、これくらいの説明に留めておきます。

🛠 使っているツール

🛠 Storybook

Storybook: UI component explorer for frontend developers

Storybook を使って、単体のコンポーネントで表示確認できるようにしています。バックエンド開発でいうと、ユニットテスト(単体テスト)を使ってテスト駆動開発する感覚に近いかもしれません。

f:id:mokuoz:20211209174515g:plain f:id:mokuoz:20211209174542g:plain

🛠 OpenAPI Generator

OpenAPI Generator を使って、OpenAPI ドキュメントから API クライント(バックエンド API に http リクエストを行うクラス)を自動生成してます。生成されたファイルは lib/generated-client/ に置いています。

↓↓のように実行するだけです。

docker run --rm \
  -v "${PWD}":/local openapitools/openapi-generator-cli generate \
  -i /local/openapi.json \
  -g typescript-axios \
  -o /local/src/lib/generated-client/

バックエンドの話になってしまいますが、API サーバー側でも OpenAPI ドキュメントを使ってバリデーションとテストを行っています。 フロントエンドとバックエンドの両方で、OpenAPI ドキュメントに沿ったリクエスト/レスポンスであることを保証できれば、より堅牢なアプリケーションを構築できると思います。

おわりに

最後までこの記事を読んでいただきまして、ありがとうございます。

機構改革・人事異動情報(β) のフロントエンドは、バックエンドの API に対してデータを検索・表示するだけのアプリケーションなので、あまり複雑なことはしていません。逆にシンプルだからこそ、1つの記事に収めることができたのかもしれません。

ページ数が増えたり、データ更新なども行うアプリケーションであれば、state 管理のライブラリを導入するなど、また構成も変わってくるだろうと思います。あくまで「機構改革・人事異動情報(β) ではこのような構成にしました」という1つの事例として参考にしていただければと思います。

参考情報など


  1. コード例として React(TypeScript)を使用していますが、コンポーネント志向のライブラリであれば、他のライブラリにも共通する内容となっているかと思います。

© Sansan, Inc.