DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

DIGGLE React SPA開発におけるアーキテクチャの勘どころ

はじめに

初めまして。 2024年2月にDIGGLEにエンジニアとして入社したfujitaです。

私はこれまで、バックエンドの開発が中心で、フロントエンドはVanilla JS、JQueryでの簡単なDOM操作やAjax通信の実装を行った程度の経験しかありませんでした。DIGGLEでは基本的にエンジニアはバックエンド・フロントエンドの両方の開発に携わるため、フロントエンドの開発に取り組むにあたって、いくつかの困難に直面しました。具体的には、DIGGLEではReactを用いたシングルページアプリケーション(以下、SPA)として構築されており、これまで経験したことがないほどにフロントエンドのコードベースの規模が大きく複雑なため、以下のような難しさがありました。

  • 機能を追加・改修するために、どのファイルのどの部分を修正すべきかがわからない
  • コードを修正した際に、その影響範囲を把握することが難しい

しかし、フロントエンドにおけるアプリケーションアーキテクチャ(以下、アーキテクチャ)、簡単にいえば各ディレクトリ、ファイル、コンポーネントの役割・責務を理解していくにしたがって、これらの困難は徐々に解消され、開発スピードも向上してきました。

本記事では、私がReactを用いた大規模なSPA開発に初めて取り組む中で得た知見や、DIGGLEのフロントエンドアーキテクチャがどのように設計されているかについて共有したいと思います。

Reactを用いたSPAのアーキテクチャ

SPAのアーキテクチャ

基本的なSPAのアーキテクチャとしては、バックエンドでも用いられるMVCや、それが発展してできたMVVMがあり、これらは総称してMVW(Model-View-Whatever)と呼ばれます。これらはいずれもアプリケーションのデータやロジックをもつModelと、UIでありModelの視覚的表現であるViewを要素としてもちます。そして、第3の要素(C: ControllerやVM: ViewModel)によってViewとModelを適切に分離しつつ連携できるようにします。この分離によって、コードの可読性、保守性、テスト容易性などが向上します。

React

ReactはUI構築のためのJavaScriptライブラリです。大雑把にいうとReactコンポーネントはMVW のVを担当し、アーキテクチャとは直接関係しません。宣言的にコンポーネントを作成することができ、状態の変化に応じたUIの更新はReactが裏側でやってくれますが、どのようにアプリケーションのデータを取得・管理し、Reactコンポーネントへ渡すかなどは別に考えなければなりません。(公式では、Next.js や Remix などのフレームワークの使用が推奨されています)

さらに、先ほどReactはMVWのViewに相当すると述べましたが、Reactコンポーネントは自身の状態を管理・更新したり、副作用を扱ったりでき、View以上のものといえます。そのため、一般にReactではMVWとは異なるアーキテクチャが採用されます。代表的なアーキテクチャとしてFluxなどがありますが、開発チームの事情に応じて様々な選択肢があるようです。次項で、DIGGLEのアーキテクチャの一部をご紹介します。

DIGGLEのフロントエンドアーキテクチャ

DIGGLEでは、UIとロジックを分離するために Container/Presentationalパターンが、関心の異なるロジックを分離するためにHOCパターンが用いられています。

なお、状態管理については記事がありますので、ぜひご覧ください。

diggle.engineer

Container/PresentationalパターンによるUIと状態・ロジックの分離

各ページのコンポーネントを実装する際、UIに関心をもつ Presentational Componentと、状態やロジックに関心を持つContainer Componentに分割することで、UIと状態・ロジックを分離します。具体的には以下のようになります。

  1. Container Componentは状態管理APIからグローバルな状態(ログインユーザーの状態など)を取得します。
  2. Container ComponentはバックエンドAPIをコールし、リソースのデータを取得します。レスポンスからViewModelを作成し、それを状態として保持します。 ViewModelはMVVMにおけるものとは若干異なり、APIで取得したデータとそれに関連するメソッドを含んだオブジェクトです。

  3. Presentational Componentは表示に必要なデータやロジックをContainer Componentからpropsとして受け取り、UIを構築します。

以下の記事を参考にさせていただきました。

zenn.dev

コードの例を見てみましょう。以下は、レポートの作成年月やタイトルなどを表示するコードです。

ViewModel

ReportsView は、レポートに関するデータとメソッドをもちます。

ReportsView.tsx

import { Report } from 'Models';
import { Record, OrderedMap } from 'immutable';

const ReportsViewRecord = Record<{
  reports: OrderedMap<any, Report> | null;
  // その他のデータ
  ...
}>({
  reports: null,
  ...
});

export class ReportsView extends ReportsViewRecord{
  // jsonデータからViewModelを組み立てる
  load(){ ... }
      
  // その他のメソッド
  ...
}

Container Component

APIをコールしてレポートのデータを取得後、ViewModelを作成し、状態として保持します。

ReportsContainer.jsx

import { ReportsView } from 'ViewModels';
import { ReportsPresentational } from './ReportsPresentational';

const ReportsContainer = () => {
  const [model, setModel] = useState(new ReportsView());
  // その他の状態
  ...
    
  useEffect(() => {
    fetch('https://xxxx/reports')
      .then((res) => res.json())
      .then(data => setReports((model) => model.load(data));
  }, []);

  return (
    <ReportsPresentational
      model={model}
      ...
    />
  );
};

Presentational Component

レポートの作成年月、タイトルを表示します。

ReportsPresentational.jsx

export const ReportsPresentational = ({ model: { reports, ... }}) => {
  return (
    <ul>
      {reports.map(({ id, title, year, month, ... }) => (
        <li key={id}>
          <p>{year}{month}月:{title}</p>
          // その他のレポート情報を表示する
          ...
        </li>
      ))}
    </ul>
  );
};

メリット

前述したアーキテクチャを取り入れることのメリットであるコードの可読性、保守性、テスト容易性などの向上が得られます。特に主要なデータやロジックが、他のコンポーネントと分離された形でViewModelに含まれるため、ViewModelに対するテストを重点的に書くことで、効率的にテストの恩恵を受けられます。

HOCパターンによる関心の異なるデータやロジックの分離

例えば、あるコンポーネントをモーダル表示したい場合、モーダル表示に関連する状態やロジックを高階コンポーネント (HOC、Higher Order Component)に分離します。高階コンポーネントとは、あるコンポーネントを受け取って新規のコンポーネントを返すような関数です。

例を見ましょう。以下のModalizeはモーダル表示に関連するデータや状態をもつコンポーネントです。

import { Modal } from '...';

export const Modalize = (Component) => (props) => {
  const [isOpen, setIsOpen] = useState(false);
  const open = ... // モーダルを開くメソッド
  ...
  return(
    <Modal
      open={open}
      ...
    >
      <Modal.Header>
        ...
      </Modal.Header>
      <Modal.Content>
        <Component {...props} />
      </Modal.Content>
    <Modal />
  );
};

コンポーネント MyComponent をモーダルで表示したい場合は、以下のようにします。

const EnhancedMyComponent = Modalize(MyComponent);

メリット

モーダル表示に関連する状態やロジックが分離されているため、これを様々なコンポーネントをモーダル表示するために再利用することができます。また、複数の高階コンポーネントを自由に組み合わせることもできます。以下は、モーダル表示のための高階コンポーネントである Modalize と非同期処理のための高階コンポーネントである WithAsync の機能を MyComponent に組み込むコードです。

const EnhancedMyComponent = WithAsync(Modalize(MyComponent));

ただし、高階コンポーネントの数が増えるとコードが読みにくくなります。

const EnhancedMyComponent = HOCn(...(HOC2(HOC1(MyComponent)))...);

デコレータを利用すると以下のように書けます。

@HOCn
...
@HOC2
@HOC1
MyComponent

デコレータの詳細は以下をご覧ください。

github.com

www.typescriptlang.org

今後の展望

ここまで紹介したコードを分離するためのパターンは、コンポーネントを利用したものでした。そのため、不必要な再レンダリングを引き起こす可能性があったり、テストが難しかったりする場合があります。これらの問題を解決し、より簡単かつシンプルにコードの分離を行うために、今後、Hooksの活用を積極的に進めていきたいと考えています。

より根本的な目標は、ReactのHooksを活用した宣言的アプローチへの進化に追随することで、Reactの利点を最大限に引き出し、プロダクトの価値を高めることです。

We're hiring!

DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。
カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!

herp.careers