Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

esbuild-loader 試してみたら開発ビルドが 2〜3 倍速くなった話

こんにちは。Eight でエンジニアをしている鳥山(@pvcresin)です。
散歩が趣味なので、暖かくなってきて嬉しい今日このごろです。
さて今回は Web フロントエンドのビルド時間短縮のため、esbuild-loader を導入した話をしたいと思います。

背景

ビルドにかかる時間はプロダクトが大きくなるにつれてじわじわ伸びていき、開発者体験(DX)を悪くする原因となるため、常に短縮する方法を考える必要があります。
Eight の Web フロントエンドは主に TypeScript・React で開発を行っており、ビルドには webpack を使用していますが、 ビルド時間の大部分はこの TypeScript や React の JSX 記法を JavaScript に変換する処理の時間です。
そこで、この処理を代替することのできるより高速なツールを使うことでビルド時間を短縮しようと考えました。 近年、高速な動作を売りにしたビルドツールが台頭してきていますが、ビルドツール自体を webpack から変更することはなかなか骨が折れることが予想されたため、webpack の loader の形で導入できるものを検討していきました。

主な代替ツール

Eight では TypeScript のコンパイルに TypeScript Compiler + ts-loader を、JSX から JavaScript へのトランスパイルに Babel + babel-loader を使っています。
これを代替するため、以下の方法を検討しました。

  1. esbuild + esbuild-loader (← 採用したもの)
  2. SWC + swc-loader
  3. Sucrase + sucrase-loader

1. esbuild + esbuild-loader

esbuild は高速に動作する Go 製のバンドラーです。 並列処理の多用やメモリの効率的な使用によって高速な動作を可能にしています。 次世代ビルドツールの ViteSnowpack でも利用されており、知名度は高まってきている印象です。 esbuild-loader を使うことで esbuild の内部のコンパイラや Minifier 部分を webpack の処理に組み込むことができます。

2. SWC + swc-loader

SWC は高速に動作する Rust 製のコンパイラです。 条件によっては esbuild よりも速いと公式サイトで謳っています。 Minifier やバンドラーとしての機能も開発中のようです。 swc-loader を使うことで、 SWC によるコンパイルを webpack の処理に組み込むことができます。 Next.js においても導入が始まっており、近年 SWC も注目されてきています。1

3. Sucrase + Sucrase Webpack loader

Sucrase は高速に動作する TypeScript 製のコンパイラです。 出力したコードがモダンブラウザや Node.js の環境で動く前提で、JSX や TypeScript などのコンパイルにフォーカスしています。 パーサは Babel から Fork したものを使用しており、ユースケースを絞って拡張性をある程度犠牲にしている分、高速に動作するようです。 README では esbuild や SWC よりも速いと謳っています。 Sucrase Webpack loader を使うことで、 Sucrase によるコンパイルを webpack の処理に組み込むことができます。

(おまけ) TypeScript Compiler + ts-loader

おまけ程度に TypeScript Compiler についても触れておきます。 TypeScript Compiler は TypeScript に内蔵されているコンパイラで、この中で唯一コンパイル時に型チェックを行うことができます。 また、tsconfig.json で設定を行うことで ES5 のコードを出力するようにしたり、 JSX を通常の JavaScript に変換したりといったことが可能です。
JavaScript をコンパイルに含めることも可能なため、単に JavaScript を ESNext から ES5 などに変換するツールとしても利用できます。 シンプルな変換処理が目的であればこれだけでも十分な可能性がありますが、処理速度の面では他と比較するとあまり速くないようです。

採用したもの

今回は esbuild + esbuild-loader の組み合わせを試すことにしました。
esbuild や esbuild-loader は国内でも試験的に導入する事例が増えてきており、loader の設定もシンプルだったため、導入で躓くことが少ないのではないかと考えました。
本番ビルドで使うには機能が足りない印象でしたが、開発ビルド目的であれば問題ないと感じました。 処理速度に関してはどのツールも自分が速い!と謳っていて、条件によっても結果が変わりそうなので、色々試してみるしかないと思います。

ちなみに、開発ビルドと本番ビルドで出力されるコードが変わってしまうといった点については賛否両論あると思いますが、Eight では以下のように結論付けました。

  • 変換処理を行うツールを置き換えることによって、出力されるコードがどのように変わるかわからないといった怖さはある
  • 一方で、既に本番ビルド時にコードの難読化や Minify など各種最適化が行われているため、 開発ビルドと本番ビルドで出力されるコードが異なっているという状況自体は変わっていない
  • また、開発が終わったタイミングで必ず検証環境に本番ビルドしたものをデプロイして自動 E2E テストや手動 QA を行っているため、何か問題があればそこで気付ける

これを合意した上で作業を進めていきます。

現状把握と方針検討

まずは現状の処理や使っているツールの把握を行います。
JavaScript・TypeScript ファイルは以下の流れで変換されていました。

  • JS(ESNext)→ babel-loader → JS(ES5)
  • TS(ESNext)→ ts-loader → JS(ES2015)→ babel-loader → JS(ES5)

JavaScript ファイル(.js, .jsx)は、babel-loader を通して ES5 のコードを出力しています。 TypeScript ファイル(.ts, .tsx)は、その前段に ts-loader で ES2015 に変換する処理が入っています。 ちなみに ts-loader は transpileOnly: true で使用しており、Fork TS Checker Webpack Plugin と併用しています。

開発ビルドの際は、上記の設定を以下のシンプルな形に変えていきます。

  • JS(ESNext)→ esbuild-loader → JS(ES2015)
  • TS(ESNext)→ esbuild-loader → JS(ES2015)

esbuild は ES2015 以降の JavaScript しか出力できないため、最後の部分は ES2015 になっています。 開発時には基本的に Chrome を使っているため、動作に問題はないと考えました。 また、esbuild は型チェックを行ってくれないため、Fork TS Checker Webpack Plugin も変わらず使用します。

導入時点では、開発用ビルド時に esbuild を有効にする環境変数を渡したときにだけ esbuild-loader を使うことにしました。 これであれば、もし何か問題が起きても他のメンバーには影響なく導入ができます。

作業手順

tsconfig.json の設定を変更

esbuild で TypeScript をコンパイルする場合、esModuleInteropisolatedModules を true に設定する必要があります。
esModuleInterop: true によって、TypeScript の型システムが ES Modules と互換性をもつようになり、CommonJS などのモジュールとの相互運用性が高まります。 Eight では TypeScript 導入時に既に true に設定されていました。
また、esbuild は各ファイルを別々にコンパイルするため、型の export 方法などを制限するために isolatedModules: true を設定します。 こちらは設定されていなかったので追加しました。 export 周りでコードの書き換えが必要になるかと危惧していましたが、特に作業は発生しませんでした。

Babel plugin への依存をなくす

Babel には便利な plugin が豊富にあり、Eight でもいくつか使用しています。 しかし、esbuild-loader はシンプルな TypeScript や JSX の変換にしか対応していないため、 これらを利用することができません。
変換の大部分を esbuild-loader に載せ替えたとしても、Babel の plugin に依存している場合は結局 babel-loader で一部の処理を行う必要があり、あまりビルド時間が短縮できないのではないかという懸念がありました。 そこで Babel の plugin への依存を減らしていくことにしました。

babel-plugin-react-remove-properties

babel-plugin-react-remove-properties は指定したプロパティを自動で削除することができます。 元々 Eight では Selenium を使った E2E テストを自前で行っており、要素を見つけやすいように専用の data 属性(data-testid='xxx'など)を付与していました。 そして、これは本番環境では必要ない情報のため、plugin で取り除いていました。
しかし、少し前にメンテナンスコストの観点で Selenium を使ったテストから mabl というローコードツールに乗り換えてテストケースを作り直したことで、data 属性が使われなくなりました。 そのため、削除処理自体が必要なくなり、data 属性と plugin を削除しました。

babel-plugin-module-resolver

babel-plugin-module-resolver は path alias の設定を行うことで、 ../../components/@components/ のように指定できるようになる plugin です。 こちらは同様の機能を持つ、webpack の resolve.alias に移行しました。

@babel/preset-env

@babel/preset-env はコードを target のブラウザで動く構文に変換してくれる preset です。 core-js と一緒に使うことで必要な Polyfill2 を設定することもできます。 Eight では本番環境でのレガシーブラウザ対応に使っています。
開発時には基本的に Chrome を使っており、@babel/preset-env がなくとも現状の実装のままで動くことが確認できたため、特に何か代わりの仕組みを用意することはしませんでした。

esbuild-loader の導入

では実際に esbuild + esbuild-loader を追加していきます。
yarn add -D esbuild esbuild-loader を行った後、以下のように開発ビルド時の処理を分岐させるように webpack.config.js を修正します。

// webpack.config.js のイメージ
{
  test: /\.jsx?$/,
  exclude: /node_modules/,
  use: ENABLE_ESBUILD
    ? [
        {
          loader: 'esbuild-loader',
          options: { loader: 'jsx', target: 'es2015', sourcemap: isDev },
        },
      ],
    : [
        {
          loader: 'babel-loader',
          options: { cacheDirectory: isDev },
        },
      ],
},
{
  test: /\.tsx?$/,
  exclude: /node_modules/,
  use: ENABLE_ESBUILD
    ? [
        {
          loader: 'esbuild-loader',
          options: { loader: 'tsx', target: 'es2015', sourcemap: isDev },
        },
      ],
    : [
        {
          loader: 'babel-loader',
          options: { cacheDirectory: isDev },
        },
        {
          loader: 'ts-loader',
          options: { transpileOnly: true },
        }
      ],
},

最後に開発ビルドが問題なく動くことを確認して作業終了です。

結果

効果があるのか確認するため、ローカルでの開発ビルドにかかる時間を比較します。
babel-loader には cache 機構があるため、cache 無し・有り・esbuild-loader を使った場合の 3 種類を見ていきます。 手元のマシン3を使ってビルド時間(5 回平均)を測定しました。

ビルド時間 (秒)
babel-loader(cache 無し)+ ts-loader 89.5
babel-loader(cache 有り)+ ts-loader 60.1
esbuild-loader 29.2

結果、開発ビルドは約 2~3 倍速くなりました 🎉
チームメンバーからは「ビルド待ちのコーヒーブレイクがブレイクされました」とお褒めの(?)言葉をいただきました。

まとめ

今回は、Web フロントエンドの開発ビルドに esbuild-loader を導入した結果、ビルドが 2~3 倍速くなった話をしました。
導入から既に数ヶ月経ち、数人のメンバーで使用してみていますが、現状特に問題なく爆速で開発できています。
これからも他の loader やビルドツールを検証するなど、ビルド時間の短縮について考えていきたいと思います。


  1. Next.js の 11.1 系統から SWC 導入のアナウンス https://nextjs.org/blog/next-11-1#adopting-rust-based-swc

  2. 古いブラウザ向けに新しい機能を模倣したもの。https://developer.mozilla.org/ja/docs/Glossary/Polyfill

  3. MacBook Pro (15-inch, 2018), 2.9 GHz 6-Core Intel Core i9, RAM 32 GB

© Sansan, Inc.