LIFULL Creators Blog

LIFULL Creators Blogとは、株式会社LIFULLの社員が記事を共有するブログです。自分の役立つ経験や知識を広めることで世界をもっとFULLにしていきます。

esbuild で開発環境の JS をビルドをしたら 55 倍(220sec->4sec)高速になった件について解説させてください

テクノロジー本部の相馬です。好きな Web API は Window.requestAnimationFrame() です。

私が現在所属しているグループでは、弊社のメイン事業である LIFULL HOME'S における開発効率の改善などを行っています。

私はフロントエンドの開発環境の改善などを主に担当しております。

今回は、LIFULL HOME'S の JavaScript 開発環境のビルドを、esbuild を使ってビルドしたところ、当社比で 55 倍高速になった事例について紹介いたします。

目次

サマリ

  • 本改修以前、バンドラー には Rollup を使い、transpiler は IE11 むけに設定
  • 本改修時点で 401 本のバンドルファイル を作成(歴史的経緯)
  • 本改修以前、npm run dev コマンドから全てのバンドルの warmup が終わるまで 220sec(m4.4xlarge)
    • 使用メモリ 3.7GB/process
  • 本改修以後、npm run dev コマンドから esbuild で全てのバンドルの warmup が終わるまで 4sec(m4.4xlarge)
    • 使用メモリ 685MB/process

Before/After

Before After
バンドラー Rollup esbuild
time 220 sec 🐢 4 sec 🚀
memory 3.7 GB 685 MB

はじめに

LIFULL HOME'S は弊社でもっとも開発が盛んなアプリケーション(Repository)です。

Commit 数や Contributor 数は弊社でもっとも多く、また開発の歴史も比較的に長い(10 年)部類に入ります。

これまでも「フロントエンドの開発環境の改善」という文脈で、何度か記事を書いているので、お時間ある際に読んでいただけると喜びます、私が。

9 年を超えて開発が続く LIFULL HOME'S の Web フロントエンド開発環境の改善 - LIFULL Creators Blog

Node.js で Twig のプリプロセッサーを作って言語の機能拡張をしてみた話 - LIFULL Creators Blog

従来の開発環境について

以前の記事で解説させていただいた「近代化」により、フロントエンドにもそれなりにモダンな開発環境が整いました。

近代化を行って全てがハッピーエンドだったかというとそうではなく、新しいモノを導入することで部分的に不都合が生じている箇所もありました。

  • 開発環境のインスタンススペック不十分問題
  • npm run dev 遅すぎる問題

開発環境のインスタンススペック不十分問題

現在 LIFULL HOME'S の開発環境は「一つの開発サーバに個人の開発マシンから ssh して開発する」という方式をとっているため、高価な処理を行うようなプロセスが開発環境で必要な場合、開発マシン上では「ログインユーザ(開発中のユーザ)*高価な処理」だけ該当マシン上でリソースが必要になってしまいます。

Rollup を導入することで、JS の依存関係が明確になり様々なツールの利用が可能になった反面、当然バンドルや minify といった比較的に高価な操作が必要になってしまいました。 それまでのインスタンスタイプでは不十分で、インスタンスタイプの変更を余儀なくされたことが記憶にある限りで 2,3 回あります。

npm run dev 遅すぎる問題

それまでは歴史的経緯により 401 本のバンドルファイルを直列で作成 していたため、220sec(m4.4xlarge) ほど warmup に時間がかかっており、開発時の問題の種となっていました。

JavaScript(Node.js) はご存知の通りシングルスレッドの実行モデルであるため、こういった AST をトラバースしたり文字列の置換など、高価な計算処理を苦手とします。

The Node.js Event Lvop, Timers, and process.nextTick() | Node.js

Node.js だけで高価な計算を処理したい場合、jest-worker などで worker-thread を駆使してパラレルで実行することで実時間の短縮などが可能です。 Rollup にも JS API が存在しているので、やろうと思えばできそうですが、シンプルにそこまでやっていませんでした。

そんなことを思っている中、JS バンドル界を揺るがす期待の新星として esbuild が颯爽と登場しました。 初期から動向を見守り続け、プロダクションでいけそうなことを目視で確認したので、esbuild にトライしてみようと奮い立ちました。

esbuild とは?

Figma の CTO である Evan 氏が作成した、Go 製の JS バンドラーです。JS といっていますが、デフォルト(特に設定不要)で TypeScript のトランスパイル/バンドルも、tsconfig.json をよしなに解釈し、実行してくれます。

esbuild - An extremely fast JavaScript bundler

An extremely fast JavaScript bundler

↑ と評されている通り、既存の JS バンドラー(webpack/rollup/parcel)と比べてベンチマークのスコアで 10-100 倍ほど高速です。

Snowpack や Vite といったモダンなバンドラーにも採用されているバンドラーで、非常に高速であることで知られています。

ネタバレ(というかすでに上でも書いてるのでバレか怪しいですが)になりますが、当社比でも Rollup から esbuild への移行で 55 倍程度高速 になりました。

なぜこんな速度が出せているのかというと、簡単にまとめると並行処理とメモリ効率が高度に配慮された設計でフルスクラッチで記述されたライブラリだからという感じです。

例えば、JS のバンドラーはコードの parser/generator は 3rd party のライブラリに依存しており、また transpiler や minifier もそれぞれ別のライブラリがプラグインとして機能を追加で提供している形が多いと思います。

それぞれのライブラリはそれぞれの主たる目的があり、全てのライブラリがパフォーマンスを最優先としているわけではありません。

esbuild は read/parse/traverse/compile/minify/generate 全ての機能を備えているため、ライブラリ間の I/O 調整のためだけな不要なデータ構造のコピーや多重に AST を捜査するオーバーヘッドなどを極力抑えることで、その圧倒的なパフォーマンスを実現しています。

詳しく知りたい方は下記リンク先の記事をご覧ください。より詳細をご確認いただけます。

Why is esbuild fast? - esbuild FAQ

また、esbuild は ES6(ES2015) 以降のコードしか生成できないという特徴があります。モダンな構文のみをサポートすることで、速度を保っているという側面もあります。よって IE 対応などが必須な場合 esbuild は利用できません。(バンドル後のコードを再度 Babel にかけるなどの対応は可能ですが、esbuild 単体での機能完遂はできないという意味です。)

Lowering for ES5? · Issue #297 · evanw/esbuild

LIFULL HOME'S では依然として IE11 をサポートしています。ですが開発環境では、開発の時は開発者はみんなモダンなブラウザを使っているはずだということを担保に、とりあえず開発環境にだけ入れてみようということで、作業に取り掛かることにしました。

開発中大変だったところ/ちょっとハックしたところ

esbuild に当時 watch option がなかった

作業を開始したタイミングでは、esbuild はバンドラーとしてはそこまで多くの機能を持っていませんでした。CLI や JS API からワンショットでバンドルできるだけでもありがたかったのですが、ファイルの変更差分を監視し、変更があったタイミングで再度 build が走る、いわゆる watch モードが存在しなかったので、この辺は chokidar などで watch モードをラップするような処理を書いて満足して PR を温めていたところ、公式から watch オプションが登場したのでこのコードは日の目を見ることがなくて安堵しています。

トップレベル this のスコープ問題

esbuild はその名の通り ESM(ECMAScript Module) 向けのモジュールバンドラーであるため、context 関連で patch をあてる必要がありました。

ESM ではトップレベル(global context)の thisundefined と解釈されます。 そのため、esbuild も当然同じ振る舞いをします。

Bug: global this is optimized to void 0, which will lead to runtime errors. · Issue #1225 · evanw/esbuild

しかし、古くから動いてる我々のコードは ESM を前提としておらず、一部のファイルでは以下の例のようにトップレベルの thiswindow であることを前提にしたコードが含まれていることがわかりました。

(function (win) {
  // do something
})(this);

こういったコードが軒並み undefined になってしまい、そのままでは動かないコードが多くありました。

そこで トップレベルの context を window で bind する関数を injection するプラグイン を書きました。

esbuild - Plugins

プラグインのコードの肝となる箇所を一部抜粋しますが、こんな感じで必要なファイルに対して

const banner = "(function () { ";
const footer = "\n}).bind(window)();";

// ...........
// なんやかんやあって
// ...........

build.onLoad({ filter: /.*/ }, async (args) => {
  const path = args.path;

  const fileBody = await fsp.readFile(path, "utf-8");
  if (!needWindowCtxWrap(fileBody, path)) {
    return {
      contents: fileBody,
      loader: "js",
    };
  }
  const contents = `${banner}${fileBody}${footer}`;
  return {
    contents,
    loader: "js",
  };
});

このプラグインを挟むことで、先程のコードは

(function () {
  (function (win) {
    // do something
  })(this);
}.bind(window)());

となり、これによって「プラグイン 適応前にトップレベルであった context」は window であることが確定します。これによって暗黙(?)の変換は行われず、期待通り window を解釈できるようになりました。

また、一応 esbuild にも Rollup でいうところの context オプション のような --define というオプションがあり、これによって this の値を書き換えられそうなのですが、同時は thiswindow を指定することはできませんでした。

これに関して、issue で起票しており、現在は window の指定ができるようになっているそうですが、こちらは指定せず上記のプラグインを引き続き利用しています。

[feat] optional global context setting in ESM #1361

UMD ライクで実行環境毎に global object を判断するライブラリのコードが壊れる

LIFULL HOME'S のコード群の中のライブラリには、歴史的な理由で npm からではなく内部的にソースコードを抱えているものもいくつか存在しており、その中には UMD ライクな形式の宣言が多くありました。

UMD は、実行環境によってモジュールをどのようにエクスポートするかをユニーバサルに決定する機構です。 決定ロジックに exports 変数の存在確認が含まれており、もし存在する場合は Node 環境だと解釈されてしまいます。 私たちの扱いたいコードはブラウザ向けのコードだったのですが、esbuild でバンドルする際に挿入されるコードに exports 変数が含まれているため、誤って解釈されるという問題が発生しました。

これについても プラグイン によって解消済みで、dummy の引数をトップレベル IIFE に対して付与することで擬似的に全スコープにおいてそれらの変数が undefined となるべく(つまり window が正しく判断されるべく)対応しました。

const banner = "(function (module, exports, require) { ";
const footer = "\n}).bind(window)();";

このように exports の変数が存在する場合、esbuild としては exports オブジェクトを injection することができず、名前の衝突を避けることができます。

LIFULL HOME'S のコードは全てブラウザでの実行が期待されているため、このように別環境で悪さをしそうな変数は軒並み強制的に全スコープで undefined とすることでことなきを得ています。

おわりに

速度とパフォーマンスは圧倒的に正義であることを esbuild で再認識させてもらいました。

esbuild は プラグイン の拡張性も高く、Rollup など既存のバンドラーで複雑なビルドを組んだ経験があり、バンドラーの特性や特有の挙動などに知見がある場合、ドキュメントを読めばこちらも難なく利用できると思います。

幸い、LIFULL HOME'S の既存の JS ビルドはそこまで複雑ではなかったので、今回は比較的シンプルに収まりましたが、プロジェクトによってはプラグインなどいくつか用意する必要があるかもしれませんが、その労力に見合うだけの見返りが得られると思いますので、esbuild、おすすめです!

最後に、LIFULL では一緒に働く仲間を募集しています。よろしければこちらも合わせてご覧ください。

【エンジニア】募集求人一覧 | 株式会社 LIFULL

【エンジニア】カジュアル面談 | 株式会社 LIFULL