ある日突然、あなたに190倍(当社比)高速なdev serverができたらどうしますか...?
この投稿ではWebアプリケーションのビルドツールをCreate React AppからViteへと移行した背景・手順・結果について説明します。
Vite等のビルドツールやフロントエンドアプリケーションの開発体験に興味・関心がある方、中でもCreate React App(以下、CRA)を利用していてdev server起動やHot Module Replacementの速度に課題を抱える方の参考になれば幸いです。
前置き
SmartBankが提供するB/43の開発チームはユーザーが利用するモバイルアプリだけでなく、カード発行会社としてのバックオフィス業務を支援する管理機能を内製しています。
B/43にちなんでA/43*1 と名付けられたこのシステムはカード発行を申し込んだユーザーの本人確認*2やカード配送にかかる業務を始めとし、お知らせ掲示やユーザーからのお問い合わせ対応等のさまざまな用途に用いられます。
本記事で述べるのはこのA/43のフロントエンドに関する内容となります。
移行の背景
A/43のフロントエンドではReactとTypeScriptを採用しつつ、React Adminというフレームワークを用いています*3。
2020年にこのアプリケーションを立ち上げた時からCRAを使っており、立ち上げ以降もずっとejectせずに利用していました*4。
開発体験上の課題
2020年当初は快適に開発できていたのですが、時を経るにつれて以下のような開発体験の悪化が目立つようになりました。
- dev serverの起動が遅い
- 1分未満〜数分
- HMR (Hot Module Replacement) が遅い
- コード変更後、画面のリロードが終わるまで数秒〜数十秒かかる
- CRA (正確にはreact-scripts) が大量の依存関係を持っていて継続的なアップデートが手間
- 使っていないpackagesも多くあるがdependabotによるアップデートが行われてコストがかかる
- 何に使われているのかよくわからないままアップデートし続けているものもある
上述の速度はコードベースにも依存するので、参考までにA/43の規模をお伝えすると画面数127、コード行数53,000超といったサイズでした*5。tokeiによる計測結果も貼っておきます。
$ tokei src/ =============================================================================== Language Files Lines Code Comments Blanks =============================================================================== CSS 3 135 99 18 18 TSX 458 50085 45895 365 3825 TypeScript 199 8689 7969 56 664 =============================================================================== Total 660 58909 53963 439 4507 ===============================================================================
開発時の速度低下は深刻で、文言程度のちょっとした修正に着手するにも毎度「ローカルで確認するために数分待つのか…」と心に暗い"影"を落としていました。
専属のフロントエンドエンジニアがいない数名のチーム*6で、立ち上げから2年経たずにこれだけの規模になったA/43は今後も継続的な成長が見込まれます。その成長を加速させるためのより良い開発者体験を求めてビルドツールを移行することにしました。
なお、A/43は社内のステークホルダーや協業パートナーによる利用が主なため、ハイパフォーマンスなユーザー体験を追求する必要は現時点ではありません。なのでCRAが隠蔽していたビルド設定のカスタマイズ性向上は今回の移行の動機には含まれません。
Viteについて
Viteは既存のツールや現代のブラウザの機能をうまく組み合わせることで優れた開発者体験を生み出すことにフォーカスしたビルドツールです。開発環境では超高速なesbuildを、本番環境向けのビルドでは安定したRollupを用いてソースコードをビルドします。
公式ガイドも丁寧に記述されているので掘り下げたい方はぜひご一読ください。個人的にはWhy Viteのページがとても印象的で、「これまでのツールとは一線を画しているんだ」という"凄み"を感じました。
その他、YouTubeの『Vite in 100 Seconds』も100秒で視覚的に"凄み"を理解したい方におすすめです。
なぜViteを選んだか
自分のビルドツールの知識がWebpack, Parcel, Rollupあたりで止まっていたので、まずはState of JS 2021のランキングからビルドツールやバンドラのトレンドを概観し、2021年に彗星のように現れたViteが"覇権"を取っていることを理解しました。
Viteに限らず各種ツールについて改めて調べてみたところ、どうやら自分が知っていたビルドツールの"1つ先のパラダイム"があり、そのパラダイムにいるのがViteやSnowpackだとわかりました。このパラダイムの中核にあるのが以下の点です。
- esbuildの活用
- 超高速なビルド
- ViteやSnowpackは依存関係の事前バンドルをesbuildで行うことで開発体験を向上させている
- ViteやSnowpack抜きでesbuildを開発・本番の両方に直接使うこともできるが、プロダクション向けビルドは既存の枯れたツールに比べるとまだ洗練されておらず、併用されることが多いようす
- No bundle
- ES Modulesを活用することで開発中はバンドルせずに各ファイルを都度読み込み
- 各ファイルは一度だけビルドされキャッシュされる
- ファイルが変更されるとそのファイルのみビルドするため、差分更新も高速に完了
さらにVite公式のWhy ViteやComparisons with Other No-Bundler Solutionsを読んだり素振りしたり情報収集したりを経て、最終的には"勢い"と"継続性"を感じるViteを選択しました。
2022年4月時点にて、Viteは活発に開発されている一方でSnowpackは開発があまり活発でなくなっているように見えます。また、Viteには企業スポンサー*7も多くついており、継続性の懸念が少ないと見込みました。
State of JS 2021の結果を見てわかるように利用者の満足度も高く、今後も新規プロジェクトではViteの採用が増えていくことを期待します。
Vite移行で得られたもの
移行した結果を先にお伝えすると、抱えていた課題の多くがVite移行によって解決されました*8。
- 遅かったdev serverの起動
- 初回の起動は10倍の高速化
- 2回目以降の起動は190倍の高速化
- 依存関係のpre-bundleのおかげと思われます
- HMRの速度
- 変更内容にもよるものの、体感1秒未満
- 依存関係
yarn.lock
の行数が49.9%削減yarn install
は1.7倍の高速化- ※ 依存関係の複雑さが減ったことをどのように計測するかについて、より適切なメトリクスがあれば教えてもらえると嬉しいです
はい、まぁ、解決されたんですが、190倍って凄すぎませんか。さすがに冗談とか計測ミスかと思ったのですが本当に異次元の速さでした。
これだけの速度が設定なし*9で手に入るとなると完全にワープ進化で世代交代です。以前の開発体験には戻れないほどの向上となりました。
metrics | CRA | Vite |
---|---|---|
start dev server (1st run) | 2m30s | 15s |
start dev server (2nd run) | 43s | 226ms |
yarn.lockの行数 | 18,873 | 9,536 |
rm -rf node_modules && yarn install | 40.01s | 22.57s |
移行の手順
移行手順については先達による記事がいくつか見つかり、その中でも以下の2つの記事がとても参考になりました。
今回の移行作業は大まかに以下の手順で行いました。Vite移行とは切り離せる作業を先に終わらせることで移行時のインパクトを極力少なくするためです。
- JestをCRAを使わずに実行する
- Vite移行
これから移行される方の参考になることを期待して我々が行った移行手順も詳細に記述しますが、読み飛ばしていただいても構いません。
JestをCRAを使わずに実行する
A/43ではテストランナーにJestを利用しており、CRA (react-scripts) 経由での実行をしていました。CRAを脱却するためJestを直接呼び出すように変更する必要があります。
なお、この変更はVite移行とは切り分けられるため単独のpull requestにて行いました。
必要なpackagesのインストール
Jestの公式に従ってインストールします。すでにCRA経由でインストールされているものばかりなのですが、バージョンが低いものもあったのでこのタイミングでアップデートしつつ明示的にpackage.json
に記述するようにしました。
$ yarn add -D jest babel-jest @types/jest @babel/core @babel/preset-env @babel/preset-typescript babel-preset-react-app identity-obj-proxy
ちなみにbabel-jestではなくts-jestへの移行も試したのですがテストの実行速度が1.5倍ほどに伸びてしまい、高速化の目処が立たなかったのでbabel-jestを選択しています。
Babel, Jestの設定ファイルを記述
これまでCRAがよしなにやっていたJestやBabelの設定を自分でやることになります。
まったく同じ設定を再現する必要はないのですが、既存の設定を知るために一度yarn run eject
を実行してCRAの設定を吐き出し、そこから必要な設定を移していく方法を取りました。
eject後に生成されたファイルたちを読み解くのもけっこう手間でしたが、Jestのconfigを生成するCRAのコードも参考にしつつ理解を進めたところ、大部分は不要だとわかり、我々のプロジェクトでは最終的に以下の設定で必要十分なことが分かりました。
// babel.config.js module.exports = { presets: ["react-app", "@babel/preset-env", "@babel/preset-typescript"], };
// jest.config.js module.exports = { roots: ["<rootDir>/src"], testMatch: [ "**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)", ], modulePaths: ["./src"], testEnvironment: "jest-environment-jsdom", moduleNameMapper: { "\\.(css|less|scss|sass)$": "identity-obj-proxy", }, setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"], };
コード中にあった不要なprocess.env.PUBLIC_URL
を削除
CRA経由でJestを実行する際にはprocess.env.PUBLIC_URL
に''
がスクリプトによってセットされていましたが、このスクリプトを介さなくなることによって一部テストが落ちてしまいました。
そもそもprocess.env.PUBLIC_URL
をアプリケーションで使っておらず不要だったためコードから削除しました。
npm scriptsの変更
npm scriptsにて直接Jestを呼び出すようにします。
"scripts": { - "test": "react-scripts test --env=jest-environment-jsdom-sixteen", + "test": "jest", },
プロジェクト立ち上げ時の名残でjest-environment-jsdom-sixteenを使用していましたがJest v26以降では不要なpackageになりました。今回Jestはv27にアップデートされたのでyarn remove jest-environment-jsdom-sixteen
しておきます。
これらの変更後にyarn test
がPASSすれば、テストにおけるCRA脱却は成功です 🎉
💭 ちなみにテストも高速化のためにVitestにそのうち移行したいと考えています。
2. Vite移行
まずViteのための最小設定を行い、それ以降はyarn start
やyarn build
実行時に動かないところをひたすら潰していくことになります。先達の記事を参照しつつ以下の変更を加えました。
Viteのための最小設定を行う
まずVite本体と、Reactを使うためのVite pluginをインストールします。
$ yarn add -D vite @vitejs/plugin-react
npm scriptsもViteを使うよう編集しておきます。vite build
はビルド時に型検査を行わないのでyarn typecheck
を直前に挟んでいます。
"scripts": { - "start": "react-scripts start", - "build": "react-scripts build", + "start": "vite", + "build": "yarn typecheck && vite build", "test": "jest", - "eject": "react-scripts eject",
現時点でのViteの最小設定をvite.config.ts
に記述します。
// vite.config.ts import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], });
index.html
の編集
Viteはデフォルトではプロジェクトルートにindex.html
があることを期待するため、それに合わせてpublic/index.html
をルートに移動します。ちなみにViteはindex.html
のあるディレクトリをrootと認識し、設定における各種パスはそこからの相対となります。
その他、細かい修正をいくつか行います。
%PUBLIC_URL%
はViteには不要な設定のため削除<script>window.global = window;</script>
を追加global
オブジェクトへのアクセス時にエラーとなるため
<script type="module" src="/src/index.tsx"></script>
を追加- エントリーポイントを指定しているだけです
- <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> + <link rel="apple-touch-icon" href="/logo192.png" /> <link rel="manifest" href="/manifest.json" /> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> + <script>window.global = window;</script> + <script type="module" src="/src/index.tsx"></script>
環境変数の扱いを変更
公式ドキュメントによると
Vite exposes env variables on the special
import.meta.env
object.
とのことなので、これまでprocess.env
で参照していた環境変数はimport.meta.env
から参照するように置換します。
その他にもガイドに従って以下を行いました。
env.d.ts
に環境変数の型を定義することができるのでこれも行うsrc/react-app-env.d.ts
=>src/vite-app-env.d.ts
にリネームする
加えて、CRAは環境変数のprefixにREACT_APP_
を要求しますがViteではVITE_
になります。これもVITE_
に統一すべきですが、移行時の差分を減らすためにvite.config.ts
のenvPrefix
を用いてREACT_APP_
のままにしました。
// vite.config.ts
export default defineConfig({
+ envPrefix: "REACT_APP_",
plugins: [react()],
});
Viteの設定を変更
ここまでの対応では足りない細かい点を設定変更で吸収します。プロジェクトによっては不要なものも含まれます。
build.outDir
- ビルドしたアセットの出力先がViteのデフォルトでは
dist
なのでこれまで通りbuild
にする
- ビルドしたアセットの出力先がViteのデフォルトでは
resolve.alias
aws-amplify
packageの問題を回避するために追加
plugins
3について補足すると、Viteではabsolute importはサポートされません。我々のプロジェクトではabsolute importを多用していたのでvite-tsconfig-paths pluginを加える必要がありました。これを機にすべてrelative importに書き換えようかとも思ったのですが当面は移行時の差分を最小化することを優先しました。
// absolute import import { MyComponent } from "components/MyComponent" // relative import import { MyComponent } from "../../components/MyComponent"
最終的にViteの設定は以下のようになりました。
// vite.config.js import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + build: { + outDir: "./build", + }, envPrefix: "REACT_APP_", + resolve: { + alias: { + "./runtimeConfig": "./runtimeConfig.browser", + }, + }, plugins: [react(), tsconfigPaths()], });
Jest用にbabel-preset-vite
packageの追加
環境変数の参照で必要になったimport.meta
はまだ標準仕様ではないため、Jest実行時にBabelによるトランスパイルが必要となります。これを行ってくれるpluginとしてbabel-preset-vite
を追加しました。
module.exports = { - presets: ["react-app", "@babel/preset-env", "@babel/preset-typescript"], + presets: ["react-app", "@babel/preset-env", "@babel/preset-typescript", "babel-preset-vite"],
ここまでで移行の手順は終わりです。趣味プロジェクトでViteを多少素振りしていたおかげもあり、移行にかかった期間は2日程度でした。
影響を受けなかったこと / 経験しなかったこと
今回のVite移行で特に影響を受けなかったり、経験しなかったことについても触れておきます。
- ESLint
- 元々CRAを使わずに実行していたのでVite移行の影響を受けませんでした
- CRA経由でLinterを実行している場合は上述のJestの例のように、設定をうまく引き継ぎながら脱却する必要があります
- Storybook
- 使用していません
- 2021年12月の情報では、StorybookのVite移行には難があるようです
- Sass等のCSS拡張
- 使用していません(React JSSを使っています)
- 必要なpackagesを追加する程度で移行は済みそうに見えました
移行を終えてみての感想
先述しましたがとにかく速くて開発体験が最高になりました。速さは正義…。
次第に悪くなっていく体験に知らず知らずのうちに慣らされていくことがありますが、立ち止まって足回りを整えることの重要性を再実感しました。
また、今回は業務でVite移行をする前に趣味プロジェクトで移行を試したり、スクラッチでアプリケーションを書いていたおかげで予想よりもスムーズにいきました。手を動かすことで新技術の手触り感を持っておくのは大事ですね。
本記事はサーバサイドエンジニアの @ohbarye が執筆しました。
SmartBankでは開発体験の向上に興味があり、ReactやTypeScriptもバリバリ書いていきたいサーバサイドエンジニアを募集しています!
*1:Administrationに由来しています
*2:詳細は『B/43のeKYCシステムの裏側』をどうぞ
*3:なぜ管理画面にSPAを採用したのかと思われる方もいるかもしれませんがそれだけで1本の記事となりそうなので本稿では説明を省略し、関連記事として筆者による『バックエンド Web API に管理画面/管理機能を追加するアーキテクチャパターン』を貼るにとどめます
*4:ejectはCRAが内包する設定や依存関係を全て吐き出すコマンドです。https://create-react-app.dev/docs/available-scripts/#npm-run-eject
*5:2022-04-20時点
*6:SmartBankではサーバサイドエンジニアがフロントエンドも担当します
*7:余談ですが、スポンサーの1つであるShopifyが提供するフレームワークHydrogenでもViteを使っています https://shopify.engineering/developer-experience-with-hydrogen-and-vite
*8:念の為おことわりしておくと、すべてのプロジェクトで同水準の結果を得られるわけではありません。プロジェクト規模や使用するパッケージ等によって高速化の度合いは異なるはずです。
*9:パフォーマンスに関する設定なし、という意味です