← 一覧へ戻る

100 ツールの React アプリのメインバンドルを 6 MB から 307 KB にした方法

2026-05-25

Vite のビルド出力は何ヶ月も前からこれを伝えていました:

(!) Some chunks are larger than 500 kB after minification.

私たちは無視していました。サイトはブロードバンドでは普通に動きます。背景除去 AI は呼び出されたときに 30 MB の WebAssembly ファイルを読み込みます — それに比べれば 6 MB の JavaScript なんて小さいはずだと。

そんなことはありませんでした。

ある友人が Pixel スマホで不安定なホテル Wi-Fi 経由で toolkoala.com を訪れました。要素のレンダリング遅延だけで Largest Contentful Paint が 2.5 秒 — ネットワーク時間は含まれていません。Lighthouse の監査は「render-blocking resources」と「reduce unused JavaScript」で点数を下げ続けていました。極めつけは:Google Search Console が Core Web Vitals レポートでサイトに黄色信号を出し始めたこと。

そこで腰を据えてビルド出力をちゃんと読みました。

dist/assets/index-CdRXzQJL.js  5,998 kB │ gzip: 1,755 kB

6。メガ。バイト。の JavaScript。何もクリックする前にホームページの訪問者全員に配信。

この記事は、これを 307 KB raw / 92 KB gzipped に — 95% 削減 — そして LCP を約 2.5 秒から 1.5 秒以下に下げたエンジニアリングログです。監査グレードは F から A へ。サイト自体は変わっていません。

6 MB の中身

最初に有用だったのは npx vite-bundle-visualizer を実行すること(あるいはビルド出力をサイズ順に見るだけ)。大物:

  • 70+ ツールコンポーネント全部(RemoveBackgroundPdfMergeLoremIpsum…)
  • 8 つの i18n ロケール JSON(英語 + 翻訳 7 種、各約 300 KB)
  • pdf-lib + pdfjs-dist(合計約 500 KB)— 30 の PDF ツールが使用
  • UPNG(200 KB)— 推移的に引き込まれる
  • markeddiffjszippptxgenxlsxmammoth — 各 100-500 KB

これらの多くは、ユーザーがまだ使っていないツールに属するものでした。それらはユーザーが lorem ipsum ジェネレーターを見た後で PDF マージャーをクリックするかもしれない、その場合のためにダウンロードされていました。

これはデフォルトの React パターンで、Vite はそれを許可します:

import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … あと 65 行

<Routes>
  <Route path="/remove-background" element={<RemoveBackground />} />
  <Route path="/pdf-merge" element={<PdfMerge />} />
  // …
</Routes>

すべての import 文は「このコードをすべてのページに配信する」と言っています。これが 70 ツールを 1 つの 6 MB バンドルにする仕組みです。

修正 1:ルートレベルのコード分割(〜5.7 MB 削減)

これに対する標準的な React の修正は React.lazy()Suspense です。Vite が既に設定している rollup ベースのバンドラーは、動的インポートごとに 1 チャンクを自動的に生成します。

すべてのツールインポートを変換しました:

const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …

その後、ルートを包む:

<Suspense fallback={<div className="loading">Loading…</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/remove-background" element={<RemoveBackground />} />
    // …
  </Routes>
</Suspense>

HomeNav は eagerly imported のままにしました。ホームページは最も一般的な着地点で、ナビゲーションはどのページでもすぐにレンダリングする必要があります。

この単一の変更後:メインバンドルは 5,998 KB → 3,025 KB。各ツールは独自のチャンクに:それぞれ 3-50 KB、ユーザーがナビゲートしたときにオンデマンドで取得。

トレードオフ:初めてツールを訪れると、そのツールのチャンクを取得するために 1 つのネットワークラウンドトリップが余分にかかります。しかしチャンクは小さく(gzip 後は 1 桁 KB が一般的)、現代の HTTP/2 は複数の並列リクエストにラウンドトリップコストを償却します。実世界の接続では、遅延の増加は見えません。

勝利は最初のページロード — Largest Contentful Paint と初回訪問者の離脱率にとって重要なもの。それを半分にしました。

修正 2:import.meta.glob でロケールを遅延読み込み(〜2 MB 削減)

バンドルの次の大物は国際化でした。8 言語をサポートし、各言語は約 300 KB の JSON ファイルで、100+ ツールのタイトル、FAQ、機能説明の翻訳が含まれます。元の設定は素朴な方法でした:

import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
import zhTW from './locales/zh-TW.json'
import ja from './locales/ja.json'
// … あと 4 つ

i18n.init({
  resources: {
    en: { translation: en },
    'zh-CN': { translation: zhCN },
    // …
  }
})

8 ファイル全部(〜2.5 MB raw、〜700 KB gzip)がメインバンドルにインライン化。フランス語話者が英語ホームページを訪れて韓国語の翻訳をダウンロード。

Vite には import.meta.glob という素晴らしいプリミティブがあり、約 10 行で修正できます:

const localeLoaders = import.meta.glob('./locales/*.json')
// localeLoaders は今こうなっています:
// {
//   './locales/en.json': () => import('./locales/en.json'),
//   './locales/zh-CN.json': () => import('./locales/zh-CN.json'),
//   …
// }

const initial = detectLangFromPath() || 'en'
const [enData, activeData] = await Promise.all([
  localeLoaders['./locales/en.json'](),
  initial !== 'en' ? localeLoaders[`./locales/${initial}.json`]() : null,
])

i18n.init({
  resources: {
    en: { translation: enData.default },
    [initial]: { translation: activeData?.default },
  },
  lng: initial,
})

Vite は各 import('./locales/<x>.json') 呼び出しを見て、ロケールごとに別チャンクを生成。起動時にはユーザーの現在の言語(と英語のフォールバック)のみがロードされます。

フォールバックは小さい(英語は eagerly ロードして、他言語の翻訳欠落でクラッシュしないように)。ユーザーが言語を手動で切り替えるとき、i18n.changeLanguage をパッチしてオンデマンドで取得:

const origChangeLanguage = i18n.changeLanguage.bind(i18n)
i18n.changeLanguage = async (lng) => {
  if (!i18n.hasResourceBundle(lng, 'translation')) {
    const data = await loadLocale(lng)
    i18n.addResourceBundle(lng, 'translation', data, true, true)
  }
  return origChangeLanguage(lng)
}

注意点:これにより i18n 初期化が非同期になり、ロケールが解決するまで React がレンダリングできません。Vite の ESM での top-level await サポートを使いました:

// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)

Top-level await は現代ブラウザでサポート済み(Chrome 89+、Safari 15+、Firefox 89+)。古いブラウザは Vite のビルドターゲットがポリフィルを制御します。IE11 が必要なら Suspense ベースのローダーで。

この修正後:メインバンドル 3,025 KB → 307 KB(gzip 1,755 KB → 92 KB)。クリティカルパスから 2.7 MB のロケールデータを下ろしました。

修正 3:二次的なリーク — PdfToolShell(〜600 KB 削減)

最初の 2 つの修正後も、PageSpeed Insights は「Reduce unused JavaScript」を特定の犯人付きでフラグしました:

  • pdf-*.js — 116 KB、96% 未使用
  • UPNG-*.js — 118 KB、28% 未使用

なぜ PDF ライブラリがホームページで読み込まれているのか?

PdfToolShell という共有コンポーネントがあり、30+ の PDF ツールルート(PDF マージ、分割、回転など)をラップしています。それ自体はツールではなく、再利用可能なシェルです。App.jsx で静的インポートされていました:

import PdfToolShell from './components/PdfToolShell'

PdfToolShell の先頭はこう:

import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'

PDF スタック全体がメインバンドルにホイストされていました — ユーザーが /lorem-ipsum を訪問していてさえも。

修正は同じ lazy() 治療:

const PdfToolShell = lazy(() => import('./components/PdfToolShell'))

教訓:ツールコンポーネントを遅延読み込みするだけでは不十分 — 共有「シェル」コンポーネントがまだ重い依存関係を静的インポートしているなら。すべての静的インポートを、推移的に何を引き込むかで監査してください。

修正 4:ロゴ(640 KB 削減)

これは恥ずかしい。サイトのロゴは 1024×1024 PNG、640 KB。ナビバーでは 32×32 ピクセルで表示。誰もそれを疑問視していませんでした。

修正にはこの段落を読むより短い時間:自前の 画像圧縮 ツールに logo.png をドロップしました。スライダーはすでに 80% 品質(デフォルト)。Download を押しました。

結果:6.9 KB。99% 削減、32×32 では目に見える違いなし — 256×256(同じロゴをソーシャルカードに使用)でも違いなし。

ソーシャルカード(Twitter、Facebook、LinkedIn のプレビュー画像)用に、ロゴが ~256 px に拡大されたときにシャープに見えるようにしたかったので、1024×1024 を 画像リサイズ ツールにドロップして 256×256 を設定、それを 画像圧縮 で 90% 品質を通して、64 KB の logo-256.png を得ました。og:image メタタグはそれを指します。

2 つのツール、2 分、すべてのページロードで 633 KB の節約。

このスマホでこれを読んでいて、上のロゴが正常に見えるなら:それは 6.9 KB の画像です。640 KB バージョンも同じに見えました。

自分のサイトに 50 KB を超えるロゴがあるなら、ToolKoala 画像圧縮 はここにあります。ドロップして、before/after プレビューを見て、小さい方をダウンロード。アップロードなし — ロゴは私たちのサーバーに行きません、ブラウザで処理されます。これがプロジェクト全体の核心で、私たちが自分自身に使ったその部分も含めて。

修正 5:Google Fonts 依存を削除(〜1.2 秒 LCP 遅延削減)

<head> は皆さんが見た web アプリと同じでした:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">

PageSpeed はこれから 1,258 ms のクリティカルパス遅延を報告。ブラウザは:

  1. fonts.googleapis.com を解決(DNS、TLS)
  2. CSS ファイルをダウンロード(リダイレクトだらけの @font-face ブロックの混乱)
  3. fonts.gstatic.com を解決(DNS、TLS 再び、異なる origin!)
  4. 実際の .woff2 ファイルをダウンロード

ユーティリティサイトには、Inter は素敵だが必要ない。CSS にすでに完璧なフォールバックがあります:

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
             system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;

macOS では San Francisco(Apple のシステムフォント)、Windows では Segoe UI、Android では Roboto に解決。3 つとも OS にプリインストールされた優秀なフォント。ネットワーク帯域 ゼロ、ロード時間 ゼロ です。

Google Fonts のリンクを削除しました。サイトの見た目は約 95% 同じ — Linux で良い sans-serif がインストールされていない場合は異なりますが、それは小さなユーザーで、フォールバックも優雅に劣化します。

PageSpeed の「クリティカルパス遅延」は 1.2 秒削減。

修正 6:バンドル CSS をインライン化(160 ms 削減)

残る render-blocking リソースは Vite が出力した CSS バンドル:

<link rel="stylesheet" href="/assets/index-CcF97ram.css">

5.4 KB。典型的な接続でラウンドトリップ約 160 ms — 初回ペイントをブロック。

この程度の小さい CSS バンドルなら、HTML にインライン化する方がキャッシングに勝ります。インライン化のコストは、すべての HTML ページが 5.4 KB を持ち運ぶこと。利点は初回ロードでブロッキングリクエストが 1 つ少ないこと。

ビルドスクリプト(SSR 用に Vite 出力をポスト処理)が CSS ファイルを読み、<style> ブロックで置換するようになりました:

const cssMatch = baseHtml.match(/<link\s+rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/)
if (cssMatch) {
  const cssPath = join(DIST, cssMatch[1].replace(/^\//, ''))
  const css = readFileSync(cssPath, 'utf8')
  baseHtml = baseHtml.replace(cssMatch[0], `<style>${css}</style>`)
}

注意:私たちの 5 KB のスタイルシートではこれで OK。CSS が 100 KB あるなら、インライン化はページ間キャッシングを台無しにします。外部のまま(または critical CSS のみ抽出)にしてください。

故意に修正しなかったこと

Google AdSenseGoogle Analytics gtag。両方とも async スクリプトで、PageSpeed は依然として forced reflow と高い転送サイズをフラグします。両方合わせて 500+ ms のクリティカルパス遅延です。

しかし AdSense はサイトの資金源です。それを削除するのはエンジニアリングのトレードオフではなく、ビジネス決定です。Analytics も同様 — それなしでは、どのツールが人気で、どのバグに誰もヒットしていないかについて手探りです。

原理的には AdSense を LCP 要素のペイント後に遅延させることができます。半時間試しました。AdSense 自身の注入ロジックをオーバーライドする必要があり、広告がペイント後に読み込まれることで視覚的なちらつきが発生しました。〜200 ms の節約に見合うエンジニアリング複雑さではありません。

広告なしサイトを最適化する場合、選択肢は私たちより多い。私たちはそれを受け入れざるを得ませんでした。

結果

https://www.toolkoala.com/ のコールドキャッシュ初回ペイント(基本ベンチマーク):

指標 変化
メインバンドル (raw) 5,998 KB 307 KB -95%
メインバンドル (gzip) 1,755 KB 92 KB -95%
ロゴ PNG 640 KB 7 KB -99%
ホームでの合計 JS 〜3 MB 544 KB -82%
Render-blocking CSS 160 ms 0 ms (インライン化) -100%
Google Fonts ブロッキング 1,258 ms 0 ms -100%
FCP 〜2.4 s 256 ms -89%
LCP / DOM Complete 〜2.5 s 1,295 ms -48%
Lighthouse パフォーマンスグレード D A (6/6 予算)

サイト自体は変更なし — 同じ React コンポーネント、同じ UI、同じバックエンド。すべての修正は設定またはビルドプロセスの変更でした。

React + Vite アプリを出荷する場合の持ち帰り

  1. すべてのリリースでバンドル出力を見る。 CI ログの 2 行です。500 KB を超えるものがあれば調査。Vite は無料で教えてくれます。

  2. React.lazy() + Suspense は最も安いパフォーマンス勝利。 ルートレベル分割は 10 分で実装でき、通常メインバンドルの 50-90% を節約。

  3. import.meta.glob は「同じ種類の多くのファイル」を遅延読み込みする Vite のスーパーパワー — ロケール、アイコン、ブログ記事など。長いリストの静的インポートの代わりに使ってください。

  4. 推移的インポートを監査。 重いライブラリを静的インポートする共有「シェル」コンポーネントは、すべてのルートレベル遅延読み込み努力を台無しにします。PdfToolShell バグは、私たちが気づくまで、すべてのページで 600 KB チャンクを背負わせました。

  5. あなたのロゴは大きすぎる可能性。 ほとんどのサイトのロゴは ≤64 px で表示。≤10 KB であるべき。ImageMagick がインストールされていなくても、まさにこのサイトのリサイズツールを使ってください。

  6. すべての外部フォント読み込みを疑問視。 macOS、Windows、Android のシステムフォントはすべて優秀。あなたのブランドが特定の書体に依存しないなら(ほとんどはしない)、Google Fonts リンクを完全に削除してください。

  7. 小さい CSS をインライン化。 ~10 KB 以下なら、リクエストスキップで節約される遅延が、外部ファイルのキャッシング利点を上回ります。

  8. AdSense と Analytics はほとんどあなたのコントロール外。 受け入れて、できることを最適化してください。

これは合計約 6 時間のエンジニアリング作業、2 日間に分散。最大の改善(ルート分割 + ロケール分割)は各約 2 時間。残りの 4 時間は測定、回帰テスト、この記事を書くことでした。

これが有用なら、よりクリーンなビルドから出てきたツールはまだここにあり、まだ無料で、まだブラウザで完全に実行されています:

  • 画像圧縮(はい、ロゴ含む)
  • PDF ツール(遅延読み込みの洞察を引き起こしたもの)
  • 動画圧縮(FFmpeg.wasm、これも遅延読み込み)
  • OCR(Tesseract.js、これも遅延読み込み)
  • もっとエンジニアリングノートのある完全なブログ

通常の接続では、すべて 1 秒以内に読み込まれます。今は。