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系では) PreactGatsby の様な軽量な環境が流行っている事実があります。SSR対応の必要等があれば依然としてフレームワークの恩恵は大きいですが、Jamstack の様なツール依存の少ない環境ではより軽量な開発環境も好まれます。ただやはり、ビルドに関してのボトルネックは Webpack である事が多くここを改善するのが最も効果的だと思われます。

そのため、ビルド自体を高速化でき、既に多くの人気を得ている esbuild を利用したいと考えました。 考慮点としては、 NextGatsby などには対応していないため、ツールに依存しない開発を行うか、 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段階の設定を行いました。

  1. esbuildをwatchモードで起動
  2. 生成物をexpressサーバでホスト
  3. コード変更時にBrowsersyncでブラウザを自動reload

1. 生成物をexpressサーバでホスト

環境変数 WATCH=turu の時に、差分ビルドされる様にビルド設定を書きます(前節の通り)。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

以下では開発に当たって工夫した点やハマった点を整理したいと思います。

URLに状態を反映する

今回の仕組みではコードの変更時に自動でブラウザのリロードが走りますが、毎回初期状態に飛ばされる仕様だと開発が手間です。そのため、検索条件やモーダルの開閉やなどの主要なglobal stateはURLのクエリストリングなどと同期する仕様が望ましいです。 (その方が様々な状態への直リンクを提供できる点でプロダクトの使い勝手もよくなると思われます。)

TSの即時実行に関して

折角なので、esbuildを実行するbuild.tsや、expressを実行するlocal-hosting.tsTypeScriptファイルで書きたいところです。ただ、毎度ビルドするのは面倒なので、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 shakingdead 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していければと考えています。