tree shakingでバンドルサイズを削る:sideEffectsとバレルの罠
未使用exportが消えない原因はESMかsideEffectsの設定ミス。仕組み、バレルファイルの罠、esbuildで効果を測る最小コードまで実例で解説。
「使ってない関数なのに、なんでバンドルに残ってるの?」
ある管理画面の初回ロードが遅くて、ビルド結果を覗いたときのことです。一度しか呼ばないはずの日付ライブラリも、デバッグ用の関数も、PDF生成のコードも、本番のJSにまるごと入っていました。importでは呼んでいないのに、です。
tree shaking(ツリーシェイキング)が効いていない、典型的な状態でした。
これ、設定を1〜2行直すだけで消えることも多いんです。でも「なぜ残るのか」を知らないままsideEffects: falseを入れて、今度はCSSが全部吹き飛んだ——という二次災害も、僕は何度かやりました。今日はその仕組みと、踏みやすい罠、そして効果を数値で確かめる最小コードを書きます。
この記事の要点
- tree shakingは「使っていないexportをバンドルから落とす」掃除。魔法ではなく、ESMの静的な依存グラフを読んで保守的に消す。
- 効かない原因の大半は2つ。CommonJSに変換されているか、
sideEffectsの設定が雑か。 package.jsonのsideEffects: falseは強力だが、CSS・polyfill・初期化処理を巻き込んで消す。副作用ファイルは配列で守る。index.tsにまとめて再exportするバレルファイルは、トップレベル処理が混ざると解析を詰まらせる。- 感覚で語らず、esbuildやrollup-plugin-visualizerでバイト数を測る。コード分割(dynamic import)とは役割が別。
tree shakingとは何をする仕組みか
tree shakingは、JavaScriptやTypeScriptを本番用にまとめるとき、実際に使っていないexportをバンドルから落とす仕組みです。木を揺すって枯れ葉だけ落とす比喩からこの名前ですが、実務では「初回ロードに要らないコードを送らないための掃除」と思うと腑に落ちます。
ポイントは、バンドラーが人間の意図を読めないことです。読めるのはimportとexportの形だけ。「この関数はどこからも呼ばれていない」と静的に証明できたものだけを、安全側に倒して消します。少しでも「実行時に何か起きるかも」と判断したら、残します。
だから消す/残すは、賢さではなくルールで決まります。次の材料を見て判断しています。
import/exportの書き方(名前付きか、デフォルトか、名前空間か)package.jsonのsideEffects- CommonJSへの変換が挟まっていないか
- ファイルのトップレベルで何か実行していないか(CSS読み込み、polyfill、計測の初期化など)
逆に言うと、この材料が曖昧だと消せません。掃除のおじさんに「触っていい物と触っちゃダメな物」を伝えていないようなもので、迷ったら全部残す。それが「使っていないのに残る」の正体です。
なぜESMでないと効かないのか
tree shakingがES Modules(ESM、import/exportの形式)を前提にするのには理由があります。ESMは実行する前に依存関係が確定するからです。import { formatYen } from "./utils"と書けば、何を持ち込むかがコードを動かさなくても分かる。だから「持ち込まれなかったもの=消してよい」と判定がつきます。
一方CommonJS(require() / module.exports)は動的です。require(someVariable)のように、実行してみないと何を読むか決まらないケースがある。バンドラーは安全のため「全部使われるかもしれない」と見なし、消すのをあきらめます。
ここで地味に効く事故が、ビルド設定が裏でESMをCommonJSに変換しているパターンです。tsconfigのmoduleがcommonjsになっていたり、Babelの設定でESMをトランスパイルしていたり。ソースはimportで書いているのに、バンドラーに届く前にCommonJSへ落とされ、tree shakingが死ぬ。webpack公式も「ES2015 module構文を使い、CommonJSにトランスパイルしないこと」を明確に要件として挙げています(Tree Shaking)。
それともう1つ。本番モードで測ることです。開発ビルドだとminify(最小化)が走らず、消したはずのコードが残って見える。webpackならmode: 'production'、Viteならvite build。効果は必ず本番ビルドのバイト数で確認します。
package.jsonのsideEffectsで踏む罠
ここが一番事故ります。sideEffectsは「このファイルは、importしただけで外部に影響を与えるか?」をバンドラーに教えるフラグです。ReactのuseEffectとは無関係で、モジュールを読み込んだ瞬間の副作用の話だと分けて考えてください。
純粋な関数だけのライブラリなら、こう宣言します。
{
"name": "@masa/formatters",
"type": "module",
"sideEffects": false
}
falseは「このパッケージは、どのファイルもimportしただけでは何も起きない。使われていないファイルは丸ごと捨ててOK」という最強の宣言です。効きはいい。でも強すぎます。
たとえばimport "./styles.css"や、polyfillを読み込むだけのファイル、custom elementを登録するファイル。これらは何もexportしないのに、importする意味がある。sideEffects: falseを入れると、バンドラーは「exportが使われていない=要らない」と判断して、CSSごと消します。見た目が崩れて初めて気づく、いやな壊れ方です。
正解は、副作用のあるファイルだけ配列で守ることです。
{
"name": "@acme/ui",
"type": "module",
"sideEffects": [
"**/*.css",
"./src/setup-theme.ts"
]
}
これで「CSSとテーマ初期化は触るな、それ以外は自由に掃除していい」と伝わります。webpack公式も、CSSやpolyfillをsideEffectsで明示せずfalseにするのを典型的な落とし穴として挙げています。「ライブラリ全体が純粋だ」と胸を張れるときだけfalse。迷ったら配列、が安全です。
バレルファイルという、便利だけど危ない入口
index.tsで関連モジュールをまとめて再exportする——いわゆるバレルファイル(barrel file)。export { Button } from "./Button"をずらっと並べたあれです。import { Button, Modal } from "@acme/ui"と一行で書けて、見た目はきれい。
でも、これが解析を詰まらせる原因になりがちです。
理由は2つ。1つは、バレル自身がトップレベルで処理を実行している場合。export * from "./x"の連鎖の途中に、importしただけで走る初期化が紛れ込むと、バンドラーは「このバレルは副作用を持つ」と判断して掃除を渋ります。import { Button }と書いただけなのに、Modalもチャートも引きずってくる。
もう1つは、export *の多段ネストです。再exportが何層にもなると、依存グラフが太くなり、解析が保守的に倒れやすい。とくにライブラリ側のバレルを、アプリのあちこちからimport { 一個 }する形は、効きが読みにくくなります。
現実的な落とし所はこうです。
| 使う場所 | おすすめ | 理由 |
|---|---|---|
| アプリ内部のimport | 直接パスで書く(../utils/formatYen) | バレルを経由させず、依存を細く保つ |
| ライブラリの公開API | 薄いバレルだけ残す | 利用者の使い勝手と解析しやすさの折衷 |
| 副作用を持つファイル | バレルに混ぜない | importしただけで走る処理を切り離す |
「バレルを全部やめろ」ではありません。再exportだけの薄いバレルに留め、トップレベル処理を混ぜない。これだけで効きがだいぶ変わります。
コピペで動く:効果をバイト数で測る最小コード
ここまで読んで「で、自分のプロジェクトは効いてるの?」が気になるはずです。tree shakingは感覚で語ると必ず間違えるので、数値で見ます。default exportが残りやすい例と、named exportで削られる例を、esbuildで比較する最小プロジェクトを作りましょう。
まず準備します。
mkdir tree-shaking-lab
cd tree-shaking-lab
npm init -y
npm install --save-dev esbuild
mkdir src scripts
package.jsonをこの内容にします。"type": "module"でESMにしておくのが肝です。
{
"name": "tree-shaking-lab",
"version": "1.0.0",
"type": "module",
"private": true,
"sideEffects": false,
"scripts": {
"measure": "node scripts/measure-tree-shaking.mjs"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
悪い例。便利関数を1つのobjectに詰め込みます。これがdefault exportで残りやすい典型です。
// src/bad-utils.ts
const utils = {
formatYen(amount: number): string {
return new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY"
}).format(amount);
},
heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
},
debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
};
export default utils;
良い例。使う関数だけをnamed exportにします。
// src/good-utils.ts
export function formatYen(amount: number): string {
return new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY"
}).format(amount);
}
export function heavyReport(rows: number[]): string {
const body = rows.map((row) => `row:${row}`).join("\n");
return `report\n${body}\n${"=".repeat(4000)}`;
}
export function debugOnly(): string {
return "debug:" + "x".repeat(4000);
}
入口を2つ作ります。どちらもformatYenしか呼びません。
// src/bad-entry.ts
import utils from "./bad-utils";
console.log(utils.formatYen(1200));
// src/good-entry.ts
import { formatYen } from "./good-utils";
console.log(formatYen(1200));
測定スクリプトです。出力のバイト数とgzip後のサイズを並べて表示します。
// scripts/measure-tree-shaking.mjs
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
async function bundle(entryPoint) {
const result = await build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
treeShaking: true,
write: false,
metafile: true
});
const code = result.outputFiles[0].text;
return {
entryPoint,
bytes: Buffer.byteLength(code), // 生のバイト数
gzipBytes: gzipSync(code).byteLength, // 転送に近いgzip後
inputs: Object.keys(result.metafile.inputs)
};
}
const rows = await Promise.all([
bundle("src/bad-entry.ts"),
bundle("src/good-entry.ts")
]);
console.table(rows);
実行します。
npm run measure
数字そのものより、「同じformatYenしか使っていないのに、bad entryだけheavyReportとdebugOnlyの重さを引きずる」のを見てください。default objectは「objectを丸ごと作る」ため、未使用の中身も道連れになります。named exportなら、呼ばれた関数だけが残る。これがtree shakingの本質です。実案件では、ここにdistのchunk名やBrotliサイズ、Lighthouseの数値を足していきます。
バンドル分析で「何が残っているか」を可視化する
最小ラボで原理を掴んだら、本物のアプリでは「どの依存が太いか」を地図にします。バイト数の合計だけ見ても、犯人が分かりません。
ViteやRollupなら、rollup-plugin-visualizerが定番です。ビルド後にツリーマップのHTMLを吐き、どのモジュールが何バイト占めているかを面積で見せてくれます。
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
// ビルド後に stats.html を自動で開く
visualizer({ open: true, gzipSize: true, brotliSize: true })
]
});
ViteはRollupの上に成り立っていて、tree shaking自体はRollupのアルゴリズムが担います。Rollup公式のtreeshakeオプションも、moduleSideEffectsを雑に切ると初期化コードまで消えると注意しています。esbuild派なら、metafileを吐いて公式の解析手順で見るのが速いです。
可視化して「思ったより大きい依存」を見つけたら、深掘りはバンドル分析の記事に手順をまとめています。重複依存やバージョン違いの二重持ちは、tree shakingでは消えないので、分析で先に潰します。
コード分割(dynamic import)との違いを混同しない
ここはよく混ざるので、はっきり分けます。
- tree shaking = 使っていないコードを「最初から入れない」。引き算。
- コード分割 = 使うコードを「後で読む」。先送り。
別物です。たとえばPDF生成やWYSIWYGエディタは、一般ユーザーの初回表示には不要なことが多い。これは「使っている」コードなので、tree shakingでは消えません。消すのではなく、dynamic importで遅延chunkに切り出します。
// src/features/admin/loadMarkdownPreview.ts
export async function renderMarkdown(markdown: string): Promise<string> {
// 管理画面を開いたときだけ、重い依存を読み込む
const [{ unified }, remarkParse, remarkHtml] = await Promise.all([
import("unified"),
import("remark-parse"),
import("remark-html")
]);
const file = await unified()
.use(remarkParse.default)
.use(remarkHtml.default)
.process(markdown);
return String(file);
}
注意したいのは、dynamic importはtree shakingの代わりにならないことです。遅延chunkの中身がCommonJS中心なら、そのchunkは重いまま。「初回chunkから外す(コード分割)」と「chunkの中の未使用コードを落とす(tree shaking)」は、別の作業として別々に測ります。組み合わせ方はコード分割に詳しく書きました。
僕がやらかした失敗3つ
正直に書きます。tree shakingまわりは、効かせるより壊す方が簡単でした。
1つ目は、sideEffects: falseを全部に効かせて、CSSを消したこと。「軽くなった!」と喜んだ翌日、本番のボタンが真っ白。スタイルが丸ごと落ちていました。テストはDOMの存在だけ見ていたので通過し、目視で初めて気づいた。以来、CSSとpolyfillは必ずsideEffectsの配列に入れます。
2つ目は、開発ビルドでサイズを測って「減った」と勘違いしたこと。minifyが走っていないので、消えるはずのコードが残って見える。逆に「効いてないじゃん」と無駄に設定をいじって、別の所を壊しました。今は本番ビルド+gzipでしか判断しません。
3つ目は、tsconfigが裏でCommonJSに変換していたのを見落としたこと。ソースは全部importで書いていたのに、何をどう直してもtree shakingが効かない。半日溶かしてからmodule: "commonjs"に気づきました。入口で潰されていたら、後段の努力は全部無駄になります。
tree shakingを始めるなら、この順番
いきなりsideEffects: falseを入れないでください。順番を守るだけで事故が激減します。
- 測る。本番ビルドのgzipサイズと、rollup-plugin-visualizerやmetafileで現状の地図を取る。ここがゼロ地点。
- ESMを確認する。tsconfigの
module、Babel設定、依存のCommonJS変換が挟まっていないか。効かない原因の半分はここ。 - named exportに寄せる。default objectや名前空間importを、使う関数だけのnamed importに直す。
sideEffectsを正しく書く。純粋ならfalse、副作用ファイルがあるなら配列。CSS・polyfill・登録処理を必ず守る。- また測って、目視する。サイズ差だけでなく、主要画面を開いてCSS欠落がないか確認。サイズ削減は「表示確認まで」で完了です。
この流れはパフォーマンス最適化で扱う計測の考え方とそろえると、効果が数字で語れるようになります。
Claude Codeに任せるときのコツ
「全部軽くして」は禁句です。Claude Codeに丸投げすると、sideEffects: falseをいきなり入れて、CSSが消えても気づかないPRを出してきます。賢い/賢くないの問題ではなく、頼み方の問題です。
僕は、実装変更ではなく調査と測定から頼みます。
このリポジトリの本番バンドルでtree shakingが効きにくい箇所を調査してください。
最初に現在のビルドサイズ、主要chunk、重い依存、CommonJS依存、バレルファイルを表にしてください。
修正案には、リスク・期待できる削減量・検証コマンドを付けてください。
CSS、polyfill、analytics、初期化処理の副作用は削らない前提で確認してください。
修正を任せるときは、範囲をさらに狭めます。
まず src/utils と src/components/index.ts だけを対象にしてください。
default object exportをnamed exportへ変え、利用側のimportも直してください。
変更後に npm run build とバンドルサイズ測定を実行し、差分を報告してください。
外部公開APIが変わる場合は、互換用のre-exportを残してください。
こう頼むと、Claude Codeは「何を削ったか」ではなく「何を壊さずに減らしたか」を軸に動きます。検証コマンドと表を必ず出させるのが、暴走を止める門番になります。
よくある質問
Q. sideEffects: falseを入れたらCSSが消えました。直し方は?
A. falseをやめ、副作用ファイルを配列で列挙してください。"sideEffects": ["**/*.css", "./src/polyfills.ts"]のように、CSSとpolyfill・初期化処理を守ります。CSSのimportはexportがなくても意味があるため、falseだと未使用扱いで消えます。
Q. named exportにしたのに、未使用関数がバンドルに残ります。
A. たいていESMが届いていません。tsconfigのmoduleがcommonjsになっていないか、Babelやライブラリ側でCommonJSに変換されていないかを確認してください。あわせて、開発ビルドではなく本番ビルド(minify有効)で測っているかも確認を。
Q. バレルファイル(index.tsの再export)は使ってはいけませんか?
A. 禁止ではありません。再exportだけの薄いバレルなら問題は出にくいです。トップレベルで初期化を走らせたり、export *を多段にネストすると解析が詰まります。アプリ内部は直接パス、公開APIだけ薄いバレル、が無難です。
Q. dynamic importを使えばtree shakingは要りませんか? A. 役割が別です。dynamic importは「使うコードを後で読む」先送り、tree shakingは「使わないコードを入れない」引き算。遅延chunkの中身がCommonJSだと、そのchunkは重いままなので、両方を別々に測ります。
Q. webpackとViteで設定は違いますか?
A. 考え方は同じで、package.jsonのsideEffectsは共通です。webpackはmode: 'production'で有効化、ViteはRollupベースでvite build時に効きます。詳細はそれぞれwebpack公式とRollup公式で確認してください。
実際に試した結果
冒頭の「使ってない関数が残る」管理画面は、原因が2つでした。1つはバレルファイルがテーマ初期化を巻き込んでいたこと。もう1つはビルド設定がESMをCommonJSに落としていたこと。sideEffectsを配列に直し、バレルを薄くし、CommonJS変換を切ったら、初回chunkのgzipが目に見えて軽くなりました。
この記事の最小サンプルも、僕の手元でnpm run measureまで回し、bad entryとgood entryの出力差を確認しています。ただし数字は依存関係に左右されるので、必ず自分のproduction buildで測ってください。そして削ったコードより、残すべき副作用をはっきりさせてから公開する。これが一番の事故防止です。
もし自分のプロジェクトで「どこから手をつけるか」に迷うなら、現状のバンドル測定と削減候補の洗い出しから一緒にやれます。研修・相談で、package.json・ビルド設定・直近のbundle reportを見ながら短時間で当たりをつけられます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。