Web●●○○○

バンドラ

Bundler ・ ばんどら

import の依存グラフをたどり、多数のモジュールを配信用の少数のファイルへ束ねて最適化するビルドツール。

✦ 30秒で試す — 航海通信が速くなっても、ファイルを1つにまとめ続ける理由束ねた配信用ファイルに入る、部品集の中身は?予想してみる

概要

バンドラは、開発時に小さなファイル(モジュール)へ分けて書かれた JavaScript や CSS を、import の依存関係をたどって一つの「依存グラフ」にまとめ、配信に適した少数のファイルへ束ね直す(bundle する)ビルドツールです。webpack・Rollup・esbuild・Rolldown、そしてそれらを内部で使う Vite などが代表で、モダンなフロントエンド開発では、意識しなくても必ずどこかで動いている裏方です。

役割は「つなげる」だけではありません。使われていないコードを削る(ツリーシェイキング)、変数名を短くし空白を消す(ミニファイ)、最初の画面に要らないコードを別ファイルに分けて必要なときに読み込ませる(コード分割)、ファイル名に内容のハッシュを付けて長くキャッシュさせる — 「開発しやすい形」から「配信に最適な形」への変換を一手に引き受けます。TypeScript や新しい構文を古い構文に書き換えるトランスパイルも、多くの場合このビルドの流れの中で一緒に実行されます。

これらの最適化がすべて可能なのは、バンドラがアプリ全体の依存グラフを手にしているからです。「どのファイルが、どのファイルの、どの関数を使っているか」を知っているので、要らないものを落とし、境目で切り分けられます。

なぜ生まれたか

バンドラ以前の Web 開発では、HTML に <script src="..."> タグを手で並べるのが唯一の方法でした。ファイル間の依存はタグの「並び順」でしか表せず、順番を間違えれば未定義エラーになります。すべての変数はグローバル空間を共有するため、ライブラリ同士が同名の変数を上書きし合う「グローバル汚染」も日常茶飯事でした。アプリが大きくなるほど、どのスクリプトが何に依存しているのか誰にも分からなくなっていきます。

答えは「モジュール」でした。ファイルごとにスコープを閉じ、依存を import / export(当時は require)で明示する方式です。JavaScript をサーバで動かす Node.js が 2009 年に CommonJS 形式のモジュールを広め、npm に大量のパッケージが集まると、ブラウザでも同じ書き方で同じパッケージを使いたいという要求が高まります。しかし当時のブラウザはモジュールを解釈できず、しかも HTTP/1.1 では1つの接続で同時に1つしかやり取りできないため、数百の小さなファイルを個別に取りに行くと非常に遅くなりました。

そこで「開発はモジュールで書き、配信前に依存グラフを解決して少数のファイルへ束ねる」という変換工程が生まれます。Browserify(2011年)が Node.js のモジュールをブラウザ向けに束ね、webpack(2012年)が CSS や画像まで同じグラフに載せて定番になりました。2015 年に言語仕様へ入った ES Modules(import / export)は、依存を実行前に静的に読める形で書かせるものだったので、Rollup(2015年)がこれを使って「使われていない export を落とす」ツリーシェイキングを広めます。束ねる工程は、やがてグラフ全体を最適化する工程へと育っていきました。

詳細

依存グラフを作って束ね直す

バンドラの仕事は、おおむね次の順に進みます。まずエントリポイント(main.js など起点のファイル)を読み、構文解析して import 文を抜き出します。import に書かれた './utils.js' や 'react' を実際のファイルの場所に解決し(node_modules を探す、拡張子を補う、package.json の exports を読む)、そのファイルをまた解析する — これを繰り返すと、アプリが使うすべてのファイルと、その間の import の線からなる依存グラフができあがります。CSS・画像・フォントも「モジュールの一種」としてこのグラフに載ります。

次に、グラフを見ながら出力を組み立てます。各モジュールのスコープが混ざらないよう名前を付け替えて1つのファイルに連結し(スコープの巻き上げ)、使われていない部分を落とし、ミニファイし、内容のハッシュを付けたファイル名(main.3f2a9c.js)で書き出します。ハッシュは中身が変われば変わるので、配信側は「このファイルは1年キャッシュしてよい」と宣言でき、更新したときだけ新しい名前のファイルが取りに行かれます。

開発時のソースmain.jsutils.jsapi.jsstyle.cssライブラリ群数百〜数千の小さなモジュールバンドラ依存グラフ解決ツリーシェイキングコード分割・ミニファイ配信用のバンドルmain.3f2a.jsreport.9c1e.jsstyle.b07d.cssハッシュ付きの名前で長くキャッシュできる少数の最適化済みファイル
バンドラの役割 — 依存グラフを解決し、多数のモジュールを少数の最適化されたファイルへ束ねる

ツリーシェイキングとコード分割 — グラフがあるからできること

ツリーシェイキングは、export されていても誰からも import されていないコードを出力から落とす仕組みです。グラフの辺には「どの名前を使っているか」(import { format } from './utils.js' なら format だけ)が載っているので、辺をすべて集めれば、各モジュールのどの export が生きているかが分かります。大きなグラフ描画ライブラリから折れ線グラフの関数だけを使うなら、円グラフやヒートマップのコードは配信されません。これが成り立つのは、ES Modules の import がファイルの先頭に静的に書かれ、実行しなくても読めるからです。require(変数) のように実行時まで行き先が分からない CommonJS では、同じことが確実にはできません。

コード分割は反対向きの最適化です。import('./report.js') のように関数として書く動的 import は「ここから先は、必要になったときに読めばよい」という印になり、バンドラはその先のモジュールを別ファイル(チャンク)に切り出します。SPA で画面ごとに分ければ、最初の画面を開くときに全画面分のコードを落とさずに済みます。ただし、分けた先と最初の画面の両方が使うモジュールは初回のファイルに残り、分けた先どうしだけで共有するモジュールは共通のチャンクにまとめられます。どこで切れるかも、グラフの形で決まるのです。

実際にグラフを組み替えてみてください。import の線を押すと「ふつうの import → 動的 import() → 外す」の順に切り替わり、モジュールの色(どのファイルに入るか)と、消える export、出力の大きさが変わります。

⚡ 体験: 依存グラフを組み替えて、束ね方を変える
ツリーシェイキング
main.jsエントリここからたどるui.jsrenderModal69router.jsstart3utils.jsformatdebouncedeepClone215home.jsHomePage5report.jsReportPage7chart.jsライブラリlineChartpieChartheatmap383555出力(KB)目標: 初回に読む量 < 40 KB初回に読むmain.2e13.js 81消えた 95 KB(点線)遅延チャンクなし(動的 import() がない)
初回のファイル遅延チャンク共有チャンク動的 import()出力から消える

線を押すと「import → 動的 import() → 外す」と切り替わります。アプリの画面(home・report)を残したまま、初回に読むサイズを 40 KB 未満にしてみてください。

ツリーシェイキングには落とし穴もあります。import しただけで何かが起きるモジュール(グローバルを書き換える、CSS を読み込むなど)は「副作用がある」ので、使われていなくても消せません。バンドラは安全側に倒して残すため、ライブラリは package.json の "sideEffects": false で「消してよい」と宣言します。また、たくさんの export を1か所から再 export するまとめファイル(いわゆる barrel ファイル)は、グラフを太くして解析と削除を難しくしがちです。「なぜこのライブラリが丸ごと入っているのか」は、バンドルの中身を面積で見せる可視化ツール(rollup-plugin-visualizer など)で確かめるのが定石です。

開発時の速さ — webpack から Vite へ

webpack は「あらゆるアセットをモジュールとして扱う」思想で長く標準の座にありましたが、弱点は開発時の速度でした。1行直すたびにグラフの一部を束ね直す方式は、プロジェクトが大きくなると起動に数十秒〜数分かかります。Vite(2020年)はここを発想から変えました。いまのブラウザは ES Modules をそのまま解釈できるので、開発中は自分のソースは束ねず、ブラウザが要求したファイルだけをその場で変換して返します。起動はほぼ一瞬で、変更の反映(HMR = ページを再読み込みせずにモジュールを差し替える仕組み)もファイル単位です。

ただし「開発時は一切束ねない」わけではありません。node_modules の依存パッケージは、CommonJS で書かれていたり、内部が数百ファイルに分かれていたりするので、そのままブラウザに渡すとリクエストの山になります。そこで Vite は開発サーバの起動時に依存パッケージだけを事前にバンドル(pre-bundling)し、ES Modules の形にそろえておきます。この事前バンドルは長く esbuild(Go 製)が、本番ビルドは Rollup が担っていましたが、2026 年 3 月の Vite 8 で両方とも Rust 製の Rolldown に一本化されました。開発と本番で別のバンドラを使うことによる挙動の食い違いをなくすのが狙いです。Next.js の Turbopack、webpack 互換の Rspack など、ほかの陣営も Rust で書き直したバンドラへ移っています。

ソースのファイル開発サーバ Viteブラウザソースのファイル開発サーバ Viteブラウザ起動時に依存パッケージだけを事前バンドルファイルを保存すると、そのモジュールだけ差し替える HMRGET /src/main.tsmain.ts を読むJS に変換して返す、import 先の URL を書き換え済みGET /src/router.tsGET /node_modules/.vite/deps/react.js変換した router.ts事前バンドル済みの react

HTTP/2 時代に束ねる意味

バンドラが生まれた理由の一つは HTTP/1.1 の制約でしたが、HTTP/2 では1本の接続で多数のファイルを並行して取得できるので、「リクエストの数を減らすために束ねる」理由は薄れました。それでもバンドルが消えないのは、理由が入れ替わったからです。ツリーシェイキングやミニファイはファイルをまたいで初めて効果を発揮しますし、束ねない ES Modules をそのまま配信すると、ブラウザは「main.js を読んで初めて router.js が要ると分かり、router.js を読んで初めて…」と、依存の深さの回数だけ往復を待つことになります(ウォーターフォール)。

つまり現代のバンドルは、接続の節約ではなく「配信するコード全体の最適化」のための工程です。束ねすぎれば1行の修正で巨大なファイルのキャッシュが全部無効になり、分けすぎれば往復と圧縮効率で損をします。変わりにくいライブラリと頻繁に変わるアプリのコードを別のチャンクに分ける、画面単位で動的 import を入れる — その切り方の設計が、バンドラを使いこなす実務の中心です。

試問

小さなアプリのモジュールの依存グラフです。main.js から始まり、2つの画面(home・report)と、report だけが使う重いグラフ描画ライブラリ chart.js があります。import の線を押すと「ふつうの import → 動的 import()(必要になったときに読む)→ 外す」の順に切り替わり、下に、最初の画面を開くときに読むファイルの大きさが出ます。

✦ 命題挑戦中

import を1本も外さずに、最初の画面を開くときに読むファイルを 30 KB 未満にせよ

  • import を1本も外さない(どの画面・機能も消さない)
  • 初回に読む量が 30 KB 未満(いま 81 KB)
⚡ 体験: 依存グラフを組み替えて、束ね方を変える
ツリーシェイキング
main.jsエントリここからたどるui.jsrenderModal69router.jsstart3utils.jsformatdebouncedeepClone215home.jsHomePage5report.jsReportPage7chart.jsライブラリlineChartpieChartheatmap383555出力(KB)目標: 初回に読む量 < 30 KB初回に読むmain.2e13.js 81消えた 95 KB(点線)遅延チャンクなし(動的 import() がない)
初回のファイル遅延チャンク共有チャンク動的 import()出力から消える

線を押すと「import → 動的 import() → 外す」と切り替わります。いまは全部が初回に読むファイル(金)に入っています。