React × Viteで新機能を開発した話(採用理由、実装、CI/CD)

2021/08/12に公開

オンライン家庭教師マナリンク CTO の名人です。

先日マナリンクでリリースした新機能でReactとViteの組み合わせを選定しました。

https://vitejs.dev/

本記事ではReactとViteについて、以下の順で解説させていただきます。気になるところから読んでいただければ幸いです。

  • 採用理由
  • 実装の内容(環境設定、ライブラリ選定、コンポーネント設計など)
  • CI/CD(デプロイ、テスティング、Linterなど)

React×Viteの採用理由

マナリンクについて

採用理由を説明するにあたって、簡単にマナリンクの事業背景を説明します。

マナリンクは、オンライン家庭教師と生徒(と保護者)を繋ぐプラットフォームを運営している事業です。

大きく分けて3つの性質の違うプロダクトを開発しています。

  • オンライン家庭教師を集客するメディア
  • 保護者の集客〜先生のプロフィールを比較〜問合せ〜決済までを受け持つWebサイト
  • 宿題やチャットなどの機能でオンライン指導ができるWebアプリおよびネイティブアプリ

スタートアップということもあり、当初は全部Laravel乗せといったモノリシックで開発していましたが、機能数の増加に伴って、徐々にプロダクトの性質によって技術選定を都度都度行うようになってきているところです。

2021年8月現在、これらのプロダクト群を3〜4名の少人数で開発しています。

今回開発した機能

今回開発した機能は、「宿題機能」です。

https://twitter.com/manalinkteacher/status/1422493363060699138?s=20

https://twitter.com/megumi_sensei_/status/1423249676829020160?s=20

ざっくりいえば、以下のようなことができる機能です。

  • 先生が担当中の生徒さんに宿題を出す
  • 生徒さんが期限までに宿題を提出する
  • 先生が宿題を採点する
  • 一覧のやり取りは全て保護者さんが閲覧できる
  • 宿題の作成、提出、採点は全てReact Native製アプリでできる
  • 先生に関してはWeb上からでも宿題の作成と採点ができる

ポイントは、生徒と保護者はReact Native製アプリで指導を受講してもらうのですが、先生は長文を入力することが多いことから、PC端末でのサポートが求められるところです。したがって、Webでも宿題機能を開発することになりました。そして、今後もこのようにWebとアプリで共通機能が次々に開発されるロードマップが引いてあります。


※React Native側の実装もそこそこの規模になりましたが、これは機会があれば別の記事にできればと思います。


さて、そのような状況下でどうしてReact×Viteの技術選定をしたのかを順に説明していきます。

課題

課題としては「同じ機能をWebとアプリでそれぞれVue、Reactで書くことに対する開発者の負担」が挙げられます。

まず、これまでのマナリンクではNuxt.jsを主に採用してきました。そして後からネイティブアプリが必要だ!(かつ開発メンバーは現状維持でやりたい...)となったことから、React Nativeを採用した背景があります(Vue Nativeなどは開発メンバー内にRN経験者がいたことから検討に上がりませんでした)。

アプリのリリース後、しばらくは少ない機能を確実に作り込む方針で進めてきましたが、いよいよ大きな機能を連続で追加する見立てが立ってきました。

となると、日頃Web開発でVueを書いている我々が、しばしばReact Nativeも書くことになります。

基本的にはVueが書けるのならばReactも実装できるはずですが、今回の宿題機能のように同じ機能をWebとアプリで提供したいとなった場合に、ライブラリの充実度、コンポーネント設計、再レンダリングなどの感覚がかなり違っている両者を往来しながら実装・運用するのは少々大変に思います。

もちろん致命的に大変というわけではないので、わざわざこれまで知見が溜まってきたNuxt.js以外で実装する手段を選ぶのは無謀かもしれません。ただ、事業面でこれからメインで実装していく機能群のことを考慮すると、1年後に振り返ったときにあのときReactに統一しておけば...って少し後悔する可能性のほうが高いのではないかなと思いました。

選定候補

ぱっと浮かんだ候補は以下のような選択肢でした。

  1. Nuxt.jsのまま、composition-apiやJSXを採用するなどでReactに寄せる(←現状はほぼこれ)
  2. React Native Web
  3. Next.js
  4. Create React App(CRA)
  5. Vite

例えばプロダクトでVueしか使っておらず、でもJSX使いたいといった要望ならばANDPADさんのような手段もありかとは思います。React Nativeを採用しているとなると、半端にVueをReactに寄せようと頑張っても費用対効果が合わないように感じました。

React Native Webは真面目に検証したわけではありませんが、現時点でまだまだ進化の余地が多いReact Nativeに、さらにWebを上乗せした仕組みなので制限が多そうに思います。事業によっては価値のある技術だと思いますが、弊社の場合、本機能を提供する相手は月額数万円〜を支払っていただいているご家庭です。アーリーなのでこの機能の実装は難しいかもしれませんね、といった制限はできるだけ無いほうがいいため、不採用としました。

Next.js 🔥 CRA 🔥 Vite

以降の候補は本当に際どい勝負でした。正直どれでも大差ないと思います。

Viteは以下の3点を兼ね備えていることが良かったです。

  • バンドラーやビルド設定をカスタマイズしやすい
  • AWSへのデプロイがシンプルに構築し、既存サービスに統合できる
  • マナリンクのローカル開発環境への統合が容易である

CRAはcracoなどをいきなり追加でインストールしなければ、tsconfigのalias設定を利用できないあたりが少々手間だと感じたこと(※1)と、ローカル開発環境への統合がうまくいかず断念。

Next.jsは原則サーバーが必要なので、もともとAWSで運用しているマナリンクへの統合はECSなどを用意してデプロイする必要があります(※2)。これで単純にサーバー代が2倍になってしまう点が難点でした。かろうじて、同じECSコンテナ内でNuxtとNextを同居させるという手段は一応ありえますが、最後の手段にしたいところです。
また、Static ExportをS3に上げるのを試したのですが、今回のようなSPAだと動的なURLが次々に作られるからか上手く設定できませんでした。

Viteを採用すると、tsconfigのalias設定はvite-tsconfig-pathsプラグインで容易に可能、ローカル開発環境への統合はこちらの記事を参考に達成でき、デプロイフロー自体はdistディレクトリをS3にアップロードして、既存CloudFrontのオリジンを追加すればOKでした。

マナリンクのAWSについては、後のCI/CD節でも説明します。


*※1 (追記)公式リポジトリを見るとabsolute pathでのimportはできそうです

※2 この点、App Runnerとかが既存CloudFrontのバックエンドに指定できるなら良かったのですが、簡単ではなさそうでした

Viteの魅力

Viteの魅力は、なんといっても高速な開発サーバーを使ってコーディングできることです。マナリンクでこれまで採用してきたNuxt.jsでは、Webpackの動作がかなり遅くなっていました。これはこれでnuxt-viteなどをいずれ導入しようと思っていますが、なんにせよesbuildによる高速な開発は魅力です。

単にesbuildを直接使うのとは違い、Viteを使うとプリセットでCSS Modulesを使えたり環境変数を使えるなど、軽量なフレームワークといってもいいような機能がついてきます。本番ビルド時のrollupの設定も簡単に調整できるので拡張性も高いです。

ちなみに以前個人で簡単なテンプレートリポジトリを作ってみたので、よければご参照ください。

https://github.com/TeXmeijin/vite-react-ts-tailwind-starter

また、先日有志によって日本語版のドキュメントが公開されたようです!英語でもとても分かりやすいドキュメントでしたが、これでさらに普及するといいなと思います。

https://ja.vitejs.dev/

補足

今回の宿題機能以降、アプリとの共通機能は原則Reactで実装していこうと考えていますが、懸念点ももちろんあります。それは当面はNuxt.jsとReactが同一サービスで同居することです。

マナリンクへの集客になる先生の一覧画面やプロフィール画面、各種広告用LPや決済ページなどは、当面は変わらずNuxt.jsで運用していこうと思っているので、ヘッダーやフッター、認証周りの実装、グローバルメニューなどはReactで再実装しました。

Nuxt.jsも、3への移行が大変そうではあるもののとても良いフレームワークです。モジュールシステムが完成度高いですし、Vue自体も生のCSSが同じファイルに書きやすいなどの点で、広告用のLPを大量生産するのに非常に向いていたりします。

ということで、あくまで当初の課題提起通り、アプリとの共通機能をReactで、集客系はNuxtで、というのはしばらく継続しようかなぁと思いますが、これに伴う学習コスト、運用コストなどは長期的に見て懸念点だと思います。

こういったことを考えているときにこんなパワーあふれる記事が公開され、参りました...と思いました。MAUの規模感も含め凄すぎですね。

https://zenn.dev/yuku/articles/a9edd53e13bb26

React×Viteの実装

Vite

Viteの設定ファイルはTS対応されたvite.config.tsです。そのため型に守られながらスムーズに設定を記述することができます。

CSS Modules

以下のように設定することで、CSS Modulesで記述したCSSをキャメルケースに変換してJavaScriptから扱うことができます。

vite.config.ts
  css: {
    modules: {
      localsConvention: 'camelCase',
    },
  },

プラグイン

Vite本体に足りていない機能を、プラグインとして容易にオプトインできます。すでに公開されているプラグインも多いです。

https://github.com/vitejs/awesome-vite#plugins

今回私が追加したプラグインは以下のとおりです。

  • vite-react-jsx
  • vite-tsconfig-paths
  • vite-plugin-sentry

Productionビルド設定

まだ1機能(コンポーネント数は120程度)しか実装していないこともあり、大した最適化はしていませんが概ね以下のような設定をしています。

      rollupOptions: {
        output: {
          manualChunks: {
            vendor: ['react', 'react-router-dom', 'react-dom'],
            // 以下略。手動でchunkする条件を決めることができる
          },
          // chunknameにコンポーネント名が含まれていると気持ち悪いので本番ではハッシュのみ
          entryFileNames: 'vite_assets/[name].[hash].js',
          chunkFileNames: mode === 'production' ? 'vite_assets/[hash].js' : 'vite_assets/[name]-[hash].js',
          assetFileNames: mode === 'production' ? 'vite_assets/[hash][extname]' : 'vite_assets/[name]-[hash][extname]',
        },
        /**
         * 参考 https://nansystem.com/rollup-plugin-commonjs-and-rollup-plugin-node-resolve/
         */
        plugins: [
          terser({
            compress: {
              // ステージングまではConsoleはDropしないようにしておく
              drop_console: mode === 'production',
            },
            mangle: {
              // 略
            },
            format: {
              // 略
            },
          }),
        ],
      },

あと、Next.jsなどを使うときと違って、react-router-domをそのまま使っているため、React公式のコード分割ドキュメントを参考にルーティング単位のコード分割をしました。rollupはWebpackと同様コード分割に対応してくれています。

https://ja.reactjs.org/docs/code-splitting.html#route-based-code-splitting

開発環境のWebsocket設定

ニッチな話でいうと、弊社のローカル開発環境は独自ドメインでSSL対応されているため、フロントエンドからWebsocketを通すために少々設定する必要がありました。

vite.config.ts
    server: {
      hmr: {
        path: 'vite-ws',
        host: 'localhost-manalink.jp',
        port: 443,
      },
    },

その他参考資料

画像などの静的アセットの取り扱いは以下を参照してください。

https://vitejs.dev/guide/assets.html

環境変数をステージごとに書き換えるには以下のような設定ができます。

https://vitejs.dev/guide/env-and-mode.html

手前味噌ですが、合わせて個人で素振りした以下の記事をご参照いただければと思います。

https://zenn.dev/meijin/articles/vite-react-ts-vanilla-extract-playground

React

将来的な移行を考慮

先に示したとおり、今回の技術選定は懸念点ゼロ!というわけではありません。近い将来、やっぱりNext.jsが良い!と思った場合の移行コストは頭の片隅に置いておきたいと思いました。

具体的な移行を考慮する点としては、ページコンポーネントの実装が挙げられます。react-router-domから直接呼ばれるコンポーネント自体の責務を薄くしておいて、仮にNext.jsに移行することになったらそのコンポーネントだけ移行すれば良いようにしておくということです。

以下のコンポーネントは、宿題を表示するページのコンポーネントなのですが、データの取得やローディングのみを実装しており、具体的なページの内容は別のコンポーネントにおまかせしています。

const Index = () => {
  /**
   * 該当するHomeworkがあるかどうか取得
   */
  const { homework_id } = useParams<{ homework_id: string }>();
  const { data: homework, error } = useAspidaSWR(割愛);

  /**
   * できなければエラー・ローディング
   */
  if (error) {
    throwError(error);
  }
  if (!homework) return <Loading />;

  return (
    <>
      <Head title="宿題詳細" description="宿題詳細ページです" />
      <BaseLayout>
        <MainContent homework={homework} />
      </BaseLayout>
    </>
  );
};

export default Index;

細かい点だと<Head>コンポーネントを自作しており、もしNext.jsに移行することになったらnext/headのラッパーとして<Head>コンポーネントを作り直せば、ページコンポーネント自体は修正無しで実装できることが見込めます。とても楽観的な予測ではありますが。

import { Helmet } from 'react-helmet-async';

type Props = {
  title: string;
  description: string;
};

// かなり雑な実装ですがこんな感じです。SPAなので他のmeta情報はindex.htmlにそもそも書いてあります
export const Head = ({ title, description }: Props) => (
  <Helmet>
    <title>{`${title} | マナリンク`}</title>
    <meta name="description" content={description} />
    <meta property="og:title" content={`${title} | マナリンク`} />
    <meta property="og:description" content={description} />
    <meta name="robots" content="noindex" />
  </Helmet>
);

これは補足ですが、react-helmetよりreact-helmet-async使うのが良いと思います

ReactとReact Nativeで共通のライブラリを使う

当初の課題を達成するために、いくつか共通のライブラリを使うようにしました。ただ記事の本題からは外れるので、軽く触れる程度にしておきます。

一方、意図的に共通にしなかったものもあります。

  • CSS周り
  • Firebase
    • 一部データをFirebaseを使って扱っていますが、react-firebase-hooksのWebでの利用はSDK V9対応の動向を観察して決めたい。WebだとSDK V9によるバンドルサイズ削減の恩恵が大きい

CI/CD

最後に、全体のインフラ・ネットワーク構成と、ビルド設定、デプロイ体制などについて説明します(適宜設定内容は割愛しています)。

全体的な構成

フロントエンドに絞って全体の構成を示すと、下図のように、これまではAWSのCloudFront→ALB→ECS(Nuxt.js)の構成でした。

今回はCloudFrontのオリジンにS3を追加して、パスベースで特定の機能だけS3を向くように設定しました。余談ですがCloudFrontの設定はAWS CDKで管理しているので、設定変更の履歴もGitに残るしGitHub Actionsを使ってフロントエンドのリリースと同期して変更を反映できました。

aws_whole

linter, test, buildの実行

GitHub Actionsを用いて、Pull Request時に以下のコマンドを実行しています。

      - name: Install Deps
        id: install-deps
        run: |
          yarn install --frozen-lockfile
      - name: Lint
        id: lint
        run: |
          yarn lint
      - name: Test
        id: test
        run: |
          yarn test
      - name: Build
        id: build
        run: |
          yarn build

S3へのデプロイ

こちらもGitHub Actionsを利用していて、ビルドした内容をシンプルにS3にアップロードしています。

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_S3_DEPLOY_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_S3_DEPLOY_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
      - name: Install Deps and Build
        id: install-deps-and-build
        run: |
          yarn install --frozen-lockfile
          yarn build
          rm -rf ./dist/vite_assets/*.js.map
      - name: Deploy to S3
        id: deploy-to-s3
        run: |
          aws s3 sync dist/ s3://$AWS_S3_ASSET_BUCKET/dist --delete --exact-timestamps

ちなみに、Viteにはmodeという考え方があり、それを活用するとステージング用ビルドなども作成できます。そのため、環境別にデプロイ用のGitHub Actionsを構築することも簡単です。

https://vitejs.dev/guide/env-and-mode.html#modes


まとめ

ミニマルにReactのSPAを開発したいとき、Viteは魅力的な選択肢だと思います。

導入したい、または導入してみた、といった話があればぜひ知見を共有してみたいのでご連絡頂けると嬉しいです!

マナリンク Tech Blog

Discussion