こちらはバイセルテクノロジーズ Advent Calendar 2023の1日目の記事です。
こんにちは、開発2部の早瀬です。普段はリユースプラットフォームの出品管理SaaSの開発を行っています。
私のチームではモダンな技術を採用しながら、チーム全員でフロントエンドの開発を行っています。
以前はフロントエンドのメンバーが私一人だけだったのですが、途中からチーム全員で開発をするスタイルに変化していきました。 一人で開発する場合は特にルールなどなくても問題ないですが、複数人で開発するとなると話は別です。開発のルールや、それを浸透させるための取り組みが必要になってきます。
そこで私のチームでは、効率的に開発を進めるために、できる限り仕組み化したフロントエンドの構成に力を入れてきました。開発を進める中でも改善を繰り返しており、最近は良い感じに開発ができているので、今回はその構成について紹介したいと思います!
技術スタック
前提として下記のような技術スタックで開発を行っています。
これらの技術に関する詳細な説明は本記事では行いません。
- Next.js
- TypeScript
- GraphQL、ApolloClient
- GraphQL Code Generator
- Storybook
- React Hook Form(RHF)
- MUI
- Figma
設計原則
私のチームでは、下記の設計原則を重要視しています。
CQS
CQSとは、状態を変更させるメソッド(Command)と、状態を変更しないメソッド(Query)を分離する設計原則です。
これはGraphQLにおいてQueryとMutationに置き換えて考えることができ、QueryとMutationを同じの関数(コンポーネントも含む)で実装しないようにしています。
具体的にはQueryはコンポーネント内で呼び出すが、Mutationはコンポーネント内で呼び出しはせず、hooksを用意してその中で呼び出しを行うようにしています。
最低限これだけでも分けておくことで、コンポーネント内の処理が肥大化するのを防ぐことができます。また、レイヤーが明確に分離され疎結合になるので、保守性の向上や並行作業のしやすさに繋がります。
Fragment Colocation
Fragment ColocationとはGraphQLにおける設計思想で、コンポーネントのデータ要件をFragmentで定義し、コンポーネントとセットで管理するというものです。
詳細な説明は本記事では行いませんが、Fragment Colocationを取り入れることで、オーバーフェッチの防止や、コンポーネントの凝集度の向上など様々なメリットがあります。
Fragment Colocationについては下記のスライドなどで説明しているので、興味のある方は見てみてください。
また、Fragment Colocationを取り入れるに当たって、私のチームではGraphQL Code Generatorのclient presetによって自動生成されるヘルパー関数や型を使用しています。
本記事のサンプルコードでそれらを使用したコードが出てきますが、こちらも詳細な説明は割愛させて頂くので、必要に応じて公式ドキュメントを参照してください。
ディレクトリ構成
ディレクトリ構成は下記になります。
./src ├── features │ └── <feature_name> │ ├── components │ ├── pages │ ├── graphql │ ├── hooks │ └── utils ├── components ├── pages ├── constants ├── types ├── hooks ├── styles └── utils
それぞれのディレクトリの役割を紹介していきます。
src/features
下記の記事などを参考にして、機能単位でディレクトリを分ける構成を採用しています。
プロダクトの機能ごとにディレクトリを分けて、その機能のコンポーネントやhooksをそのディレクトリ配下に集約します。
feature
ごとにディレクトリを分けることで、機能を追加したい場合に、どのディレクトリを見ればいいかがわかりやすいので、認知負荷が非常に低くなります。また、feature
内での修正が別のfeature
に影響することがないので、修正時の影響範囲がそのfeature
に閉じます。そのため、他feature
への影響を考えなくて良いので安心して実装できるのと、並行して複数の機能の実装を進めることができます。
feature
を跨いだインポートは禁止で、この依存関係は後述するESLintのルールで強制しています。
複数のfeature
から使いたいコンポーネントやhooksがある場合は、feature
配下に配置せず、src
配下に移動させるというルールで運用しています。
features
配下のディレクトリの役割はそれぞれ下記になります。
src/features/components
feature
固有のコンポーネントを配置します。
同じfeature
のコンポーネントであればインポートしても良いルールにしているので、feature
内で共通化したいコンポーネントなどを配置します。
GraphQLのデータを利用したい場合はFragment Colocationの思想に従って、コンポーネント内にFragmentを定義して、getFragmentData
ヘルパーを利用してデータにアクセスします。
import { FragmentType, getFragmentData, graphql } from '~/gql'; export const ItemInfoFragment = graphql(/* GraphQL */ ` fragment ItemInfo_item on item { id images { id fileName } } `); type ItemInfoProps = { itemRef: FragmentType<typeof ItemInfoFragment>; }; export const ItemInfo: React.FC<ItemInfoProps> = ({ itemRef }) => { const item = getFragmentData(ItemInfoFragment, itemRef); // ...省略 };
src/features/pages
ページコンポーネントを配置します。
ここで定義するコンポーネントはsrc/pages
配下のコンポーネントと1:1で対応させてあります。
ここで定義するページコンポーネントはコンポーネントツリーにおける最上位のコンポーネントになります。そのため、このページコンポーネント配下の子コンポーネントで定義してあるFragmentを全て含めたQueryを定義して実行します。
import { useQuery } from '@apollo/client'; import { graphql } from '~/gql'; const ItemQuery = graphql(/* GraphQL */ ` query ItemQuery($id: Int!) { item(id: $id) { # 子コンポーネントで定義されているFragment ...ItemInfo_item ...ItemInfo2_item } } `); type ItemProps = { itemId: number; }; export const Item: React.FC<ItemProps> = ({ itemId }) => { const { data, loading } = useQuery(ItemQuery, { variables: { id: itemId }, }); // ...省略 };
src/features/graphql
feature
固有のGraphQLオペレーション(APIリクエスト)に関するhooksを配置します。
Fragment Colocationの思想に従って、Queryは原則コンポーネント内で定義します。そのため、このディレクトリには主にMutationに関するhooksを配置します。
APIリクエストの処理や、それに伴うドメインロジックはこのhooks内に記載し、コンポーネント側ではただこのhooksを呼び出すだけにしています。このようにすることで、CQSの原則に従い、QueryとMutationの処理を明確に分離することができます。
import { useMutation } from '@apollo/client'; import { graphql } from '~/gql'; const UpdateItemMutation = graphql(/* GraphQL */ ` mutation UpdateItemMutation($input: updateItemInput!) { updateItem(input: $input) { id } } `); export const useUpdateItem = () => { const [updateItemMutation] = useMutation(UpdateItemMutation); const updateItem = useCallback(async () => { // ドメインロジック+updateItemの呼び出し }, [updateItemMutation]); return { updateItem }; };
src/features/hooks, src/features/utils
feature
固有のhooksやutil関数などを配置します。
src/components
プロジェクト全体で使用する共通コンポーネントを配置します。
ここで定義するコンポーネントは基本的にはドメイン知識を持たず、主にUIパーツなどが該当します。
デザインチームが作成したFIgmaを元にして、最小コンポーネントであるUIパーツを実装しています。
開発者はUIパーツを再利用することで、CSSの有識者でなくても一定品質のデザインを実現しています。
この辺りの取り組みは下記の記事で詳しく紹介しています。
src/pages
App Router移行は検討はしていますが、現時点ではPages Routerを採用しています。
このレイヤーの責務はかなり薄くしており、基本的にはsrc/features/pages
のコンポーネントの呼び出しや、レイアウトの適用くらいしか行いません。
import { DefaultLayout } from '~/components/Layout/DefaultLayout'; import { Items } from '~/features/item/pages/Items'; import { NextPageWithLayout } from '~/pages/_app'; const Index: NextPageWithLayout = () => { return <Items />; }; Index.getLayout = (page) => <DefaultLayout>{page}</DefaultLayout>; export default Index;
src/styles
プロジェクト全体に影響するスタイル関連のファイルを配置します。
具体的には、MUIの設定、グローバルスタイル、カラーコードの定義などを行っています。
また、プロジェクト内で使用するカラーコードはsrc/styles/theme.ts
に定数として定義して、各コンポーネントではその定数をインポートして使用するようにしています。
このようにすることで、カラーコードが氾濫せず、管理も楽になります。
import { createTheme } from '@mui/material/styles'; export const theme = createTheme({ palette: { primary: { main: '#0077C7', extraDark: '#003050', dark: '#00538B', light: '#50B4F6', extraLight: '#EBF4FB', alpha08: '#0077C714', }, ... }, });
src/constants, src/types, src/hooks, src/utils
プロジェクト全体で使用する定数やhooksなどを配置します。
状態管理
GraphQLを採用していることもあり、状態管理にはApollo Clientを使用しています。
APIリクエストに加え、キャッシュの管理もApollo Clientが正規化した上で保持してくれるので、キャッシュ周りで実装者が意識しないといけないことは少ないです。
また、APIレスポンスに関する状態はApollo Clientで管理して、その他の状態はフォーム値であればRHF、それ以外であればuseState
+バケツリレーで管理しています。
基本的にコンポーネントのネストが深くなりすぎないように意識しているので、バケツリレーでそこまで辛くなっていないのと、APIレスポンスとフォーム値以外でコンポーネントを跨いで管理したい状態がそこまでないので、この方針で今の所は問題にはなっていないです。
仕組み化のための取り組み
フロントエンド開発をできる限り仕組み化するために、上記で紹介した内容をESLintなどを使用し、CIでチェックすることで強制するようにしています。
一度仕組みを作ってしまえば、レビュー時に毎回指摘する必要がなくなりますし、担当者がいなくなったとしても、その人が作った仕組みは残ります。そのため、この辺りの取り組みに関してはかなり力を入れて取り組んでいます。
ESLint
ESLintの設定にはFlat Configを採用しており、プラグインごとにグルーピングして設定できるようにしています。
適用しているルールをいくつか紹介します。
featuresディレクトリの依存関係
eslint-plugin-importを使用することで、ディレクトリ構成で紹介したfeatureを跨いだインポートを禁止しています。
const FEATURES = [...] module.exports = [ ... { plugins: { import: esLintPluginImport, }, rules: { 'import/no-restricted-paths': [ 'error', { zones: FEATURES.map((featureName) => ({ from: `./src/features/${featureName}`, target: `./src/features/!(${featureName})/**/*`, message: 'featureを跨いだimportは禁止です。', })), }, ], }, }, ... ]
GraphQL関連
GraphQLをLintするためにgraphql-eslintを使用しています。
const TS_FILES = ['src/**/*.ts', 'src/**/*.tsx']; module.exports = [ ... { files: TS_FILES, processor: esLintPluginGraphQL.processors.graphql, }, { files: ['src/**/*.graphql'], languageOptions: { parser: esLintPluginGraphQL, parserOptions: { skipGraphQLConfig: true, schema: './graphql.schema.json', documents: TS_FILES, }, }, plugins: { '@graphql-eslint': esLintPluginGraphQL, customRule: rulesDirPlugin, }, rules: { ...esLintPluginGraphQL.flatConfigs['operations-recommended'].rules, '@graphql-eslint/naming-convention': 'off', 'customRule/graphql-naming-convention': 'error', }, }, ... ]
operations-recommended
に含まれるrequire-id-when-available
により、idフィールドが存在する型に対してidフィールドを取得していない場合はエラーになります。
これにより、Apollo Clinetがキャッシュの正規化に必要としている、idフィールドの取得漏れをなくすことができます。
また、独自ルールを作成することで、QueryやFragmentに対する命名規則の強制も行っており、それに関しては下記の記事で紹介しています。
UIパーツのインポート
UIコンポーネントにMUIを使用していますが、Typographyなどの一部のコンポーネントは、MUIのコンポーネントをラップしたものを使用するようにしています。
そのため、それらはno-restricted-importsを使用して、MUIのコンポーネントのインポートを禁止しています。
module.exports = [ ... { rules: { 'no-restricted-imports': [ 'error', { patterns: ['@mui/*/*/*'], paths: [ { name: '@mui/material/Typography', message: TYPOGRAPHY_ERROR_MESSAGE, }, ], }, ], }, }, ... ]
カラーコードのハードコーディング
前述したように、カラーコードは一箇所に集約させて定数として定義して、その定数を各コンポーネントでインポートして使用するようにしています。
そのため、no-restricted-syntaxを使用して、src/styles
以外でのカラーコードのハードコーディングを禁止しています。
const HARDCODED_COLOR_ERROR_MESSAGE = 'カラーコードはsrc/styles/theme.tsからimportした定数を使用してください。'; module.exports = [ ... { files: TS_FILES, ignores: ['src/styles/*'], rules: { 'no-restricted-syntax': [ 'error', { selector: 'Literal[value=/^#[a-zA-Z0-9]/i]', message: HARDCODED_COLOR_ERROR_MESSAGE, }, { selector: 'Literal[value=/^rgb[(]/i]', message: HARDCODED_COLOR_ERROR_MESSAGE, }, { selector: 'Literal[value=/^rgba[(]/i]', message: HARDCODED_COLOR_ERROR_MESSAGE, }, ], }, }, ... ]
cspell
typoを防ぐためにcspellを導入しています。
また、cspellは固有の単語を禁止にできるflagwordという機能があります。
私のチームではこの機能を、複数の命名が可能な単語に対する命名の統一のために利用しています。
例えば、店舗はshop
ではなくstore
を、ステータスはstatus
ではなくstate
と命名を統一しているので、flagwordにshop
とstatus
を追加して使用を禁止しています。
{ ... "flagWords": ["shop", "status"], ... }
hygen
ESLintやcspellがルールに従っていない場合を弾くのに対して、ルールに従った雛形を生成するためにHygenを使用しています。
対話形式で、対象のfeatureや、Queryの有無を選択することで、今回紹介したルールに従ったコンポーネントの雛形を生成するようにしています。
$ yarn hygen new component ✔ featureを選択してください · item ✔ コンポーネント名を入力してください · Sample ✔ Queryを定義しますか? (y/N) · false ✔ Fragmentを定義しますか? (y/N) · true Loaded templates: .hygen added: src/features/item/components/Sample/index.tsx added: src/features/item/components/Sample/style.ts ✨ Done in 22.75s.
import { graphql } from '~/gql'; import { SampleStyle } from './style'; // TODO: 使用しないFragmentは削除する、<type>は適切な型に置き換える const SampleFragment = graphql(/* GraphQL */ ` fragment SampleFragment_<type> on <type> { # TODO: implement } `); const SampleOptionFragment = graphql(/* GraphQL */ ` fragment SampleOptionFragment_<type> on <type> { # TODO: implement } `); type SampleProps = {}; export const Sample: React.FC<SampleProps> = () => { return ( <> <h1>Sample</h1> </> ); };
まとめ
今回は開発効率を上げるためのモダンなフロントエンド構成について紹介しました。
複数人で開発するからこそ、Linterでルールを強制したり、自然とルールに沿って実装できるような仕組みが重要だと思います。
同じ技術スタックでなくても取り入れられる部分はあると思うので、フロントエンドの構成について検討している方の参考になれば幸いです!
最後にBuySell Technologiesではエンジニアを募集しています。興味がある方はぜひご応募ください!
明日のバイセルテクノロジーズ Advent Calendar 2023は藤澤さんのアジャイルにおけるフロー効率を追い求めた結果、開発メンバーのエンゲージメントが低下したので改善した話です、そちらもぜひ併せて読んでみてください!