ツリーシェイキング
バンドル時に未使用のコードを除去し、出力サイズを削減する最適化手法
ツリーシェイキングとは
ツリーシェイキング (Tree Shaking) は、バンドラー がビルド時に到達しないコードを出力から取り除き、配信サイズを削減する最適化である。他の言語処理系でデッドコード除去と呼ばれてきた処理と同じもので、webpack の公式ドキュメントも「JavaScript の文脈でデッドコード除去を指して使われる用語」と説明し、この名称と概念を広めたのは ES2015 モジュール対応のバンドラーである Rollup だとしている。
前提になるのは ES Modules の静的構造である。import と export はモジュールのトップレベルで宣言され、実行前に依存関係が確定するため、どの export がどこからも参照されていないかをコードを動かさずに判定できる。
// utils.ts
export function add(a: number, b: number) { return a + b; }
export function multiply(a: number, b: number) { return a * b; }
// main.ts
import { add } from './utils';
console.log(add(1, 2));
// バンドル結果に multiply は残らない
除去は 2 段階で起きる
「バンドラーが未使用コードを消す機能」と 1 つの処理として捉えると、出力を見たときに食い違う。webpack が担うのは未使用の export を検出して印を付けるところまでで、development モードの出力にはその印がコメントとして残るだけで関数の本体は残っている。実際に削るのは production モードで動くミニファイア (Terser) である。効いていないと判断する前に、production ビルドで測っているかを確認する。
esbuild は宣言レベルのデッドコード除去を自前で持つが、既定で有効なのはバンドル時と出力形式が iife のときだけで、それ以外では --tree-shaking=true を明示しないと働かない。
ESM と CommonJS で結果が変わる理由
| モジュール形式 | 未使用 export の除去 | 静的に判定できるか |
|---|---|---|
ESM (import / export) | 効く | 依存関係が実行前に確定する |
CommonJS (require) | 効かない | どのプロパティが使われるかは実行時に決まる |
CommonJS が効かない理由は「動的だから」で済まされがちだが、機序はプロパティ単位の到達可能性が静的に決まらない点にある。exports.d = 2 は実行時に評価される代入式であり、require() の戻り値がその後どう使われるかを追い切れないため、バンドラーはモジュール本体を関数で包んだまま丸ごと残す。実際に esbuild でバンドルすると、参照していない代入行もトップレベルの副作用も出力に残る。
// lib/c.cjs
exports.c = 1;
exports.d = 2; // 参照していないが出力に残る
console.log('loaded'); // この副作用も残る
// app.cjs
const m = require('./lib/c.cjs');
console.log(m.c);
npm 上のパッケージは ESM と CommonJS の両方を同梱しているものが多く、esbuild は既定でツリーシェイキングの効く形式を選ぼうとする。意図した側が選ばれていないときは main fields や conditions の設定で調整する。
AWS SDK v3 がパッケージ分割されている理由
AWS SDK for JavaScript v2 は 1 つのパッケージに全サービスのクライアントを抱えていたため、読み込む 1 行で使わないサービスの定義まで出力に入った。v3 は @aws-sdk/client-dynamodb のようにサービスごとの別パッケージへ分け、さらに個々の操作を Command クラスとして別々に export したので、import した範囲だけが残る形になった。
// 使うクライアントと操作だけを import する
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
なお v2 は 2024 年 9 月 8 日にメンテナンスモードへ入り、2025 年 9 月 8 日にサポート終了となった。バンドルサイズを論じる以前に、新規採用の選択肢ではない。
Lambda で SDK を外部化するときの判断
AWS SAM から Lambda 関数を esbuild でビルドする場合、Metadata でツリーシェイキングとミニファイの効いた出力を作れる。
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2022
External: ['@aws-sdk/*'] # ランタイム同梱の SDK を出力から外す
External に SDK を挙げるとデプロイパッケージは小さくなるが、実行時に使われるのはランタイムに同梱された SDK になる。Lambda のドキュメントは、サポートされる Node.js ランタイムに含まれるのは v3 の特定のマイナーバージョンであって最新版ではなく、その版はランタイムのバージョンとリージョンによって異なると明記している。新しめの API を使う場合や、動作を版で固定したい場合は External から外して自分でバンドルする。サイズと再現性のどちらを取るかの判断であり、常に外すのが正解ではない。
効かないケースと、効くのに落ちない理由
| ケース | 何が起きるか |
|---|---|
| トップレベルに副作用があるモジュール | 未使用 export は落ちても、実行される文とモジュール自体は残る |
| CommonJS | 前述のとおりモジュールごと残る |
eval() や文字列で組み立てた import() | 参照先が実行時に決まり、どの export が必要か判定できない |
| 実行時に決まるプロパティアクセス | obj[key] では key の値が分からず、全プロパティを残すしかない |
引数がリテラルの import() は解析できるので、こちらは除去の対象外ではなくコード分割の分割点になる。
barrel ファイル (index.ts に export * from を並べる形) が効かないという説明も見かけるが、再エクスポート自体は静的に解析できる。barrel を経由して 1 つだけ import した場合でも、使っていない側の宣言は出力から消える。残るのは経由したモジュールのトップレベルの副作用と、そのモジュール自身である。barrel の問題は再エクスポートしていることではなく、依存を広く辿るぶん副作用を持つモジュールを巻き込みやすい点にある。
sideEffects フラグは申告であって保証ではない
{
"name": "my-library",
"sideEffects": false
}
package.json の sideEffects: false は「このパッケージのファイルは、そこからの import が全て未使用になったら丸ごと捨ててよい」という申告である。バンドラーが除去を強めるのではなく、副作用の有無を自力で判定できない部分を開発者の宣言で埋める仕組みだと捉えると挙動が読める。前述の副作用が残る例でも、この申告を付けるとモジュールごと出力から消える。
危険なのは根拠のない false である。webpack のドキュメントは、import されたファイルは全てツリーシェイキングの対象になるため、css-loader 経由で CSS を import している場合は副作用として申告しないと production モードで意図せず落とされると注意している。副作用のあるファイルは配列で列挙する。
{
"sideEffects": ["**/*.css", "./src/polyfill.js"]
}
webpack はこの配列を glob として扱い、/ を含まない *.css のようなパターンは **/*.css と同じ意味に展開する。esbuild も同じフィールドを尊重するが、申告の正確さは開発者に完全に依存すると明記し、誤った申告を含むパッケージに当たったときの一時的な回避策として注記を無視するオプションを用意している。副作用を持つファイルを後から足して申告を更新し忘れると、利用者側のバンドルで壊れる。
関数呼び出し単位で副作用がないことを伝えるには /*#__PURE__*/ 注記を使う。戻り値が使われなければ呼び出しごと削除してよいという印で、未使用の変数の初期値がこの注記付きなら初期化式も落とせる。
つまずきやすい点
- development ビルドで出力サイズを比べて効かないと結論する。削る工程は production 側にある。
sideEffects: falseを機械的に付ける。CSS の import やポリフィルを持つパッケージでは実害が出る。- 依存パッケージが CommonJS しか提供していない場合、自分のコードを ESM にしてもその依存は丸ごと入る。依存管理の段階で ESM 提供の有無を見る。
- サイズ削減の手段をツリーシェイキングだけで考える。初回に読み込む範囲を絞るならコード分割と遅延読み込みのほうが効く場面が多い。
- 除去できたか確認せずに進める。webpack は未使用 export の検出を有効にした development 出力の注記、esbuild はバンドル内訳の出力で、何が残っているかを確認できる。
この記事は役に立ちましたか?