社内向けプロダクトのフロントエンド構成のふりかえりとこれから(2022年1月版)

こんにちは。梅津です。
今回は社内向けプロダクトの開発で携わっているフロントエンドの現状や、これからやっていきたいことをお話します。

担当サービスの話

現在のチーム規模

  • エンジニア 3人
  • プロダクトオーナー 1人
  • サポートしてくれる人 2人

主な技術

  • バックエンド
    • Rails
    • graphql-ruby
  • フロントエンド
    • React
    • TypeScript
    • Material-UI

フォルダ構成

src
├── ... いろいろ
├── ui
│     ├── common(画面をまたいで共通利用されるコンポーネントやhook)
│     ├── ...
│     ├── client(クライアントが利用する画面)
│     │     ├── ...
│     └── inhouse(社内側の画面)
│           ├── ${ページ名} (task, progress, etc...)
│           │     ├── pages
│           │     │     └── ${ページ名}Page
│           │     ├── components
│           │     │     ├── ${コンポーネント その1}
│           │     │     │     ├── ...
│           │     │     ├── ${コンポーネント その2}
│           │     │     │     ├── ...
│           │     ├── hooks
│           │     │     ├── useHoge.ts
│           │     │     └── useFuga.ts
│           │     ├── graphql(graphqlファイル置き場)
│           │     │     ├── HogeQuery.graphql
│           │     │     └── FugaMutation.graphql
│           │     ├── globalState(グローバルステート置き場)
│           │     │     └── hoge.ts
│           │     ├── types.ts(型定義。大きくなったらtypesフォルダを作ってファイル分割)
│           │     └── utils
│           │           └── fuga.ts

コンポーネントを作っていく際の方針

  • 画面ごとにフォルダを切って、影響範囲を小さくしている
    • ある画面に関する修正はその画面のフォルダを見に行けばいいようになっている
  • はじめは共通化のことは考えない
    • 重複したコードが増えてきたときに、それらが本当に同じ目的のコードかを考えてから共通化していく
    • 共通化は難しいので諦めているとも言う

src/ui/inhouse/${ページ名}

ある画面に関するコンポーネントなどがまとまっているフォルダです。

  • 具体的なフォルダ名は次のような形
    • 案件の画面: task
    • 進捗管理画面: progress
  • チーム内で話をしているときに、実はページ名を正確に表しているわけではないということがわかった。
    • よしこさんの記事 で言うmodelに近い
    • この記事を書いているときに気づいたが、おそらくRailsのルーティングに引きずられている
  • 現状としては「ページ名のようなモデル名のような、明確にしきれていない何か」になっているので、今後見直していきたい
    • ドメイン駆動設計で言うところのContextにあたるような粒度も必要かなと考えている
    • 単純にmodelとして切り出すと、「この画面のクライアントとあの画面のクライアントってやりたいことが違う気がするけどどうする?まとめちゃう?」となり、過度な共通化がなされていく気配がある

src/ui/inhouse/${ページ名}/pages

画面を表すコンポーネントを置く場所です。

  • 将来的にNext.jsを始めとしたReactベースのフロントエンドフレームワークに移行したいと考えている
    • 移行した際には src/pages からこのフォルダ内のコンポーネントを参照するようにしたい
  • 上記の src/ui/inhouse/${ページ名} フォルダを見直していったときに、このフォルダもリネームされたり別のフォルダと統合される可能性はある

src/ui/inhouse/${ページ名}/components

その他のコンポーネントを置く場所です。

  • コンポーネントの粒度に関係なく、大小様々なものが置かれる
  • Presentational Component / Container Componentでフォルダを分けていた時期もあったが、分けることに大きなメリットもないので統合してしまった
    • 詳細は後述

src/ui/inhouse/${ページ名}/hooks

その画面で必要な機能を表すカスタムフックを置く場所です。

  • 複数のコンポーネントで共通利用されるかによらず、その画面で必要なロジックであればここに置く
    • データ取得、State更新など
    • ApplicationServiceやUsecaseと呼ばれるものに近しいイメージ
  • useQuery/useMutation(graphql-codegenによって生成されたHookも含む) などはコンポーネントで直接使わず、カスタムフックでラップしておきたい
    • Queryした結果をConnectionから配列に変換するので、その処理をコンポーネント側に露出させたくない
    • 将来別のGraphQL Clientに移行する際、変更箇所を最小限にしたい
  • Global State, Local Stateに関しても同様にカスタムフックにラップして利用する

アプリケーションのアーキテクチャ

  • 特定のアーキテクチャに則っていない
  • 以下の二点は方針として決めている
    • カスタムフックにロジックを集約していく
    • 依存の向きが Component -> Custom Hook -> Data | State となるようにする

コンポーネントの構成

${コンポーネント名}
├── index.ts
├── ${コンポーネント名}.tsx
├── ${コンポーネント名}.stories.tsx
├── useHoge.ts
└── tests
     ├── ${描画されている内容を確認するテスト}.test.tsx
     ├── ${ユーザーの入力に伴う状態変更のテスト1}.test.tsx
     └── ${ユーザーの入力に伴う状態変更のテスト2}.test.tsx
  • テストとStorybookのファイルをなるべくコンポーネントの近くに置きたかったのと、ファイルがあまりにも平坦に並びすぎるのが嫌だったのでコンポーネント用のフォルダを作ることにした
  • ${コンポーネント名}.tsxcomponents.tsx としていたこともあったが、WebStormでCommand + B/Ctrl + Bしたときに出てくる候補に components.tsx が並びまくってしまって見づらかったのでやめた
  • 基本的にコンポーネントのフォルダ内にカスタムフックは作らないが、必要であれば作成してもよい(コンポーネント特有の処理が太ってきたなど)
    • フォルダ構成の話で出てきた hooks フォルダを作らず、コンポーネントフォルダに逐一カスタムフックを作るような運用をしてみたことがある。つまりコンポーネントとカスタムフックが1対1になるような関係
    • 結果的に処理があちこちに散らばりすぎて見通しが悪くなってしまったのでやめた
  • テストファイルはやむなく分割
    • 最初は ${コンポーネント名}.test.tsx としていたが、1ファイルに多くのテストを書いているとCIでときどきfailする
    • ファイルを分割してみたところ安定し始めたため、なるべく分割するようにしている
    • CIで不安定になる要因までは踏み込めていない
  • WebStormでテンプレートの設定をして、一連のファイルが一発で作られるようにしている

コンポーネントのメモ化について

  • もともとはコンポーネントを作っていく段階では React.memo, useMemo, useCallback などは利用しない方針だった
    • パフォーマンスに影響が出てきてから必要な部分をメモ化していくということをしていた
    • 特に困っているわけではないけど、これでいいのかなー?というモヤモヤはあった
  • 今後は useMemo, useCallback は基本的に利用していくことにする

スタイリング

  • Emotionを利用
    • ${コンポーネント名}.tsx と同じファイル内に書かれる
  • 正直に言うとスタイリングに関して強いこだわりがない
  • 過去にいたメンバーが入れたのをそのまま利用している状況

データ取得 & キャッシュ管理

  • GraphQLのクライアントとしてApollo Clientを利用
    • Apollo Client v2の頃から利用している
    • 導入当時はRelayかApollo ClientくらいしかまともなGraphQL Clientがなかった
    • 当時の利用用途や、自分を含めたメンバーのレベル感ではApollo Clientのほうが簡単に始められそうだったのでこちらを選択した
  • キャッシュの整合性を保つのは結構難しい
    • Mutation後にrefetchQueriesすることが増えてきた
    • もう少しざっくりとしたキャッシュ更新でも問題ないかもしれないので、他のライブラリも視野に入れておきたい
      • urqlが結構良さそう。公式でSuspenseに対応しているのも嬉しい
  • 当時のメンバーが書いてくれたApollo Clientに関する記事はこちら

Global State

  • 主にRecoilを利用している
    • バックエンドを書いている人がフロントエンドのコードも書くという状況になってきたため、覚えることが少ないものを利用したかった
    • Reduxに比べるとRecoilは覚えるものも少ないし、Stateの分割も容易。フロントエンドの開発に慣れていない人でもいくらかは簡単に開発ができそうだった
    • ContextはProviderの分割についてしっかり考えなくてはいけないし、Providerをいちいち差し込まなくてはいけないのも手間なのでやめた
    • jotaiという選択肢もあったが、なるべくReactチームに近いもののほうがいいかと思って最終的にRecoilを選択した
    • 単に新しく出てきたものを使ってみたいという欲もあった
  • 古い画面ではReduxが残っている
    • プロダクトにReactを導入した当時はReduxが全盛だったので採用した
      • この頃はフロントエンドとバックエンドの担当が明確に分かれていた
    • 概念は少し複雑かもしれないが、型が決まっているのはいいところ。一度覚えてしまえば考えることが少ない
    • Redux Toolkitの登場によってコード量も減っているので、チームの人数がもっと多ければ引き続き使っていたかもしれない

Formatter

  • Prettierを利用
  • husky + lint-stagedを使って、git commitするときにフォーマットされるようになっている
  • コードを保存したときにフォーマットされるようなIDE設定もしている

Linter

  • eslint, typescript-eslintを利用
  • 誰が書いても同じようなコードになるようにしたいのでLinterの設定は多め。今も徐々に増やしているところ
    • 例えば関数宣言をどう書くか(function?ファットアロー?)という判断は、これまでプロダクトに積み上げられてきたコードに合わせて書くという運用になっていた。つまり書き手の善意に依存するものだった
    • プロダクト固有の書き方に慣れていない人が入ってきたタイミングで、「これまでのコードはこう書いていたので書き直してください」というレビューが増えてしまった
    • こりゃいかん!と思いコーディング規約を設定。機械的に判断できるものはLinterに任せるようにしていった
    • 決めができることで考えることが減り、レビュアーもレビュイーも本質的なことに集中できるようになった
  • 導入しているeslint-pluginの一覧
    • eslint-plugin-import
    • eslint-plugin-react
    • eslint-plugin-react-hooks
    • eslint-plugin-testing-library
    • eslint-plugin-unused-imports
    • eslint-plugin-jest
    • eslint-plugin-jest-dom
    • これらのrecommendは設定している
  • 他にも人によって微妙に書き方が変わりそうなものを設定している
    • eqeqeq
    • eol-last
    • object-shorthand
    • react/jsx-boolean-value
    • react/self-closing-comp
    • react/jsx-curly-brace-presence
  • Airbnb JavaScript Style Guide を参考にした
  • 途中からLinter設定を増やしていったため、現状ではoffにしているところも残っている
  • Formatterに続いて、こちらもコードを書いているときにLinterが実行されるようにIDEの設定をしている
  • Linterの実行時間が長いため、git commit時には実行しないようにしている
  • 代わりにCIでLinterを動かしており、エラーがあればマージできないようになっている

テスト

Presentational Component / Container Componentについて

  • こちら でも言及があるように、Interactive Storiesの登場によってPresentational Component / Container Componentに分ける理由はさらに減ったように思う
  • Dan先生も言っているが Hooksの登場によってこれらを分ける理由は大分減っていた
    • そうは言ってもカスタムフックに依存するようなコンポーネントはContainerとして切り出したほうが責務が別れていいでしょう、という意見もあるはず
    • 自分もそう思ってしばらくは分けていたが、手間のほうが勝ってきたのでContainerは無理に作らないでいいかなと考えが変わった
  • 唯一Storybookに関してはPropsで渡した値をもとに描画してくれるほうがパターンが出しやすいので、分けるモチベーションはまだ残っていた
    • しかし、Storybook Addon Interactionsのplay関数により、Propsを渡さなくてもコンポーネントを望む状態に更新することができるようになった
    • 結果的にStorybookやテスト容易性のためにPresentational Component / Container Componentに分けることはしなくてよくなった
  • 分けるのが有効なときもあることは理解しているので、そのときは分ければいいと思う

ライブラリのアップデート

おわりに

いかがだったでしょうか。何かの参考にでもなれば幸いです。

この記事を書きながら改めて感じたのは、「なるべく考えることを少なくする」というのをテーマにこれまでやってきたんだなーということでした。
とはいえ理想の形にはまだまだ。より良いものにするために、これからも試行錯誤を繰り返していきます。

それでは、また。

参考リンク