Advanced (更新: 2026/6/2)

Claude Codeでツリーシェイキングを改善する実践ガイド

Claude Codeでツリーシェイキングを改善する手順。ESM、sideEffects、測定、失敗例まで実例で解説。

Claude Codeでツリーシェイキングを改善する実践ガイド

ツリーシェイキングとは何か

ツリーシェイキングは、JavaScriptやTypeScriptを本番用にまとめるとき、実際に使っていないexportをバンドルから落とす仕組みです。 木を揺らして枯れ葉だけを落とす比喩ですが、実務では「初回ロードに不要なコードを送らないための掃除」と考えると分かりやすいです。

ただし、バンドラーは人間の意図を読めません。 importexportの形、package.jsonsideEffects、CommonJSへの変換、トップレベルで実行される処理を見て、消してよいかを保守的に判断します。 「使っていない関数なのに残る」「sideEffects: falseでCSSが消えた」という事故は、この判断材料が曖昧なときに起きます。

Claude Codeは、単に「軽くして」と頼むだけでは危険です。 効果を測れる入口を作り、ES Modulesに寄せ、失敗しやすい副作用を棚卸しし、最後にビルド結果で確認する作業を任せると強いです。 この記事では、Masaが小さなVite/React系の管理画面を直すときに使っている、コピーして動かせる最小例とレビュー観点をまとめます。

概念図としては、次の流れです。

flowchart LR
  A["source files"] --> B["ESM import/export graph"]
  B --> C["bundler tree shaking"]
  C --> D["minified production bundle"]
  B --> E["side effects kept"]
  E --> D
  D --> F["measure bytes and gzip"]

まず公式ドキュメントで前提をそろえる

細かい挙動はバンドラーごとに違います。 本番コードでは、少なくとも次の公式資料を根拠にしてください。

項目公式リンク実務で見る点
webpackTree ShakingsideEffects、ESM維持、production build
webpack設定optimization.sideEffectspackage側のsideEffectsをどう読むか
Rollup/ViteRollup treeshakemoduleSideEffectsを雑に切らない
Rollup詳細treeshake.moduleSideEffects副作用のあるモジュールの扱い
esbuildTree shakingESMの静的解析と測定用metafile

ここで重要なのは、ツリーシェイキングは「未使用っぽい文字列を削る魔法」ではないことです。 ESMの静的な依存グラフを読み、実行時の副作用を壊さない範囲で落とします。 そのため、require()中心のCommonJS、default objectにまとめた便利関数、トップレベルでCSSやpolyfillを読み込むファイルは、期待より残りやすくなります。

Claude Codeに渡す依頼の型

最初の依頼は、実装変更ではなく調査と測定から始めます。 いきなりsideEffects: falseを入れると、見た目やpolyfillが壊れても気づきにくいからです。

このリポジトリの本番バンドルでツリーシェイキングが効きにくい箇所を調査してください。
最初に現在のビルドサイズ、主要chunk、重い依存、CommonJS依存、barrel exportを表にしてください。
修正案は、リスク、期待できる削減量、検証コマンドを付けて出してください。
CSS、polyfill、analytics、global setupの副作用は削らない前提で確認してください。

修正を任せるときは、範囲をさらに狭めます。

まず src/utils と src/components/index.ts だけを対象にしてください。
default object exportをnamed exportへ変え、利用側importも直してください。
変更後にnpm run buildとバンドルサイズ測定を実行し、差分を報告してください。
外部公開APIが変わる場合は互換用re-exportを残してください。

この頼み方にすると、Claude Codeは「何を削ったか」ではなく「何を壊さずに減らしたか」を軸に動けます。

コピペで動く最小測定サンプル

ツリーシェイキングは、感覚ではなく数値で見ます。 次の最小プロジェクトは、default object 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を次の内容にします。

{
  "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に詰めます。

// 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つ作ります。

// 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));

測定スクリプトです。

// 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,
    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

この例では、数値そのものより「同じ機能なのに出力サイズが変わる」ことを確認してください。 実案件では、ここにdistのchunk名、gzipサイズ、Brotliサイズ、LighthouseのTotal Blocking Timeを足します。 バンドル分析の記事と組み合わせると、どの依存が残っているかまで追いやすくなります。

実務ユースケース1: ユーティリティ関数の整理

最初に効くのは、utils/index.tshelpers.tsに何でも入っているプロジェクトです。 日付、通貨、文字列、CSV、Markdownなどが1ファイルに混ざると、1つの関数を使うだけで関連しない処理まで評価対象になります。

Claude Codeには、次の順で依頼します。

src/utilsを用途別ファイルに分けてください。
既存のimportをnamed importへ置き換え、公開APIが必要なものだけindex.tsで再exportしてください。
トップレベルでDate.now、console、localStorage、fetchを呼ぶ処理があれば、関数内へ移してください。

良い形はこうです。

// src/utils/formatDate.ts
export function formatDate(date: Date, locale = "ja-JP"): string {
  return new Intl.DateTimeFormat(locale).format(date);
}
// src/utils/index.ts
export { formatDate } from "./formatDate";
export { formatYen } from "./formatYen";
// src/pages/invoice.ts
import { formatYen } from "../utils/formatYen";

export function invoiceLabel(total: number): string {
  return `合計: ${formatYen(total)}`;
}

バレルファイル、つまりindex.tsでまとめて再exportするファイルは便利です。 ただし、そのファイルが余計な初期化やexport * fromの連鎖を持つと、解析しづらくなります。 アプリ内のimportは直接パスに寄せ、ライブラリ公開用だけに薄いbarrelを残す判断が現実的です。

実務ユースケース2: UIコンポーネントライブラリ

社内UIライブラリでは、import { Button } from "@acme/ui"の裏で、Modal、DatePicker、Chartまで評価されることがあります。 とくにCSSの読み込み、アイコン全量import、テーマ初期化が絡むと、単純なnamed exportだけでは減りません。

改善の基本は、入口を分けることです。

{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": [
    "**/*.css",
    "./src/setup-theme.ts"
  ],
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button.js",
    "./modal": "./dist/modal.js"
  }
}

利用側は必要な入口だけを読みます。

import { Button } from "@acme/ui/button";

ここでsideEffects: falseを雑に入れると、ButtonのCSSやテーマ登録が落ちることがあります。 webpackのsideEffectsは「このファイルはimportしただけで外部に影響するか」を伝える情報です。 ReactのuseEffectの話ではなく、モジュールを読み込んだ瞬間の副作用の話だと分けて考えてください。

実務ユースケース3: 管理画面だけ重い依存を遅延ロード

Markdown変換、PDF生成、グラフ、WYSIWYGエディタは、一般ユーザーの初回表示に不要なことが多いです。 ツリーシェイキングで未使用exportを落とし、さらにコード分割で画面単位に遅延ロードします。

// 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);
}

注意点は、動的importはツリーシェイキングの代わりではないことです。 遅延chunkの中がCommonJS中心なら、そのchunk自体は重いままです。 「初回chunkから外す」と「遅延chunkの中で未使用コードを落とす」は別の作業として測ります。

実務ユースケース4: npmパッケージを配布する

自作ライブラリを配るなら、利用者のバンドラーが読みやすい形で出します。 mainだけをCommonJSに向けるより、exportsでESM入口を明示し、型定義も対応させます。

{
  "name": "@masa/formatters",
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./currency": {
      "types": "./dist/currency.d.ts",
      "import": "./dist/currency.js"
    }
  }
}

ただし、CSS、polyfill、custom element登録、analytics初期化のようなファイルがあるなら、sideEffectsに配列で残します。 「ライブラリ全体が純粋」と言える場合だけfalseにしてください。

失敗例と落とし穴

失敗例はだいたい似ています。 レビューでは、次の項目をClaude Codeに表で確認させます。

落とし穴症状対策
BabelやtsconfigでCommonJSへ変換未使用exportが残る本番バンドル前はESNext/ESMを維持
sideEffects: falseが強すぎるCSS、polyfill、登録処理が消える副作用ファイルを配列で列挙
default object export使わない関数もobject生成に残るnamed exportへ分割
barrel fileのトップレベル処理importしただけで重くなるbarrelを再exportだけにする
dev buildで測るサイズが減って見えないproduction build、minify、gzipで比較
moduleSideEffects: falseを全体適用初期化コードが消える依存ごと、ファイルごとに検証
名前空間import解析が保守的になる必要なnamed importへ寄せる

特に危ないのは、見た目が少し壊れる程度のCSS欠落です。 テストがDOMの存在だけを見ていると通ってしまいます。 パフォーマンス最適化と同じで、サイズ削減は表示確認、E2E、計測ログまで含めて完了です。

測定ワークフローをCIに入れる

一度軽くしても、次の依存追加ですぐ戻ります。 最低限、サイズ予算をCIに入れてください。

// scripts/check-bundle-budget.mjs
import { statSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";

const file = "dist/assets/index.js";
const maxGzipBytes = 160 * 1024;
const raw = readFileSync(file);
const gzipBytes = gzipSync(raw).byteLength;

if (gzipBytes > maxGzipBytes) {
  console.error(`Bundle budget exceeded: ${gzipBytes} > ${maxGzipBytes}`);
  process.exit(1);
}

console.log({
  file,
  bytes: statSync(file).size,
  gzipBytes
});

Viteなら、まず通常ビルドでdistを作ります。

npm run build
node scripts/check-bundle-budget.mjs

予算値は、最初から理想値にしない方が続きます。 現在のgzipサイズに10%程度の余白を置き、新しいPRで増えたら理由を書く運用にします。 大きく増えたときは、速度改善の記事で扱う画像、フォント、API待ち時間も同時に見てください。

Claude Codeレビュー用チェックリスト

実装後は、次のチェックをそのまま依頼できます。

ツリーシェイキング改善PRをレビューしてください。
1. 未使用exportが本当に本番bundleから消えたか
2. CSS、polyfill、custom element登録が消えていないか
3. ESMがCommonJSへ早すぎる段階で変換されていないか
4. 直接importに変えたことで公開API互換性を壊していないか
5. build、unit test、主要画面の表示確認、bundle budgetの結果
以上を、証拠となるファイル名とコマンド結果つきで報告してください。

このチェックリストを使うと、単なる置換作業ではなく、公開前レビューとして品質を見られます。 Masaの案件でも、sideEffects設定の修正だけで終わらせず、管理画面、ログイン画面、請求画面を開いてCSS欠落がないか確認するようにしています。

収益化につながる見方

ツリーシェイキングは、SEOだけの施策ではありません。 初回ロードが軽いほど、記事、LP、SaaSの無料登録フォームまでの離脱が減り、広告や問い合わせの機会損失を抑えられます。 特にClaudeCodeLabのような技術メディアでは、コード例ページ、商品LP、相談フォームが重いと収益導線が弱くなります。

ClaudeCodeLabでは、既存のVite、Next.js、Astro、社内UIライブラリを対象に、バンドル分析、ツリーシェイキング、コード分割、CI予算化までをまとめて診断できます。 相談時は、package.json、ビルド設定、主要route、直近のbundle reportがあると、短時間で削減候補を洗い出せます。

まとめ

ツリーシェイキングは、ES Modules、正しいsideEffects、副作用を閉じ込めた設計、継続的な測定がそろって初めて効きます。 Claude Codeには「全部軽くして」と頼むより、測定、分割、import修正、失敗例レビューを小さく任せる方が安全です。

この記事で紹介した最小サンプルは、Masaの手元でnpm run measureまで実行し、bad entryとgood entryの出力サイズ差を確認しました。 実案件では数字が依存関係に左右されるため、必ず自分のproduction buildで測り、削ったコードより残すべき副作用を明確にしてから公開してください。

#Claude Code #ツリーシェイキング #バンドルサイズ #ES Modules #フロントエンド最適化
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。