esbuild + React(TS) で実現する超軽量な開発環境
自ブログからの引用です。
こんにちわ!
本記事は2021年度の「株式会社カケハシ x TypeScript」アドベントカレンダーの3日目の記事として執筆いたしました。
概要
最近 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
express
サーバでホスト
1. 生成物を環境変数 WATCH=turu
の時に、差分ビルドされる様にビルド設定を書きます(前節の通り)。esbuild
は差分ビルドも超高速なので、大抵の場合はコードの変更を検知して一瞬でビルドが完了します。
express
サーバでホスト
2. 生成物を今回は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です。
Browsersync
でブラウザを自動reload
3. コード変更時に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
以下では開発に当たって工夫した点やハマった点を整理したいと思います。
URLに状態を反映する
今回の仕組みではコードの変更時に自動でブラウザのリロードが走りますが、毎回初期状態に飛ばされる仕様だと開発が手間です。そのため、検索条件やモーダルの開閉やなどの主要なglobal stateはURLのクエリストリングなどと同期する仕様が望ましいです。
(その方が様々な状態への直リンクを提供できる点でプロダクトの使い勝手もよくなると思われます。)
TSの即時実行に関して
折角なので、esbuild
を実行するbuild.ts
や、express
を実行するlocal-hosting.ts
もTypeScript
ファイルで書きたいところです。ただ、毎度ビルドするのは面倒なので、TypeScriptを直接実行するツールがあると便利です。古くから利用されている物であれば、ts-node
がありますが、実行毎のトランスパイルで少し待たされます。
そこで、今回はトランスパイル作業をesbuild
で行ってくれる esbuild-register
を利用しました。これにより、個人的には気にならないくらいの速度でTSを直接実行する事ができました。
CSSの対応
esbuildはコード上でimportされているCSSファイルを全て一つのファイルにまとめて出力してくれますが、scss
の変換や、 css modules
など特殊なローダが必要になる様な機能を実装していません。いずれも事前にトランスパイルするとか、自作プラグインを作成するなどして対応も可能な様ですが、すでにだいぶ複雑な環境になってきた事もあり、ひとまず普通の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
機能があります。これにより、実行されない事が静的に確定するコードブロックを削除してくれますが、以下の通り注意をする必要があります。
/* 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モックは個人情報っぽい顧客のダミーデータが記載されている状態でした。なんとなく気持ち悪いのと、容量的にも大きいのでこの点を注意しました。moduleがbundleされているかどうか
は上記の meta.json
で確認できます。
Jestに関して
設定ファイルを以下の通りしました。TSのトランスパイル(jestの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していければと考えています。
Discussion