Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

ビルドシステムをcreate-react-appからViteに移行した話

こんにちは、技術本部 Bill One Engineering Unitの江川です。
2021年4月に新卒としてSansanに入社したのですが、早いものでもう1年が経ってしまいそうで驚いています。
今回は、そんな早かった1年に思いを馳せつつ、Bill Oneフロントエンドのビルド周りを高速なビルドツールであるViteに移行していった話をしたいと思います。

移行の背景

Bill OneのフロントエンドではReact + TypeScriptを採用しており、ビルド周りには Create React App(以下、CRA)をejectなしで利用していました。*1
CRAは、開発初期にwebpackなどの設定を隠蔽し簡単にセットアップを行えるという利点がある一方で、開発を進めていきコードベースが大きくなってくるといくつかの問題が発生することがあります。
具体的にはBill Oneでは以下のような問題がありました。

  • コードベースが大きくなるにつれてビルドにかかる時間が増加し、開発サーバーの起動やHot Module Replacementに時間がかかってしまう。
  • webpack configのカスタマイズがしづらく、ビルドの高速化やチャンク分割に向き合いづらい。
  • CRAにてインストールされるパッケージの多くはreact-scriptsによるもので、依存が暗黙的でライブラリの継続的なアップデートがしづらい。

上に挙げた問題より、ビルド周りをできるだけ薄くし、暗黙的な依存パッケージを剥がしておくことは開発体験の向上や継続的な改善において恩恵を受けられると考え、移行に踏み切る判断をしました。

Viteへの移行

Viteの特徴

ViteはVue.js の作者である Evan You氏が中心となり開発されているビルドツールです。
大きな特徴としては、

  1. Native ES Modules(以下、Native ESM)を用いたno-bundleなビルドによる、高速な開発サーバーの起動・モジュール更新の反映
  2. プラグインによって足りない機能を柔軟にオプトインできる
  3. 本番ビルド時の最適化

などがあります。

ViteをはじめとしたNative ESMなビルドツールへの移行において問題となるのはレガシーブラウザの対応です。
Bill Oneではアプリケーションの一部でInternet Explorer 11(以下、IE11)対応を行っているため何らかの対応が必要となりますが、 Viteではプラグインによりレガシーブラウザ対応のチャンクを生成することができ、polyfillの生成含め問題なく対応することが出来ます。

ちなみに、ビルドツールを選定する際にSnowpackesbuildなど他のツールも検討しましたが、本番ビルドの安定性や豊富なプラグイン*2が既に存在すること、今後のOSSコミュニティの継続性などを考えて、最終的にはViteに決定しました。*3

CRAからViteへの移行方針

主に以下の状態を目指して移行を行いました。

  • react-scriptsなど、CRAによりインストールされ、不要になるパッケージが削除できている。
  • 移行時点では、不必要なプラグインの導入やチャンク最適化などは行わない。できるだけconfigファイルを薄くする。
  • Viteでレガシーブラウザに対応した本番ビルドが行える。ビルド先のディレクトリやエントリポイントのファイルは基本的に変更しない。

また、移行の手順についてはこちらの記事が参考になりました。
以下では実際に移行の際にやったことや、発生した問題とその対策をお話していきます。

移行の際にやったこと

パッケージインストールとpackage.jsonの書き換え

Bill OneではReactを採用しているため、以下のパッケージをインストールしました。

  • vite
  • @vitejs/plugin-react

@vitejs/plugin-reactは、執筆時点でFast RefreshやJSXトランスフォームの対応、Babelプラグインの適用などを行うことのできるプラグインとなっています。

また、package.jsonのscriptsフィールドを以下のように書き換えました。

// package.json
{
  ...
  "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test",
-    "eject": "react-scripts eject",
+    "start": "vite",
+    "build": "tsc --noEmit && vite build",
+    "test": "", 
    ...
  },
  ...
}

注意点として、Viteでは型チェックが行われないため、ビルド時やCI時にtsc --noEmitコマンドなどを実行して型チェックを行う必要があります。

configファイルの作成

CRAからViteへの移行における最低限のconfigファイルは以下のようになりました。

// vite.config.ts
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [
    react(),
  ],
  build: {
    outDir: "build", // CRAに合わせて指定
  },
})

build.outDirについては、デフォルトはdistになっているため、CRAの出力先ディレクトリのデフォルトに合わせてbuildを指定しています。

エントリポイントの更新

Viteではエントリポイントとして、デフォルトでプロジェクトルートのindex.htmlを参照するようになっています。
そのため、CRAのプロジェクトにおいて配置されていたpublicディレクトリ内のindex.htmlをプロジェクトルートへ移動しておきます。
index.htmlでは、TypeScriptファイルを読み込むためにscriptタグを追加します。

<body>
  <div id="root"></div>
+   <script type="module" src="/src/index.tsx"></script>
</body>

また、CRAでは必要だった、index.htmlにおける%PUBLIC_URL%の記述は不要になるため、以下のように置換を行います。

- <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <link rel="icon" href="/favicon.ico" />

環境変数の更新

CRAにおいて、環境変数はREACT_APP_というプレフィックスをつけることで自動で読み込むことが可能でした。
Viteでも同様にVITE_というプレフィックスにより環境変数を読み込むことが可能です。
そのため、利用していた環境変数をREACT_APP_からVITE_に置換を行いました。
加えて、Viteにおいて環境変数への参照はprocess.envではなくimport.meta.envを利用する必要があるため置換が必要となります。

- process.env.REACT_APP_CUSTOM_ENV
+ import.meta.env.VITE_CUSTOM_ENV 

なお、CRAを利用している際にprocess.env.NODE_ENVの値を用いて何らかの処理を行っていた場合は変数の置き換えが必要となります。 詳しくは、公式のドキュメントが参考になるかと思います。

- if (process.env.NODE_ENV === "production") {
+ if (import.meta.env.PROD) {
    ...
  }

また、import.meta.envから独自の環境変数にアクセスする際に、TypeScriptによる補完が必要な場合はvite/client.d.tsを拡張し型定義を行う必要があります。

// src/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_CUSTOM_ENV: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

ここまで終えると、npm run startで開発サーバーの立ち上げができるかと思います。
あとはreact-scriptsによるパッケージへの依存を剥がしていきましょう。

react-scriptsを剥がす

react-scriptsには、ビルド周りを除くと主にESLintやJest等のパッケージが内包されています。

まずは、ESLintの対応です。 今回の移行では、eslintのルールの見直しなどはスコープ外としていたため、react-scriptsに内包されているeslint-config-react-appをそのままインストールし直す形で対応しました。 これにより、CRA時代に設定していたLintルールは特に壊さずに移行を進めることができます。

次にJestです。 configファイルを既に設定済みの場合は必要なパッケージをインストールするのみで対応完了です。 Bill Oneでは設定ファイルをカスタマイズしていなかったため、改めて設定ファイルを作成しました。
作成したら、package.jsonを修正し、npm run testが走ることを確認しておきます。

// package.json
{
  ...
  "scripts": {
    ...
-    "test": "", 
+    "test": "jest", 
    ...
  },
  ...
}

そして最後にreact-scriptsをアンインストールしていきましょう。

// さらば、全てのreact-scripts
npm uninstall react-scripts

ここまでの対応でCRAからViteへの移行は完了です。お疲れさまでした。

その他対応したこと

IE11対応

上でもお話しましたが、Bill OneではIE11対応が必要でした。
Viteにおいては、レガシーブラウザ向けのビルドを作成するために@vitejs/plugin-legacyという公式のプラグインが提供されています。
このプラグインでは、本番ビルド時に指定したブラウザターゲット向けにレガシーチャンクを生成し、エントリポイントであるHTMLにnomodule属性付きのscriptタグを挿入することでNative ESMをサポートしていないブラウザに対してスクリプトの評価をさせることができます。
今回は、このプラグインを導入する形でIE11対応を行いました。

設定ファイルは以下のようになります。

// vite.config.ts
...
import legacy from '@vitejs/plugin-legacy'
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [
    ...
    legacy({
      targets: ['ie >= 11'],
      additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
  ],
  ...
})

これで本番ビルド時にレガシーブラウザ用のチャンクファイルが生成されるようになりました。

多言語対応

Bill Oneでは多言語対応のためのライブラリとしてLinguiJSを採用しており、このライブラリは内部で、babel-plugin-macrosというBabelプラグインに依存しています。
そのため、開発/本番ビルドとテストの実行時にBabelプラグインを差し込む必要がありました。
開発/本番ビルド時については、以下のように@vitejs/plugin-reactにBabelの設定を記述することで対応しました。

// vite.config.ts
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ["babel-plugin-macros"],
      },
    }),
    ...
  ],
  ...
})

テストにおいては、babel-jestにBabelの設定ファイルを指定する形で対応しました。
ちなみに、testファイルのtransformについては、babel-plugin-macrosts-jestの相性が悪かったためbabel-jestを利用しています。

// jest.config.js
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  ...
  transform: {
    "^.+\\.(ts|tsx|js)$": ["babel-jest", { configFile: "/path/to/config" }],
  },
  ...
}

移行の際にハマったこと

Native ESMなビルドツールであるViteへの移行では、利用しているライブラリによってはいくつか問題が発生することがありました。
以下ではBill Oneにおいて当たった問題について紹介します。

一部のライブラリがNode.jsコアモジュールを参照していてエラーが発生する

ライブラリにてESM形式で提供されているスクリプトにおいて、Node.jsコアモジュールを参照している場合に、ブラウザから呼び出せずにエラーが発生することがありました。
対応案としては、

  1. 対応するShimライブラリを入れる。(今回はstreamを参照してエラーとなっていたのでstream-browserifyなど)
  2. Viteの設定ファイルにresolve.aliasオプションを追加し、ブラウザから利用可能なスクリプトに参照を向ける。
  3. 問題の発生したライブラリの使用をやめる/代替ライブラリを検討する。

などがありました。
今回の移行では、たまたまエラーが発生したライブラリの使用をいずれやめることが決まっていたため、暫定対応として2を行い、最終的には3の対応を行う形で問題を解決しました。

globalオブジェクトへのアクセスが走りエラーが発生する

ヘッドレスブラウザなどからアプリケーションにアクセスした際に、globalオブジェクトにアクセスしようとしてエラーになることがありました。
これには、エントリポイントであるindex.htmlに以下のscriptタグを追記することで対応しました。

<script>window.global = window;</script>

globalThisが利用可能な環境ではwindow.global = globalThisとするでも良いと思います。

ESM形式に非対応なパッケージへの依存でエラーが発生する

Bill Oneで利用している一部のライブラリがNative ESMに非対応なパッケージに依存しており、名前空間が解決できずにランタイムエラーになるといった問題が発生しました。
実はこの問題はViteへの移行が完了したあとに発覚したもので、 問題が発生しているパッケージ側で既に解決の目処が立っていたことから、patch-packageを用いて問題となっている箇所を一時的に書き換える形で暫定対応を行いました。

今回はパッケージ側での対応目処が立っていたため暫定対応を取り移行を進めることができましたが、Viteに移行する際には依存しているパッケージがESM形式に対応しているのかが重要になるかもしれません。

まとめ

今回は、Bill Oneのビルドシステムをcreate-react-appからViteに移行した話をしました。
Vite移行後は開発環境の立ち上げ速度が大幅に改善し、特に問題なく快適に開発を行うことができています。 ただ、現在のBill OneのコードではCode Splittingなどが不十分で、開発環境立ち上げ後の初回アクセス時にリクエストを待つ必要があったりするので、更なる開発体験の向上のために今後も改善を続けていきたいと思います。

ここまでお読みいただいてありがとうございました。
CRAからのビルドシステムの移行を考えている方がいたら、Viteを検討してみてはいかがでしょうか。

*1:当時の技術選定については過去ブログにまとまっています。

*2:プラグインはこちらに良くまとまっています

*3:比較検討の際には、公式の比較こちらの記事を参考にしました。

© Sansan, Inc.