esbuild + React(TS) で実現する超軽量な開発環境 

[自ブログ](https://blog.hedrall.work/posts/esbuild-dev-env-20211202)からの転載になります。

今回アドベントカレンダーに初めて参加いたします！

## 概要

最近私が 0=>1 から開発に取り組んでいるプロダクトのフロントエンドで `esbuild + React(TS)` を利用した開発環境を選定しており、タイトルが若干盛っていることはさておき、リリースから少し時間もたったので、一度振り返って知見が共有できればと思います。

尚、今回ご紹介する開発環境はサンプルレポジトリ [esbuild-dev-environment-sample](https://github.com/hedrall/esbuild-dev-environment-sample) を作成しましたので、適宜ご参照ください。

## esbuildとは?

この記事を見ている方はすでにご存知の通り、Webpackの100倍早いバンドラと謳われています。
その威力に関しては以前の [記事](https://qiita.com/hedrall/items/2548718cfdf7bef3efc0) に書きましたので、ご参考いただけると幸いです。

もしまだ `esbuild` を試したことがない方がいましたら、是非 [サンプルレポジトリ](https://github.com/hedrall/esbuild-dev-environment-sample) を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

```typescript
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. `esbuild`をwatchモードで起動

環境変数 `WATCH=true` の時に、差分ビルドされる様にビルド設定を書きます（前節の通り）。`esbuild`は差分ビルドも超高速なので、大抵の場合はコードの変更を検知して一瞬でビルドが完了します。

### 2. 生成物を`express`サーバでホスト

今回はpureなReact SPAであり、htmlファイルは`index.html`一つだけになる想定をしています。そのため、全てのアクセスは `/index.html` に飛ばす(rewriteする)必要があり、`http-server` などで単純にホストするだけでは要件を満たせません。
そこで、`express`を利用してローカルでホスティングをしてあげる事にしました。

`express`のコードは以下の通りです。

local-hosting.ts

```typescript
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` を利用して自動的にブラウザをリロードする様にしました。コマンドは以下の通りです。

```bash
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

```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`の機能があります。これにより、実行されない事が静的に確定するコードブロックを削除してくれますが、以下の通り注意をする必要があります。

```typescript
/** 
 * 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

```typescript
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していければと考えています。