create-react-appからViteへの移行

に公開

はじめに

create-react-app(CRA)がdeprecatedとなりました。
TypeScriptやwebpackをはじめとした依存ライブラリを上げるブロッカーになっている点などを踏まえ、移行が必要になっています。

https://react.dev/blog/2025/02/14/sunsetting-create-react-app

Linc'wellでも、とあるCRAベースのプロダクトをViteに移行し、無事に本番リリースが完了しました。
この記事では、移行の流れや確認した点、工夫したポイントについてまとめて紹介します。

create-react-appからの移行とは

CRAからの移行は、ローカル開発サーバー、本番ビルド、Jestによるテスト実行を提供するreact-scriptsというコマンドを剥がすのが主にやることになります。

https://create-react-app.dev/

具体的には以下の対応を行いました。

  • 開発サーバーや本番ビルドはViteに移行
  • テスト実行はJestおよび設定を内包しているため、今回のタイミングでVitestに移行
  • ESLintでeslint-config-react-appを使っている場合は、プロジェクトに必要なルールを精査して他のeslint-configに移行
  • Storybookで@storybook/preset-create-react-appを使っている場合は、Viteベースに移行

移行の方針

今回の移行は、以下の方針で進めました。

  • 変更時のリスクを下げるため、プロダクトコードの変更を最小限にする(基本的にしない)
  • 本番リリース後に切り戻しができるように、同じプロダクトコードでreact-scripts/Viteどちらでもローカル開発や本番ビルドが行える状態を作る
  • 本番リリースはビルドコマンドの差し替えだけで行う

特に、react-scriptsとViteの両方が使える状態を維持することで、問題が発生した際に即座に切り戻せる体制を整えました。

移行ステップ

Step0: 事前準備

今回の移行では開発サーバーと本番ビルドのVite移行のリスクが高いため、CRA関連への依存を減らす対応を事前準備として行いました。

事前準備としては、以下の対応を行いました。

ESLintの対応

ESLintでeslint-config-react-appを使っていたのですが、ESLint v9に対応していないため、このタイミングで一緒に剥がしました。

configとして持ってる内容を見て、プロジェクトとして必要なルールを精査して、他のeslint-configに移行しました。

Storybookの対応

Storybookで@storybook/preset-create-react-appおよびwebpackを使っていたのをViteベース(@storybook/react-vite)に移行しました。

テストの対応

react-scripts testコマンドはJestおよびテスト設定を提供しています。このタイミングでテスト基盤をJestからVitestに移行しました。

移行の詳細はチームメンバーが以下の記事にまとめてくれてます。

https://zenn.dev/lincwell_inc/articles/3bb6a8a33de14d

監視体制の確認

Viteへの移行はアプリ全体に影響するため、エラー監視はもちろんパフォーマンス監視の仕組みが整っていて、それが今回の監視に使えるかどうか確認しました。

  • どの値を監視するか
  • パフォーマンス計測についてはサンプリングしてるデータ量が十分かなどを確認

ビルド環境のメモリ割り当てを増加

react-scriptsよりViteの方がビルド時にメモリを使うようになっていたので、Node.jsの--max-old-space-sizeオプションを利用してサイズを変更しました。

Step1: Viteで開発サーバーと本番ビルドが実行できるように

まずはVite版の開発サーバーと本番ビルドコマンドを用意してビルドが通るようにします。普段のローカル開発や本番ビルドは引き続きreact-scriptsコマンドを使いつつ、Viteによるビルドおよび動作検証が行えるように対応しました。

必要な依存を追加

以下の、パッケージを追加します。

  • vite
  • @vitejs/plugin-react
  • vite-tsconfig-paths(必要であれば)

また、プロジェクトでstyled-componentsを利用しているため、babel-plugin-styled-componentsも追加しました。

package.jsonにスクリプトを追加

Vite版のコマンドを追加します。既存のreact-scriptsコマンドはそのまま残しておきます。

package.json
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "start:vite": "vite",
  "build:vite": "vite build"
}

index.htmlの用意

プロジェクトのrootにCRAで使っているpublic/index.htmlの内容を元にindex.htmlを用意します。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Your App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

CRAのindex.htmlは拡張構文で%REACT_APP_XXX%のような形式で環境変数を展開したり、条件分岐が行えます。この仕組みは標準のViteには存在しないため対応が必要です。コミュニティのvite-pluginを使うのも検討しましたが、利用箇所が少なく依存を増やしたくなかったため、htmlを書き換える簡素なcustom pluginを実装しました。

vite.config.ts
plugins: [
  {
    name: 'html-transform',
    transformIndexHtml(html) {
      // イメージ
      return html.replace('XXX', 'YYY')
    }
  }
],

環境変数参照コードの対応

react-scriptsはREACT_APPをPrefixとした環境変数を定義し、コード上ではprocess.env.REACT_APP_XXXという記述で参照します。

ViteはVITE_をPrefixとした環境変数を定義し、コード上ではimport.meta.env.VITE_XXXという記述で参照します。

これだと、react-scriptsとViteに応じて環境変数名および参照コードを切り替える必要があります。ただ、前途した通り同じソースコードでreact-scripts/Viteどちらも動かしたかったので既存の環境変数参照コードをViteでもそのまま使えるように、vite-plugin-env-compatibleを利用しました。

vite.config.ts
import { defineConfig } from 'vite'
import envCompatible from 'vite-plugin-env-compatible'

export default defineConfig({
  plugins: [
    envCompatible({
      // 段階的に移行するためにCRAと同じ環境変数のプレフィックスを読み込むように
      prefix: 'REACT_APP'
    }),
  ],
})

このpluginはVite移行完了後に環境変数のrenameおよびimport.meta.env経由のコードに修正して削除しました。

その他create-react-appの挙動を引き継ぐように設定

その他開発サーバーのportなど、設定でCRAと同じ状態にします。

vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    // ...
  ],
  server: {
    port: 8100,
    open: true
  },
  build: {
    outDir: 'build',
  },
})

Step2: ローカルや検証環境で動作確認を行いつつ不具合があれば対応する

この段階でViteでも問題なく動作していたため、開発者にVite版の開発サーバーコマンドを周知しました。有志に使ってもらって問題があれば報告してもらう運用を始めました。

また、検証環境ではVite版のビルドコマンドを利用してデプロイし、動作確認を行いおかしいところがあれば対応していきました。

assetsInlineLimitの調整

Viteはデフォルトだと4kb以下のアセットをインライン展開します。これだとうまく動作しないケースがありました。この閾値はvite.configのassetsInlineLimitで設定可能です。

今回はなるべく既存の挙動やコードを変更したくなかったので、設定値を0にして挙動をOFFにしました。

vite.config.ts
export default defineConfig({
  build: {
    assetsInlineLimit: 0,
  },
})

import文の修正

react-scriptsで使ってるwebpackだとimportで外部パッケージの型定義ファイル (.d.ts) を参照してるコードも正常に読み込めていました。しかし、Viteだとモジュールが読み込めずにエラーになったので、import type構文に書き換える対応を行いました。

- import { Foo } from 'foo/types'
+ import type { Foo } from 'foo/types'

このようなモジュール読み込みのエラーについては、画面ごとにdynamic importを使ってると画面を開くまで気づけないので、手元で全てのdynamic importを削除して、起動時に全てのモジュールを読み込むようにして検証すると楽です。

Step3: 本番リリースに向けた最終調整

sourcemapの対応

sourcemapの設定を確認し、必要があれば対応します。
設定でhiddenを指定すると、sourcemapファイルは生成されますが、JSファイルにsourcemapの紐づきコメントは消えます。この挙動はSentryにsourcemapを共有するようなケースにマッチしています。

また、ビルド時に出力されるJSファイルのパスが変わるため、sourcemapをアップロードする仕組みがあればそこも対応します。

vite.config.ts
export default defineConfig({
  build: {
    sourcemap: 'hidden',
  },
})

ターゲットブラウザの対応

CRAはbrowserslistを見てトランスパイルを行います。Vite v7でデフォルトのtargetが'baseline-widely-available'という値になり、具体的な中身は['chrome107', 'edge107', 'firefox104', 'safari16']になるため、サポートブラウザと違う場合は対応を行います。

また、CRAもViteも自動でPolyfillの挿入は行わないので、独自でPolyfillを読み込んでる場合はそれも対応する必要があります。

vite.config.ts
export default defineConfig({
  build: {
    target: ['chrome107', 'edge107', 'firefox104', 'safari16'],
  },
})

https://create-react-app.dev/docs/supported-browsers-features/

chunkFileNameを調整

Viteのデフォルトだと、chunkのjsファイル名に元のファイル名がついた命名規則となります。CRA同様にhashだけにしたい場合は設定で対応します。

その他にもchunkファイルをはじめとしたバンドラーに関する設定はrollupOptionsで行えます。

vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[hash].js',
      },
    },
  },
})

create-react-app組み込みの環境変数の利用をチェック

CRAが提供する組み込みの環境変数があり、それを使っている場合は対応します。
以下を見て使ってる環境変数がないかチェックできます。

https://create-react-app.dev/docs/advanced-configuration/

Step4: 本番リリースに向けて最終動作確認

ローカル開発環境のデフォルトをviteに変更

本番リリースに向けて、ローカル開発環境のデフォルトをViteに変更して、全開発者がローカル開発ではViteを使うようにしました。

package.json
"scripts": {
  "start": "vite",
  "build": "vite build",
  "start:cra": "react-scripts start",
  "build:cra": "react-scripts build"
}

パフォーマンス計測

CRAに比べて、Vite(Rollup)がデフォルトで吐き出すchunkが細かく作成されていました。これによるパフォーマンス影響を確認するため、Chrome DevToolsのLighthouseやPerformanceタブで検証を行いました。

RollupにはmanualChunksexperimentalMinChunkSizeという設定が存在しますが、検証環境ではパフォーマンスの優位差は見られなかったため、デフォルト設定のままにして、リリース後のパフォーマンス監視を行う判断をしました。

動作検証

アプリ全体に影響するため、普段のリリース時に行なっているE2EやQAに加えて、関係するチームを交えたモンキーテスト会などを開催して動作検証を行いました。

また、その他開発観点で以下の点を確認しました。

  • DevToolsでの検証
    • Networkパネルでリクエストされているリソース数やサイズをチェック
  • CRAとViteでビルド後に生成されるhtmlの差分を確認
  • 複数種類のブラウザや古いバージョンのブラウザでの検証
  • 出力された静的ファイルのサイズチェック

上記全て問題ないことを確認し、本番リリースしました。

Step5: 不要になった関連パッケージや設定を削除

本番リリースを行い、監視するメトリクスなどを含めて全て問題がないことが確認できました。
無事にリリースを行えたことを確認したあとはCRA関連のパッケージや設定を削除します。

$ npm uninstall react-scripts

これで完了です🎉

移行を終えて

移行を完了したことで、CRA要因でアップデートがブロックされていたtypescriptなどの依存パッケージを上げることができました。

また、開発サーバーの起動速度やHMR、ビルド速度の向上など、開発体験の恩恵を受けることができました。

本番リリース後に大きな問題もなく、安全にリリースできたと思います。

切り戻しがしやすく、事前に検証しやすい移行を心がけることで、大きなトラブルなく移行を完了できたのが良かったです。この記事が、create-react-appからの移行を検討されている方の参考になれば幸いです。

Linc'well, inc.

Discussion