自ブログからの転載になります。
今回アドベントカレンダーに初めて参加いたします!
概要
最近私が 0=>1 から開発に取り組んでいるプロダクトのフロントエンドで esbuild + React(TS) を利用した開発環境を選定しており、タイトルが若干盛っていることはさておき、リリースから少し時間もたったので、一度振り返って知見が共有できればと思います。
尚、今回ご紹介する開発環境はサンプルレポジトリ esbuild-dev-environment-sample を作成しましたので、適宜ご参照ください。
esbuildとは?
この記事を見ている方はすでにご存知の通り、Webpackの100倍早いバンドラと謳われています。
その威力に関しては以前の 記事 に書きましたので、ご参考いただけると幸いです。
もしまだ esbuild を試したことがない方がいましたら、是非 サンプルレポジトリ をcloneして npm i の後、npm build を試して見てください!
かなり高速にビルドができることを実感できると思います。
選定理由
まずはesbuildを利用した開発環境を選定した理由を整理したいと思います。
私は今まで Angular, Nuxt, Next など各種SPAを利用してフレームワークを利用してプロダクトの 0 => 1 構築を行ってきました。その経験上、フレームワークは多くのツールチェーンを事前作業なしに利用でき、ビルド設定も高度に最適化されているため大変開発者が救われる物ではありますが、反面オーバーヘッドが大きく徐々にビルド時間が増大することで開発のアジリティ低下に悩まされやすいのも事実として実感してきました。
現実問題、ほとんどの開発者は複数のタスクを持っていることが多く、PCの性能の限界を考えてもタスクの切り替えは頻繁に発生し、その度にビルドに時間を取られていてはDXが悪化していく危険性があります。大それた言い方ですが、ビルド速度はフロントエンド開発のDXにおける長年のクリティカルな問題点だと言えます。
また、最近では(React系では) Preact や Gatsby の様な軽量な開発環境の流行も一巡し、さらに軽量な開発ツールが多数登場しています。SSR対応が必要であれば依然としてフレームワークの恩恵は大きいですが、Jamstack の様なツール依存の少ない環境ではより軽量な開発環境が好まれます。ただやはり、ビルドに関してのボトルネックは Webpack である事が多くここを改善するのが最も効果的だと思われます。
そのため、ビルド自体を高速化でき、既に多くの人気を得ている esbuild を利用したいと考えました。 考慮点としては、 Next や Gatsby などには対応していないため、フレームワークに依存しない開発を行うか、 vite の様な対応する新興ツールを選定する必要があります。
今回私が構築したプロダクトは、業務アプリケーションで所謂 Jamstack なSPAアプリケーションです。
0 => 1 段階で漸進的にツールを取り入れていく運用が可能かつ、ある程度React系のフロントエンド開発に慣れがあることから、思い切ってフレームワークを捨て esbuild + React で開発環境を構築してみましたが、非常に正解だったと思っています。
ビルドの設定
esbuildのビルド設定は非常にシンプルです。Webpackの利用経験などがある方であれば下記のコードで理解できると思います。
build.ts
import * as fs from 'fs';
import * as path from 'path';
import { build, BuildOptions } from 'esbuild';
// 環境変数を確認
const NODE_ENV = process.env.NODE_ENV ?? 'development';
const isDev = NODE_ENV === 'development';
const watch = process.env.WATCH === 'true' || false;
const metafile = process.env.META_FILE === 'true' || false;
// webpackのdefine pluginと同じ
const define: BuildOptions['define'] = {
// コード上の `process.env.NODE_ENV` を `development` などで置き換える
'process.env.NODE_ENV': JSON.stringify(NODE_ENV),
};
// ビルド処理
build({
define,
// Reactのメインファイル
entryPoints: [path.resolve(__dirname, 'src/index.tsx')],
bundle: true,
// ビルドされたバンドルの出力先
outfile: 'public/index.js',
minify: !!process.env.MIN || !isDev,
sourcemap: true,
platform: 'browser',
target: ['chrome58'],
treeShaking: true,
watch: watch && {
// watchモードで起動したい場合は、再ビルドのcallbackを渡す
onRebuild(err, result) {
console.log(`${dayjs().format('HH:mm:ss')}: 再ビルド`);
},
},
}).then(result => {
console.log(`ビルド完了`);
}).catch(() => process.exit(1));
その他, 基本的な loader(data-url, fontなど) や, external を指定する事もできます。
ローカルでの開発
結論から、以下の3段階の設定を行いました。
-
esbuildをwatchモードで起動 - 生成物を
expressサーバでホスト - コード変更時に
Browsersyncでブラウザを自動でreload
1. esbuildをwatchモードで起動
環境変数 WATCH=true の時に、差分ビルドされる様にビルド設定を書きます(前節の通り)。esbuildは差分ビルドも超高速なので、大抵の場合はコードの変更を検知して一瞬でビルドが完了します。
2. 生成物をexpressサーバでホスト
今回はpureなReact SPAであり、htmlファイルはindex.html一つだけになる想定をしています。そのため、全てのアクセスは /index.html に飛ばす(rewriteする)必要があり、http-server などで単純にホストするだけでは要件を満たせません。
そこで、expressを利用してローカルでホスティングをしてあげる事にしました。
expressのコードは以下の通りです。
local-hosting.ts
import express from 'express';
import * as path from 'path';
const app = express();
const PORT = 3030;
app.use(express.static(path.join(__dirname, 'public')));
app.get('/*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT);
console.log(`listen on port: ${PORT}`);
この通り、簡単にホスティングが可能です。起動ポートが3030になっているのは 3 で説明する Browsersync との兼ね合いです。ちなみに esbuild によってホストされるファイルの内容が順次書き換えられていくので、express自体は起動しっぱなしでOKです。
3. コード変更時にBrowsersyncでブラウザを自動でreload
1, 2により、任意のパスでReactアプリにアクセス可能になりました。この状態でもOKなのですが、コードを変更した時にesbuildの再ビルドの完了を待ってから自分でブラウザをリロードする必要があります。esbuildにはいわゆるHMR(hot module replacement)の機能がなく、実装のされる見込みも薄い現状があります。
そこで、Browsersync を利用して自動的にブラウザをリロードする様にしました。コマンドは以下の通りです。
npm i browser-sync
browser-sync start --notify=false --proxy 'http://localhost:3030' --files 'public/*' --port 3000
これにより http://localhost:3000 へのアクセスを browser-sync が受けて、3030番ポート で待っている express に接続しながら、public ディレクトリ配下のファイルの変更(=esbuildの再ビルド完了)を検知して、ブラウザを自動更新してくれます。
package.jsonにまとめる
一例ですが、この様に concurrently を利用してこの様にコマンドをまとめました。
これにより npm run dev コマンド一発で開発環境が立ち上がります。
package.json
{
"scripts": {
"dev": "concurrently --prefix \"{time}\" -t \"HH:mm:ss\" -c \"bgCyan.bold,bgGreen.bold,bgGrey.bold\" -n \"esbuild,BrowserSync,Express\" \"npm run build:watch\" \"npm run browser:reload\" \"npm run serve:local\"",
"build": "node --require esbuild-register build.ts",
"build:watch": "WATCH=true npm run build",
"browser:reload": "wait-on public/index.js && browser-sync start --notify=false --proxy 'http://localhost:3030' --files 'public/*' --port 3000",
"serve:local": "node --require esbuild-register local-hosting.ts"
}
}
TIPS
以下では開発に当たって工夫した点やハマった点を整理したいと思います。
TSの即時実行に関して
折角なので、esbuildを実行するbuild.tsや、expressを実行するlocal-hosting.tsもTypeScriptファイルで書きたいところです。ただ、毎度ビルドするのは面倒なので、TypeScriptを直接実行するツールがあると便利です。古くから利用されている物であれば、ts-node がありますが、実行毎のトランスパイルで少し待たされます。
そこで、今回はトランスパイル作業をesbuildで行ってくれる esbuild-register を利用しました。これにより、個人的には気にならないくらいの速度でTSを直接実行する事ができました。
URLに状態を反映する
今回の仕組みではコードの変更時に自動でブラウザのリロードが走りますが、毎回ストアがリセットされて初期状態に飛ばされる仕様だと開発が手間です。そのため、検索条件やモーダルの開閉やなどの主要な global state はURLのクエリストリングなどと同期する仕様が望ましいです。
(その方が様々な状態への直リンクを提供できる点でプロダクトの使い勝手もよくなると思われます。)
CSSの対応
esbuildはコード上でimportされているCSSファイルを全て一つのファイルにまとめて出力してくれますが、scss の変換や、 css modules など特殊なloaderが必要になる様な機能を実装していません。いずれも事前にトランスパイルするとか、自作プラグインを作成するなどして対応も可能な様ですが、ひとまず普通のCSSで記述する様にしました。
ただし、各コンポーネントごとにのCSSはファイルを作成し、そのコンポーネントのルート要素のclass名を必ず頭につける運用である程度分離できる様にしました(コードをご参照ください)。私の場合はコンポーネントの雛形として、index.tsx, index.css, index.spec.tsなど一式を自動生成するCLIツールを作成してメンバーに共有しました。
(eslintの独自ルールを作っても良いかもしれません)。
大規模な開発を行う際は当然CSSのエンカプセレーションを検討すると思いますので、注意が必要なポイントです。
metafileによるバンドル解析
WebpackではBundle Analyzerを利用してバンドル結果を出力し、バンドルサイズのチューニングなどを行いますが、それと同様の事がmetafileによって実現されています。ビルド設定にフラグを立てるだけで、モジュール解決の詳細がまとめられた meta.json が出力されます。
Dead Code Eliminationに関して
esbuildにはtree shakingとdead code eliminationの機能があります。これにより、実行されない事が静的に確定するコードブロックを削除してくれますが、以下の通り注意をする必要があります。
/**
* esbuildのdefine設定で、
* process.env.NODE_ENV を `production` に変換する場合
*/
if (process.env.NODE_ENV === 'development') {
// to be eliminated
import('module1').then( module => { /* some code */ } );
} else {
// not to be eliminated
import('module2').then( module => { /* some code */ } );
}
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
// not to be eliminated
import('module3').then( module => { /* some code */ } );
} else {
// not to be eliminated
import('module4').then( module => { /* some code */ } );
}
この通り実際のバンドルから除外されるmoduleはmodule1だけになり、module3はバンドルされます。
例えば、私の場合 DIコンテナ(inversify.js)を利用しており、検証環境にだけ検索APIのモックがDIされますが、APIモックは個人情報っぽい顧客のダミーデータが記載されている状態でした。なんとなく気持ち悪いのと、容量的にも大きいのでこの点を注意しました。各モジュールがバンドルされているかどうか
は上記の meta.json で確認できます。
Jestに関して
設定ファイルを以下の通りしました。TSのトランスパイル(jest.config.jsのtransformプロパティ)には ts-jest が使えますが動作が遅いので、 esbuild-jest の利用を検討しましたが、トランスパイルに失敗することがありました。今回は原因に深入りせずに tsc でトランスパイルした js(dist配下) ファイルに対してjestを実行する形にしました。
概ね一般的な設定ですが、esbuild が対応している css import がJestでエラーするためモックしています。
jest.condig.js
const path = require('path');
module.exports = {
roots: ['<rootDir>/dist'],
testMatch: ['**/__tests__/**/*.+(jsx|js)', '**/?(*.)+(spec|test).+(jsx|js)'],
moduleNameMapper: {
'^@/(.+)': '<rootDir>/dist/$1',
// CSS Import をモック
'\\.(css|less|scss|sass)$': path.resolve(__dirname, './dist/styleMock.js'),
},
};
まとめ
上記の通り色々とTIPSはあるものの、十分扱い易い形で開発環境を構築することができました。esbuildを利用して開発環境を構築したことで、ある程度コード量が多くなってきても10秒もかからずビルドができており、実際に開発速度はかなり向上しました。今のところどうしても扱いにくいという点は見つかっていないのが現状です。
esbuild はすでに多くのツールに組み込まれ今後の発展が一層期待されますので、適宜開発環境もupdateしていければと考えています。