技術本部 データ戦略部 Newsグループの木田です。
最近、初めて自作キーボードに挑戦しました。ちょうど2枚目のモニターも買ったので、モニター2台と自作キーボードで快適に記事を書いています。
予めお断りしておきますが、この記事は元々、社内向けに設計方針や規約・ツールなどについて共有するために書いたものでした。最近、他チームの参考資料として役立ったこともあり、社外向けに手を加えて公開する運びとなりました。
はじめに
突然ですが、アプリケーションを設計する上で大切にしていることはありますか?私は、変更に強いか を常に意識しています。変更に強いアプリケーションを構築するためには、ソースコードや規約を通して 意思と意図が明確なレールを敷く ことが重要だと考えています。
また、本記事は「Webアプリケーションの開発経験がある方」を想定して書きました。具体的な実装の話はあまり出てこないので、Webフロントエンド経験がなくても大丈夫かと思います。1
特に「イチからWeb フロントエンドアプリケーションを構築する予定だが、どのような構成にするか迷っている」場合などの参考となったら嬉しいです!
機構改革・人事異動情報(β) とは
実験的な機能をいち早く利用できる Sansan Labs で提供している機能の1つです。WEBで公開されている、企業の機構改革や人事異動の情報を、Sansan上で会社名や業界・キーワードから検索できます。
今回は、この機能の Web フロントエンドアプリケーションを題材にご説明していきます。
⚛️ 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 にデータを流し込んだもの |
※イメージなし。データを流し込むと Pages になります。 |
コンポーネントは Pages => Templates => Organisms => Molecules => Atoms の方向(自分より小さい階層のコンポーネントに対して)に依存します。ただし、Organisms と Molecules は自分と同じ階層のコンポーネントにも依存できます。
(チームメンバーから 原子の再定義 - Atomic ReDesign - という記事を紹介していただいたので、そちらも参考にしてみてください。Atomic Design とは少し異なった概念になっています。)
⚛️ Molecules と Organisms の分け方
よくある問題として、コンポーネントを Molecules と Organisms のどちらに分類するか迷ってしまうことがあります。 Atomic Design 本
の 88 ページには
- Molecules: 独立して存在できるコンポーネントではなく、ほかのコンポーネントの機能を助けるヘルパーとしての存在意義が強いコンポーネント
- Organisms: 独立して存在できるスタンドアローンなコンポーネント
と書かれています。
冒頭でご説明したアプリケーションでは、検索フォームと一覧画面の行部分をどちらに分類するか迷いました。結論としては、以下のように分類しました。
検索フォーム => Molecules | テーブルの行 => Organisms | |
---|---|---|
理由 | 一覧表示があって初めてコンテンツとして成り立つから | コンテンツとして成り立っている(一人分の人事異動情報を表示している)から |
画像 イメージ |
⚛️ コンポーネントの設計方法
どのようにコンポーネントに切り出すか、デザインモックに書き込んで検討しました。私は、だいたいこのようなやり方で設計することが多いです。
もちろん、実装し始めてからコンポーネントの切り出し方を変えることもあります。コンポーネント設計と実装は、行ったり来たりして良いものだと思っています。
一覧画面(Atoms) | 一覧画面(Molecules) | 一覧画面(Organisms) |
---|---|---|
📝 規約
ここからは、チーム内での決め事についてご紹介します。
📝 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 で動作確認することができています。
// 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 を使って、単体のコンポーネントで表示確認できるようにしています。バックエンド開発でいうと、ユニットテスト(単体テスト)を使ってテスト駆動開発する感覚に近いかもしれません。
🛠 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つの事例として参考にしていただければと思います。
参考情報など
- Atomic Design を分かったつもりになる - DeNA Design
- Atomic Design ~堅牢で使いやすいUIを効率良く設計する
- 原子の再定義 - Atomic ReDesign -
- コード例として React(TypeScript)を使用していますが、コンポーネント志向のライブラリであれば、他のライブラリにも共通する内容となっているかと思います。↩