楽をして仕事を早く終わらせるためならどんな苦労も惜しまない藤田です。
現在は社内向けのシステム開発に携わっていて、比較的自由にライブラリを変更しやすいこともあり、いろいろ試して楽に開発できる道を探っています。
- Recoilで快適フロントエンド開発
- Jotaiで快適フロントエンド開発
- GraphQLライブラリをApollo→Relay→Urqlにハシゴした話
※現在はバックエンドのアーキテクチャ変更の都合でGraphQLからRESTに変更
開発&テスト&ビルドシステムも、Next.js(Webpack/Turbopack + Babel)+ Jest + Storybook から、Vite + Vitest + Ladle に乗り換えて大変快適に開発できているので、今日はこちらをご紹介します。
Viteはすでにかなり人気のビルドツールなので今更感はありますが、もしこれらのツールを使っていなくて、フロントエンドプロジェクトの開発サーバー、ビルド、テストが遅いと感じている人がいたら参考になれば幸いです。
目次
前提
今開発しているフロントエンドのざっくりとした規模感です。
- 言語:TypeScript + React
- アプリケーションのts + tsxファイル210個程度、svgファイル4個
- *.test.tsファイル10個程度、テストケース100個程度
- 大半は純粋関数のテスト(速い)
- 一部react-testing-libraryを使ったJotai.atomのテスト(遅い)
- *.stories.tsxファイル30個程度、Story100個程度
凡例
- 💝=気に入ったポイント
- 💔=残念なポイント
- 🔶=どちらでもないけど違うポイント
Vite
Viteはフランス語の「素早い」という言葉で発音は「ヴィート」だそうです。
ViteはJavaScript/TypeScriptアプリケーションのビルド(トランスパイル+連結+最小化)、開発用サーバーの機能を持ち、WebpackとBabelを置き換えるものです。
きっかけ:Next.js無しでディレクトリベースのルーティングしたい
うちのチームで開発しているアプリケーションは2022年末ごろにちょっと事情がありインフラ構成を大幅変更しました。
その際ついでに、Next.jsサーバーを使うSSGから、Next.jsでビルドしたバンドルJSファイルを静的に配置するSPAに変更しました。フロントエンドのインフラ構成がシンプルになり、エラー発生時の切り分けが簡単になり、フロントエンド担当者とバックエンド担当者でお見合いが発生する可能性が減りました。
※この記事ではSSG、SSR、SPAという言葉を下表のように使っています。
SSG, SSR | SPA | |
---|---|---|
インフラ構成(AWS) | [Amplify hosting、ECS、Fargate等] フロントエンド専用サーバー [CloudFront+S3] 画像等アセット |
[CloudFront + S3] |
リクエストの受けかた | フロントエンド専用サーバーで全てのパスを処理 | AWS API Gatewayで全てのパスに対してindex.htmlを返す |
アプリケーションの規模 | 適度に分割されたjsを返すので大きくてもイケる | あまり大きいとバンドルJSのサイズがすごくなる |
エラー発生時に調べる場所 | フロントエンド専用サーバー or バックエンド | バックエンドのみ |
SPAにするとNext.jsの嬉しさが半減しますが、/pages/以下のディレクトリ構造がそのままエンドポイントのパスに対応する、ディレクトリベースのルーティングをとても気に入っていたので、ほぼルーティングだけのためにNext.jsを使い続けていました。
今年(2023年)の春頃、高機能なNext.jsをルーティングのためだけに使いつづけるのはやはり違う気がする、と調べていて「ViteとReact Routerを使えば簡単にディレクトリベースのルーティングできるよ」という記事を見つけたのが、実はViteを知った最初でした。流行に鈍感…これが老化か…。
Simplifying Routing in React with Vite and File-based Routing
Viteにはimport.meta.glob()という機能があり、トランスパイル時にディレクトリ以下のファイルを列挙した結果を展開してくれます。これを利用して、ファイルが増えてもコード修正の要らないルーティングが作れるというわけです。
なるほどと思って別プロジェクトをさっと作ってほんの20行ほどコードを書いたらあっさりとディレクトリベースのルーティングが実現できました。(コードは上の記事ほぼそのままなので興味のある方はそちらを参照ください。)
ちょうど、開発サーバーの遅さにストレスが募っていたのもあり、Viteで速くなるなら良さそうだと思って乗り換えることにしました。
結果的にいろんな箇所が高速になり、vite.config.tsというとても簡潔な設定ファイルが出来た代わりにnext.config.jsと.babelrcというちょっと厄介な設定ファイルがなくなり、開発がとても快適になりました。
以下、Viteに乗り換えてよかった点を挙げていきます。
💝全てが速い
開発中のプロジェクトでvite build
を実行したら8秒弱でdist/ディレクトリにindex.htmlはじめ必要なファイルができました。以前のnext build
がどうだったか測っていませんが、体感ではここまで速くはなかったと思います。
開発サーバーの起動は一瞬で、あきらかにNext.jsより速いです。コンポーネントのソースコードを変更してからブラウザ上の表示に反映するまでの時間も一瞬で、とても助かっています。
またあとで書きますが、Storybook代替のLadleは3秒くらいで起動するし、Vitestの全テスト実行も5秒で終わります。とても快適に開発でき、テスト環境や本番環境へのデプロイも速いので助かります。
なぜこんなに速いのかについても調べたので、本記事の最後の方に書きます。
💝設定ファイルが小さくてわかりやすい
うちのチームで使っているviteの設定ファイルvite.config.tsはたったこれだけです。
import react from '@vitejs/plugin-react' import { defineConfig } from 'vitest/config' import svgr from 'vite-plugin-svgr' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), svgr()], test: { globals: true, environment: 'jsdom' } })
“test”という項目があるのは後述のVitestの設定ファイルも兼ねているからです。
これだけで、開発サーバーの起動も、テストも、デプロイ用のビルドも全部できます。next.config.js(とくにその中のwebpack設定)や.babelrcのような理解しづらい設定が全くないのは最高に楽です!
設定ファイルがTypeScriptなので、エディタで補完が効くのも地味に嬉しいです。
設定値はいろいろありますが、デフォルト値が十分に現実的で有用なので、コードやディレクトリ構成の方をデフォルトに寄せて設定ファイルをこの最小で保ちたいというモチベーションが湧きます。
💝ソースコード中で環境変数を置き換えられる
Viteはトランスパイル時に、名前が「VITE_」から始まる環境変数を展開してくれます。Next.jsで「process.env.NEXT_PUBLIC_***」を展開してくれるのと同じ動作です。
const backendUrl:string = import.meta.env.VITE_BACKEND_URL || '/api'
というソースコードを書いて、コンソールから
VITE_BACKEND_URL=http://localhost:8000 npx vite build
を実行すると、できたjsファイルは
const backendUrl = 'http://localhost:8000' || '/api'
相当になります(実際は最小化されて変数名など変わります)。なにかと便利です。
💝後でSSRにもできる
現在はアプリケーションが小さいのでSPAにしていますが、もし将来アプリケーション規模がすごく大きくなったらvite-plugin-ssrを使ってまたSSR、SSGに移行するかもしれません。Next.jsをやめてもこのオプションを持っておけるのはとてもありがたいです。
Vitest
うちのプロジェクトでは、バックエンドがアプリケーションの根幹となるモデルの処理を行い、フロントエンドはそれをユーザーにわかりやすく提示する、といった分担になっています。ユーザーにわかりやすく提示するためには、同じモデルでもシチュエーションによって情報を省略したり、強調したり、組み替えたりする必要があります。そのためフロントエンドのロジックもバックエンドとは別ベクトルで、結構複雑になります。
僕は日頃から、できるだけ純粋関数の割合が多くなるようにコードを書いています。複雑なロジックほどテストしながら書きたいので、純粋関数のテストが増えていきます。
どうしてもReactコンポーネントで使う状態管理のテストをする必要がある場合のみ、react-testing-libraryで仮想DOMを生成してDOMの中身をチェックするようなものを書いています。ヘッドレスブラウザを使うようなテストはありません。
ほとんどが純粋関数のテストなので結構早く終わるはずなのですが、以前Jestを使っていたときはテスト実行してから10〜20秒ほどかかっていました。
今回Viteに乗り換えたついでに、JestからVitestにも乗り換えて幸せになったので、Jestと比べてVitestを気に入っている点を挙げます。
💝超速い
全テストの実行が5〜6秒ほどで終わるようになりました。コンソール出力の様子を見ていると、どうもかなり並列でテストを実行しているようです。前述の通り純粋関数のテストが多く、テスト同士が干渉しないので並列実行は大歓迎です。
また、コンソールからコマンドライン引数無しでVitestを実行するとwatchモードとなって常駐します。Jestの–watchオプションと同様の機能です。
Vitestは内部でViteの開発サーバーを利用しているらしく、ファイルの変更を検知して関連するテストを再実行するのも非常に高速です。ソースコードを変更して保存して、一呼吸置いてコンソールに目を移すともうテストが終わっている感じです。
watchしない場合は–runオプションをつけます。
僕はフロントエンドのコードをIntelliJで書いています。IntelliJはVitestにも対応していて、Vitestを走らせておくと、テストを書いて保存した2秒後にはアサーション失敗した箇所に下線が表示されます。いちいちコンソールでテスト結果を見る必要すらなく、どんどん書いていけるので気に入っています。Jestでも同じ機能が使えますが、Vitestだとより速くて快適です。
💝設定ファイルが小さい
上で書いたViteの設定ファイルvite.config.tsをVitestでも相乗りで使います。”test”以下がVitest特有の設定項目です。設定ファイルを相乗りしたくない場合はファイル名「vitest.config.ts」を作るとそちらを使ってくれます。
import react from '@vitejs/plugin-react' import svgr from 'vite-plugin-svgr' import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react(), svgr()], test: { globals: true, environment: 'jsdom' } })
Jestとの比較で言うと、Jestは設定ファイルが無かったので一歩後退ではありますが、既存のvite.config.tsにたった4行付け加えるくらいなら十分許容範囲です。
💝コードの書き換え不要
設定ファイルにglobals: true
を書いておくと、テストコードにimport文を書かずにdescribe()
、test()
、expect()
といったJestでもおなじみの関数を使えるようになり、テストコードをほぼ修正せずJestからVitestに乗り換えられました。ありがたいです。
Ladle
きっかけ:Storybookが遅くなった→壊れた
以前うちのチームの開発ではコンポーネントの開発にStorybookを使っていました。しかし、アプリケーションが徐々に育ってコンポーネントもストーリーも増え、やがてStorybookの起動だけで30秒〜1分近くかかるようになっていました。
その時はよく調べなかったので原因がどこにあったのかはわかりません。とりあえずaddonを全部offにしてみたら起動時間が大幅に短縮したので、そのあたりに原因がありそうでしたが、a11y等の有用なaddonは使いたいので、全部offはあまり嬉しくありませんでした。
ViteとVitestの速さに浮かれてStorybookをなんとかするのを忘れたまま数週間たったころ、なぜか急にStorybookが起動しなくなりました。
TypeError: glob is not a function
node_modules/の中に確かにglobが存在するのに見つからないってどういうことなのか、いくら調べても原因がわからなかったのですが、調べている間にLadleの存在を知りました。
バックエンドが結構込み入っている関係上、ローカル実行では到達できないコンポーネントの状態がたくさんあって、Storybook無しのままでは開発できないので、緊急避難的にLadleを使ってみることにしました。
Ladle Demo からLadleのデモを見られます。Google検索する場合、”ladle”で検索しても料理の話ばかり見つかるので、”react ladle”で検索してください。
🔶コードの書き換え方
さすがにコードを書き換える必要がありましたが、難しくはありませんでした。
もとのStorybook用のストーリーは以下のようなものでした。
import React, {useState} from 'react' import {Meta, StoryObj} from '@storybook/react' import {Button} from './Button' const meta: Meta<typeof Button> = { component: Button, } export default meta type Story = StoryObj<typeof Button> export const Primary: Story = { args: { backgroundColor: '#ff0', label: 'Primary Button', }, } export const Secondary: Story = { args: { ...Primary.args, label: 'Secondary Button' } }
それがLadle用の書き方だとこうなります。
import {Story, StoryDefault} from '@ladle/react' import {Button} from './Button' export default { title: 'components/Button' } satisfies StoryDefault export const Primary: Story<Button> = (args) => <Button {...args} /> Primary.args = { backgroundColor: '#ff0', label: 'Primary Button', } export const Secondary = Primary.bind({}) Secondary.args = { ...Primary.args, label: 'Secondary Button' }
ご覧の通り、だいたい似通った雰囲気の書き方になっているので、あまり頭を悩ませることもなく機械的に作業して数時間で全てのストーリーを書き換えられました。
Storybookと同様、単にReactコンポーネントを作ってexportすればそのまま一つのストーリーにもなるので、面倒な初期化等が必要でReactコンポーネントとして書いていた部分は逆に変更が要りませんでした。
そして、以下、Ladleが気に入っている部分とそうでもない部分を紹介します。
💝超速い
Ladleは内部でVite開発サーバーを使っていて、起動が早いです。手元の環境では、コマンド入力してすぐにサービスが起動してブラウザが勝手に立ち上がり、画面が表示されるまで3秒ほどです。Storybookの起動が遅くて悩んでたのが嘘みたいです。
💝設定ファイルが小さい
Storybookはセットアップでstorybook init
を実行して設定ファイル群を揃えてもらう方式ですが、LadleはVite前提、React前提なので、環境やライブラリによる選択肢がありません。そのため設定無しですぐに実行できますし、設定ファイルもとても簡潔です。
うちのプロジェクトのLadle設定ファイル.ladle/config.mjsはたったこれだけです。
export default { stories: "src/**/*.stories.tsx", appendToHead: "<style>:root{--ladle-main-padding: 0;--ladle-main-padding-mobile: 0;}</style>" }
appendToHeadという項目は、Ladleの画面中で<head>
タグの中に任意のHTMLを埋め込める機能です。ちょっとpaddingが大きくてコンポーネントの端の感覚がつかみづらいな、と思ったので0にしちゃいました。
💝Storybookにある機能がだいたいある
Storybookのpreview.jsでできるGlobal decoratorの定義はLadleではcomponent.tsxというファイルでできます。(ここが最初からTypeScript前提なのも、地味に嬉しいです。)
僕が必要としていた、一つのテスト用コンポーネントをArgsによって分岐して別のストーリーにする機能や、画面操作でパラメータを変更する機能、a11yアドオンはLadleにも存在していました。
使っていませんが、Action実行機能やスナップショット画像の保存機能もあるようです。Visual Testingは無いようです。
🔶iframeではない
最近のStorybookはストーリーをiframe
内で表示するようになっていますが、Ladleは一つのDOMの中でメニューもストーリーも表示します。僕としてはだからどう、というのはありませんが、人によっては好きだったり嫌いだったりするかもしれません。
💔画面仕様(細かい)
Ladleの画面はたいへんシンプルです。あと、メニューのpaddingそんなに大きい必要ある?とか思ったりします。慣れれば気にならなくなります。
下図のようにコンポーネント表示部のpaddingが大きくとってあり、大きめのコンポーネントの端の感覚がつかみづらかったので、うちのプロジェクトでは前述の設定ファイル.ladle/config.mjsで0にしています。
メニューのツリー構造を開いていって一番下の階層のストーリー名をクリックしなければならない仕様は少し気に入っていません。
Storybookはひとつ上の階層のコンポーネント名クリックでそのコンポーネントのデフォルトストーリーを表示してくれるので、僕はそのほうが好きです。
Viteとその仲間達はなぜ速いのか
Viteとその周辺のツール群はなぜそんなに速いのか調べてみました。といってもViteの公式ドキュメントに書いてある通りですが、ざっくりまとめます。
バンドル<ネイティブESモジュール
まず開発サーバーやLadleの起動が速いのは、Webpackのようにすべてのスクリプトを一つに連結(バンドル)するのではなく、分割した状態でブラウザに提供しているからだそうです。最近のブラウザでESモジュール機能がサポートされてimportやexportが使えるようになったからこそ可能な、後発の強みですね。
たしかに開発サーバーで動いているアプリケーションでChromeのDevToolsでリクエストを見てみると、chunk-****.jsやnpmモジュールと同名のファイルをたくさん読み込んでいます。中身は別のJSファイルを指すimport文が書いてあります。
開発コードと依存先npmモジュールの参照関係を全て解決して、参照されている部分だけを取り出して一つのJSファイルに連結するのは、それはもう重たい処理でしょうし、開発コードを変更する度にまたバンドルし直すのも大変そうです。その辺の参照関係の解決をブラウザに任せてしまえるなら、たしかに速そうですね。
そしてJavaScriptのトランスパイルやESモジュール化にはgo製で高速なesbuildを使うので、さらに速いとのことでした。
事前バンドル
依存先ライブラリは開発コードほど頻繁に更新されないので、npmモジュール毎に1つずつのjsファイルに事前バンドルしておき、毎回トランスパイルせず済むようにしてあるそうです。また、npmモジュール間の依存関係を積極的にキャッシュしているのも高速化に貢献しているとのことです。
実際にnode_modules/.vite/depsディレクトリを見てみるとjsファイルがnpmモジュールごとに作られているのでそのことだと思います。
TypeScript型チェックを省略
Viteはトランスパイルする際、時間のかかる型チェックをしないそうです。たしかに、ほとんどの場合、VSCodeやIntelliJなどのエディタで書いてエラーが表示されてなければ型チェックは終わっているので、必ずしも必要ではないですね。
デプロイプロセスのどこかで別途tscコマンドを実行することが想定されています。うちのプロジェクトではテスト時に実行しています。
ビルドが速い理由
Viteはプロダクションビルド時には全体を1つにまとめるバンドルを行い、それにはesbuildではなくRollupを使っているそうです。その際でも上記の事前バンドルと型チェック省略による恩恵が得られるため、高速にビルドできるそうです。
事前バンドルと依存関係のキャッシュはViteを利用するVitestの速さにも効いているはずです。
おわりに
Vite、Vitest、Ladleをご紹介しました。速いって素晴らしいですね。あまりに気に入ったので、個人開発しているChrome拡張やライブラリのプロジェクトもみんなViteに乗り換えてしまいました。もしWebpackやStorybookが遅くて困っている場合、検討してみてはいかがでしょうか。
ちなみに、Webpackでesbuildを利用することで高速化するesbuild-loaderといったローダーもあるようなので、状況に応じて快適な開発環境を追求すると幸せになれると思います。