使っていない関数を import しただけなのに、なぜバンドルから消えるのか?
ライブラリから 1 つの関数だけを import したのに、ビルド後のバンドルサイズがライブラリ全体分だけ増えて驚いたことがあるはずだ。逆に webpack や Vite が「tree shaking で不要なコードを除去しました」と言うのも見たことがあるだろう。バンドラは何を見て「これは要る」「これは要らない」を判断しているのか、説明できるだろうか。
- ES Modules の
import/exportはトップレベル・文字列リテラル限定という静的な構文しか許さないため、バンドラはコードを実行せずに「誰が何を使っているか」の依存グラフを組み立てられる - CommonJS の
require()は実行時に評価される普通の関数呼び出しにすぎず、パスを動的に組み立てられてしまうため静的解析ができず、tree shaking の対象外になる - webpack は「未使用 export に印を付ける」→「package.json の
sideEffectsでモジュール単位ごと除外する」→「minify で物理的に消す」という 3 段階を経て初めてバンドルから消える。途中の段階が欠けると消えない
「使っていないコードを消す」がそもそも難しい理由
不要なコードの除去(デッドコード除去)は、コンパイラの世界では昔からある最適化だ。しかし安全に消すには「このコードを削除しても、プログラムの挙動が一切変わらない」という証明が要る。JavaScript はこの証明が特にやりづらい言語だ。関数を呼ぶだけでグローバル変数を書き換えたり、他のモジュールの状態を変えたりできてしまうため、コンパイラは「呼んでも何も起きない」とは滅多に断言できない。
だから tree shaking は「賢い AI がコードの意味を理解して要不要を判定する」仕組みではない。バンドラが安全に判定できるのは、言語やモジュールの仕様がそもそも「これは静的に追跡できる」という保証を与えている範囲だけだ。この保証の有無こそが、この後の話全体を分ける境界線になる。
ES Modules が静的である、という意味
MDN のリファレンスは import 宣言について、指定できるのは文字列リテラルのみ・記述できるのはファイルのトップレベルのみ・束縛される名前は識別子のみという、意図的に融通の利かない構文だと説明している。この制約のおかげで、バンドラは 1 行もコードを実行しないまま全ファイルを読み、どのモジュールがどの名前を export し、どのモジュールがどの名前だけを import しているかを 1 つのグラフとして組み立てられる。
一方 CommonJS の require() はただの関数呼び出しだ。require("./plugins/" + name) のようにパスを実行時に組み立てることも文法上できてしまう。バンドラの立場からすれば、実際にコードを実行してみるまでどのファイルが読み込まれるか分からない以上、「このモジュールは使われていない」と静的に断定する根拠がない。ES Modules と CommonJS の差は、書き方の好みではなく、解析可能かどうかという構造そのものの差だ。
ESM: 静的な依存グラフ
index.mjs
└─import { cube }─▶ math.mjs
├ square(未参照)
└ cube (参照あり)
→ square だけを安全に除外できる
CJS: 実行するまで分からない
index.cjs
└─require(path)───▶ ??? モジュール
path は実行時に組み立て可能
→ 何が要るか、実行せずには断定不可
引っ越し業者に荷物を預ける場面を思い浮かべてほしい。ES Modules は中身をラベルに書いた透明な段ボールで、業者は箱を開けずに外から見るだけで「これは積まなくていい」と判断できる。CommonJS は中身の見えない不透明な箱に近い。何が入っているかは開けてみる、つまり実行してみるまで分からないため、業者は安全側に倒して結局すべての箱をトラックに積むしかない。バンドラが賢いか鈍いかではなく、荷物がそもそも外から中身の見える形で梱包されているかどうかが、積み荷を減らせるかどうかを決めている。
webpack の 3 段階: マーク → 除外 → 物理削除
webpack 公式ガイドは tree shaking を 1 つの機能ではなく、3 段階の合わせ技として説明している。まず optimization.usedExports が有効だと、webpack はモジュール内の各 export が実際に import されているかを解析し、未使用のものに /* unused harmony export square */ のような印を付ける。ただしこの段階ではまだコードは残ったままだ。文単位で副作用があるかどうかの判定を最終的に terser(minifier)任せにしており、その判定は動的な JavaScript の性質上どうしても保守的にならざるを得ない。
ここで効くのが package.json の sideEffects フィールドだ。これは export 単位ではなく、モジュール(ファイル)まるごとを安全に読み飛ばしてよいかをバンドラに直接伝える宣言になる。
// package.json
{
"name": "my-lib",
"sideEffects": false
}
sideEffects: false と書くと、webpack は「このパッケージのどのファイルも、import されるだけでは何も副作用を起こさない」という前提で、使われていないファイルをサブツリーごと解析対象から外せる。ここまでの 2 段階を経て、最後に mode: "production" が terser による実際の削除とモジュール結合を行い、ようやくバンドルから消える。usedExports だけでは印が付くだけ、sideEffects だけでは除外候補になるだけで、3 段階が揃って初めて出力に反映される。
sideEffects は諸刃の剣
ここで見落とされがちなのが、webpack はデフォルトでは「どのファイルにも副作用がある」という安全側の前提に立っている点だ。だから sideEffects: false を宣言しない限り、モジュール単位の除外は働かない。しかし逆に、実際には副作用を持つファイルを含むパッケージ全体に sideEffects: false を付けてしまうと、CSS の import やポリフィルのように「import されること自体に意味がある」ファイルまで一緒に消され、本番でスタイルが当たらない・グローバル設定が反映されないといった壊れ方をする。
CSS を import しているパッケージなら、副作用のあるファイルを名指しで残す書き方が要る。
// package.json
{
"name": "my-lib",
"sideEffects": ["*.css", "./src/polyfills.js"]
}
sideEffects: false は「軽くなるおまじない」ではなく、パッケージの作者がファイル単位で副作用の有無を実際に検証した上で初めて付けてよい申告だ。検証せずに付けると、tree shaking はバンドルを軽くする代わりにアプリを壊す機能に変わる。
ここまで来ると、named import さえ書けばよいように思える
import { debounce } from "lodash" と書けば debounce だけがバンドルされるように思えるかもしれない。しかし実際には、lodash 本体は今も CommonJS 形式で書かれた 1 つの巨大なモジュールであり、named import の構文を使っても内部的には require("lodash").debounce というプロパティアクセスに変換される。CommonJS はモジュール全体が 1 つの module.exports オブジェクトである以上、バンドラにとって「使われているプロパティだけ」を切り出す判断は静的解析の対象外であり、結局ライブラリ全体がバンドルに含まれてしまう。
ESM 版として配布されている lodash-es に切り替える、あるいは lodash/debounce のようにサブパスから個別モジュールとして import すると、読み込まれるファイル自体が最初から debounce 単体になる。tree shaking が効くかどうかは import の書き方だけでなく、依存しているライブラリ自体が ESM で配布されているかどうかにも懸かっている。
モノレポで社内共有パッケージを作るとき sideEffects の宣言を忘れると、他のパッケージから 1 関数だけ import したつもりでも共有パッケージ全体がバンドルに残り、ビルドサイズ肥大の原因になる。逆に急いで sideEffects: false を付けて CSS やグローバル初期化コードが消え、本番だけスタイルが崩れるという事故も起きる。webpack-bundle-analyzer や Vite の rollup-plugin-visualizer でバンドルの中身を可視化すると、想定より大きいライブラリが紛れ込んでいないかを直接確認できる。
ESM と CommonJS で tree shaking の効き方が実際に違うことを、esbuild で確かめる。同じ 2 関数(1 つは使う、1 つは使わない)を ESM 版と CommonJS 版でそれぞれ定義し、バンドル後に未使用の関数名が残っているかを grep で見る。
mkdir -p /tmp/tree-shake-demo && cd /tmp/tree-shake-demo
# ESM 版
cat > math.mjs <<'EOF'
export function square(x) { return x * x }
export function cube(x) { return x * x * x }
EOF
cat > index.mjs <<'EOF'
import { cube } from './math.mjs'
console.log(cube(3))
EOF
npx esbuild index.mjs --bundle --minify --outfile=out-esm.js
echo "ESM: square が残っている数 -> $(grep -c square out-esm.js)"
# CommonJS 版
cat > math.cjs <<'EOF'
function square(x) { return x * x }
function cube(x) { return x * x * x }
module.exports = { square, cube }
EOF
cat > index.cjs <<'EOF'
const { cube } = require('./math.cjs')
console.log(cube(3))
EOF
npx esbuild index.cjs --bundle --minify --outfile=out-cjs.js
echo "CJS: square が残っている数 -> $(grep -c square out-cjs.js)"
ESM 版は square の出現回数が 0 になり、使っていない関数が完全に消えているのが分かる。CommonJS 版は module.exports が 1 つのオブジェクトとして丸ごと解決されるため square も含めて残り、出現回数が 0 にならないはずだ。
- import さえすれば使わないコードは自動的に全部消える — ESM 構文であることに加えて、package.json の
sideEffects宣言と minify を通すビルド設定が揃って初めて消える。CommonJS のライブラリは named import の書き方に関わらずほぼ丸ごと残る。 sideEffects: falseを付ければ安全に軽量化できる — 実際に副作用を持つファイル(CSS の import やグローバル初期化コード)が混ざっていると、それごと削除されてアプリが壊れる。付ける前にファイル単位で副作用の有無を検証する必要がある。- tree shaking は minify と同じもの — tree shaking は使われていない export をモジュール単位で解析から除外する仕組みで、minify は残ったコードの変数名短縮や空白除去、実際のバイト削減を行う別の処理だ。両方揃って初めてバンドルサイズに反映される。
- ES Modules(ESM)
- トップレベル・文字列リテラル限定の
import/export構文を持つ、静的解析が可能な JavaScript の標準モジュール形式。 - CommonJS
require()/module.exportsによる Node.js 発祥のモジュール形式。require()は実行時に評価される関数呼び出しであるため静的解析ができない。- 静的解析
- コードを実際に実行せず、構文の構造だけを読んで依存関係や挙動を判定する手法。
- sideEffects
- package.json のフィールド。import されるだけで意味を持つファイル(CSS・ポリフィル等)を宣言し、それ以外をバンドラがモジュール単位で除外してよいと伝える。
- デッドコード除去(dead code elimination)
- 実行しても結果に影響しないと判定されたコードをコンパイラ・バンドラが取り除く最適化の総称。tree shaking はその一種。
- Tree Shaking — webpack — usedExports・sideEffects・minify の 3 段階を具体例付きで解説する公式ガイド
- import — JavaScript | MDN — import 宣言がトップレベル・文字列リテラル限定という静的な構文制約を持つことの一次資料
- Frequently Asked Questions | Rollup — tree shaking が「live code inclusion」と呼ばれる理由と、CommonJS が静的解析に向かない理由を説明する公式 FAQ