
はじめに
この記事はBASEアドベントカレンダー2025の16日目の記事です。
こんにちは。Pay ID プラットフォーム Group で エンジニアをしている noji です。最近は Pay ID の認証基盤のフロントエンド開発を担当しています。
本記事では BASE のショップや Pay ID アプリでの買い物時にカートでの Pay ID ログイン機能を提供している JavaScript(以後 payid-js)のビルド環境を webpack/Babel から esbuild に移行した話を紹介します。
payid-js について
payid-js は Pay ID ログイン機能を提供している埋め込み用の JavaScript です。Pay ID ログインすることで、Pay ID に登録されている住所情報や決済手段情報を連携することで、ユーザーはスムーズに購入手続きを進めることができます。

BASE のカートのフロントエンドで payid-js を読み込み、用意された関数を呼び出すと画面上にログイン用の画面が iframe 上に表示され、Pay ID にログインできます。iframe 内でログイン処理を行い、結果を postMessage API を使ってカートのフロントエンドに通知します。
payid-js は iframe 内外でやり取りを行うインターフェースを提供しており、iframe 内でログインが完了すると、結果をカートのフロントエンドに返すようになっています。(iframe の内側の画面については別システム)
技術としては、TypeScript で実装されており、ビルドには webpack と Babel を使用していました。
移行背景
payid-js は BASE のカートと iframe で表示される Pay ID ログイン画面の橋渡しをするだけのコンポーネントなので、軽量な JavaScript です。 軽量であるので、webpack のビルドに時間がかかるとは感じていませんでしたが
- webpack 時代のバージョンアップや設定変更が大変
- Babel を含む関連ライブラリの設定が複雑
- 依存関係の脆弱性が多い
- payid-js には webpack ほど高度な機能が不要である
などの課題があり、よりシンプルなビルドツールへの移行を検討しました。
esbuild を選んだ理由
候補として esbuild、vite、Rollup などがありましたが、最終的に esbuild を選択しました。理由は以下です。
- シンプルで高速なビルドが可能 Go 製でビルドが非常に速いのに加えて、設定もシンプルでわかりやすく、TypeScriptのトランスパイルも内蔵されていてBabelも不要
- 依存関係が少なく、メンテナンスコストや脆弱性リスクが低い webpack や Babel に比べて関連ライブラリ等の依存関係が少なく、アップデートや脆弱性の対応に追われる負荷が軽減されそう
- 他ツールとの比較
- vite:SPA 向けの開発サーバーは強力だけど、payid-js のような埋め込み用 JavaScript にはオーバースペック
- Rollup:esbuild ほど高速ではなく、設定もやや複雑になる。ライブラリ向けには良いが、今回は見送り
esbuild は HMR(Hot Module Replacement) をサポートしていないですが、payid-js は埋め込み用の JavaScript であり、開発時に HMR は必要ないため問題ありませんでした。
参考
移行で詰まったポイント
ローカルの 開発サーバーの構築
今までは webpack-dev-server を使用してローカル開発環境を構築していました。
webpack-dev-server はビルドしたアセットをメモリに保持し、変更があれば自動で配信内容を更新してくれる開発サーバーを内蔵しています。
Docker からのアクセスでも常に最新が返ってくるため、ビルド・配信・更新反映をひとまとめに解決してくれる優れた仕組みでした。
一方、esbuildにはwebpack-dev-serverのような開発サーバーは内蔵されておらず、あくまで”ビルド”のみの機能です。今回は serve で簡易的に http-server を立ち上げるスクリプトを用意しました。watch だけだと変更を検知して Docker コンテナに反映させることができなかったので、 chokidar も利用し、変更を検知して明示的に再ビルドできるようにしました。
#!/usr/bin/env node import path from "path"; import { fileURLToPath } from "url"; import * as esbuild from "esbuild"; import { spawn } from "child_process"; import chokidar from "chokidar"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const outdir = path.resolve(__dirname, "dist"); // esbuild の watch 用コンテキストを作成 const ctx = await esbuild.context({ entryPoints: [path.resolve(__dirname, "src", "index.ts")], bundle: true, sourcemap: true, platform: "browser", outdir, entryNames: "bundle", minify: true, loader: { ".html": "text", }, }); await ctx.watch(); console.log("esbuild: watching", outdir); // chokidar でファイル変更を監視して rebuild const watcher = chokidar.watch([path.resolve(__dirname, "src")], { ignoreInitial: true, usePolling: true, interval: 100, }); let rebuilding = false; async function scheduleRebuild(event, filePath) { if (rebuilding) return; rebuilding = true; console.log(`change detected (${event}):`, filePath); try { await ctx.rebuild(); console.log("esbuild: rebuild complete"); } finally { setTimeout(() => (rebuilding = false), 50); } } watcher.on("add", (p) => scheduleRebuild("add", p)); watcher.on("change", (p) => scheduleRebuild("change", p)); watcher.on("unlink", (p) => scheduleRebuild("unlink", p)); // ローカルサーバー (npx serve) spawn("npx", ["serve", "-s", outdir, "-l", "9000"], { stdio: "inherit", shell: true, });
このスクリプトを実行すると、chokidar がソースコードの変更を監視し、変更があった場合に再ビルドを行います。また、npx serve を使用してローカルサーバーを立ち上げ、ブラウザから埋め込み用 JavaScript を確認できるようにしています。
ビルドの成果物の違い
基本的に成果物はほぼ同じでしたが、loaderの指定によりHTML の import 部分で差異がありました。
- webpack: HTML モジュールをオブジェクトとして扱う
- esbuild: HTML モジュールを文字列として扱う
そのため後々の移行の手順にもあるように、一定期間同じコードベースで webpack/esbuild の両方をビルドする必要があったため、どちらのビルド方法でも動作するように、下記のようなユーティリティ関数を追加しました。
const rawModule = require("./container.html"); const html = getHtmlStringFromModule(rawModule); // `*.html` をバンドルする方法はバンドラによって異なります。 // - esbuild や rollup の一部設定では、インポートはそのまま文字列になります。 // - もしくは `{ default: string }` のようなオブジェクトを返す場合もあります。 // ここで形を正規化することで常に文字列として扱えるようにします。 const getHtmlStringFromModule = (mod: unknown): string => { if (typeof mod === "string") { return mod; } if (typeof mod === "object" && mod !== null) { const maybeDefault = (mod as Record<string, unknown>).default; if (typeof maybeDefault === "string") { return maybeDefault; } } throw new Error("unexpected HTML module shape"); };
ビルド用の設定
#!/usr/bin/env node import path from "path"; import { fileURLToPath } from "url"; import { build } from "esbuild"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const outdir = path.resolve(__dirname, "dist"); const outfile = path.join(outdir, "bundle.js"); // 本番ビルド await build({ entryPoints: [path.resolve(__dirname, "src", "index.ts")], bundle: true, sourcemap: true, platform: "browser", outfile, minify: true, define: { API_BASE_URL: JSON.stringify(process.env.API_BASE_URL || ""), }, loader: { ".html": "text", }, logLevel: "info", }); console.log("esbuild: built", outfile);
ビルド用のスクリプトも非常にシンプルです。esbuild の build 関数を使用して、エントリーポイントや出力先、バンドル設定などを指定しています。 ビルドされたファイルを CircleCI のジョブで S3 にアップロードし、CDN 経由で配信する仕組みは以前と同様に維持しています。
移行の手順
移行は段階的に行いました。
- ローカル/dev 環境のみ esbuild に切り替え
- stg/本番も esbuild に切り替え
- webpack/Babel 関連の設定・依存関係を削除
結果として、問題なく移行でき、切り替えによる影響もありませんでした。
移行結果
元々軽量な JavaScript であったため、ビルド時間の劇的な改善はありませんでしたが、設定が大幅にシンプルになり、依存関係の脆弱性も出にくくなりました。 元々が CircleCI 上で 2 ~3秒程度のビルド時間でしたが、esbuild に移行したことで 1 秒未満に短縮されました!!
おわりに
payid-js のビルドを webpack/Babel から esbuild に移行したことで、設定のシンプル化と依存関係の削減が実現できました。
今後も payid-js の開発を続けていく中で、さらなる改善点が見つかれば積極的に取り組んでいきたいと思います。
BASE / Pay IDではエンジニアを募集しているので、興味ある方は以下からご連絡ください。
明日のBASEアドベントカレンダーはIzuharaさんの記事です。お楽しみに。