はじめに:なぜ、あなたのバンドル戦略は「破綻」するのか
フロントエンドのビルドツールとしてWebpackが選ばれる理由、その一つに「プラグインと最適化機構の圧倒的な拡張性」がある。しかし、多くの現場で `optimization.splitChunks` の設定は、チュートリアルのコピペや、なんとなく動くマジックナンバーの寄せ集めで放置されている。
結果として何が起きるか。わずか数行のコンポーネントの修正を行っただけで、数十MBに及ぶ巨大な `vendor` チャンクのハッシュ値が変わり、CDNのキャッシュが無効化される。ユーザーは毎回、変更のないサードパーティ製ライブラリ(React、Lodash、UIフレームワークなど)を数メガバイトもダウンロードし直させられている。
真のDevOpsおよびフロントエンドアーキテクトにとって、ビルド成果物のバンドル構造は「アプリケーションの動脈」そのものである。ブラウザのキャッシュヒット率(Cache Hit Ratio)を極限まで高め、ネットワーク帯域を節約し、Time to Interactive (TTI) を最小化するためのキャッシュグループ戦略を、Webpackの内部レイヤから紐解いていこう。
—
1. Webpack内部アーキテクチャ:SplitChunksPluginの挙動原理
`SplitChunksPlugin` は、魔法のようにコードを分割しているわけではない。内部的には、Module Graph(モジュールグラフ)と Chunk Graph(チャンクグラフ)の構築フェーズにおいて、「既存のチャンク間で共有されているモジュール」や「サイズが一定以上のモジュール」を自動的、あるいは定義されたルールに従って抽出するヒューリスティックなアルゴリズムを実行している。
デフォルト挙動の罠
Webpack 4以降のデフォルト設定では、以下の条件を満たすモジュールが自動的に分割対象となる。
- 共有されている、または `node_modules` からインポートされている。
- 圧縮前で 20KBより大きい。
- 読み込み時のリクエスト数が平行で 30以下(initial)、または 5以下(async)。
このデフォルト値は「汎用的なスタート地点」に過ぎず、大規模なエンタープライズアプリケーションにおいて最適解であることは絶対にあり得ない。なぜなら、ビジネスロジックの変更頻度と、サードパーティ製ライブラリの更新頻度の乖離を全く考慮していないからだ。
—
2. 実務の極み:キャッシュ効率を最大化する `splitChunks` 構成術
ここから示すのは、筆者が数々の大規模プロダクトで導入し、キャッシュヒット率を 98% 以上に維持し続けているプロダクションレディな `webpack.config.js` の最適解である。
const path = require(‘path’);
const CompressionPlugin = require(‘compression-webpack-plugin’);
module.exports = {
mode: ‘production’,
entry: {
app: ‘./src/index.js’,
},
output: {
filename: ‘[name].[contenthash:8].js’,
chunkFilename: ‘[name].[contenthash:8].chunk.js’,
path: path.resolve(__dirname, ‘dist’),
clean: true, // ビルド前にdistディレクトリをクリーンアップし、不要な孤立アセットを残さない
},
optimization: {
moduleIds: ‘deterministic’, // モジュールIDの変更によるvendorハッシュの汚染を防ぐ(重要)
chunkIds: ‘deterministic’,
runtimeChunk: ‘single’, // Webpackのランタイムコードを分離し、appのハッシュ変動から切り離す
splitChunks: {
chunks: ‘all’, // 初期チャンクと非同期チャンクの両方を最適化対象に含める
minSize: 20000, // 分割対象となるモジュールの最小サイズ(20KB)
maxSize: 244000, // HTTP/2環境下における理想的なチャンクサイズの上限(約240KB。並列度とキャッシュ効率のバランス点)
minChunks: 1, // モジュールが共有されていなくても分割を許可
maxAsyncRequests: 30, // 非同期読み込み時の最大平行リクエスト数
maxInitialRequests: 30, // エントリーポイントでの最大平行リクエスト数
automaticNameDelimiter: ‘~’,
cacheGroups: {
// 1. フレームワーク層:滅多に更新されないコアライブラリを完全固定化
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: ‘vendor-framework’,
priority: 40, // 評価優先度を最高に設定
enforce: true,
},
// 2. UIコンポーネントライブラリ層:巨大になりがちなデザインシステム等を分離
uiLibrary: {
test: /[\\/]node_modules[\\/](@mui|antd|styled-components|framer-motion)[\\/]/,
name: ‘vendor-ui’,
priority: 30,
enforce: true,
},
// 3. 一般的なサードパーティユーティリティ層(Lodash, Axiosなど)
libs: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// パッケージ名を安全に抽出してチャンク名動的生成(ハッシュ汚染を防ぐため粒度を調整)
const packageName = module.context.match(
/[\\/]node_modules[\\/](.?)([\\/]|$)/
)[1];
// スコープ付きパッケージ(例: @babel/runtime)に対応
return `npm.${packageName.replace(‘@’, ”)}`;
},
priority: 20,
minChunks: 1,
reuseExistingChunk: true,
},
// 4. アプリケーション共通ロジック層
commons: {
name: ‘commons’,
minChunks: 2, // 2つ以上のエントリー/動的チャンクから参照されていること
priority: 10,
reuseExistingChunk: true,
},
},
},
},
plugins: [
// Brotli/Gzip圧縮をビルド時に完結させ、Nginx等の負荷を削減
new CompressionPlugin({
filename: ‘[path][base].br’,
algorithm: ‘brotliCompress’,
test: /\.(js|css|html|svg)$/,
compressionOptions: {
level: 11, // 最高圧縮率
},
threshold: 10240, // 10KB以上のファイルのみ対象
minRatio: 0.8,
}),
],
};
この設定がもたらすアーキテクチャ上の優位性
1. `moduleIds: ‘deterministic’` の強制: Webpack 5のこの機能は、モジュールの追加・削除によって他のモジュールIDが連鎖的に変わる現象を防ぐ。これにより、コードを1行変えても、関係のないモジュールのハッシュ値が一切変化しなくなる。
2. `runtimeChunk: ‘single’` によるハッシュの完全分離: Webpack自体のローダやモジュール解決マッピングを含むランタイムコードを `runtime.[hash].js` として分離する。これを行わないと、`app.js` のコードがわずかに変わっただけで、ランタイム内のモジュールIDマップが書き換わり、`vendor` チャンクのハッシュまで連鎖的に変わってしまう。
3. パッケージ単位での細粒度分割 (`libs` キャッシュグループ): すべての `node_modules` を単一の `vendor.js` に押し込むのはアンチパターンである。数メガバイトの単一巨大vendorは、一部のライブラリ(例: Lodash)をアップデートしただけで全体が無効化される。パッケージ名ごとに細かく分割することで、「更新されたライブラリのみ」を再ダウンロードさせることが可能になる。
—
3. 応用編:ルート単位の動的インポートとCI/CDパイプライン連携
静的なコード分割に加え、大規模SPAでは「ユーザーがアクセスした画面(ルート)のコードのみを遅延ロードする」動的インポート(Dynamic Import)が不可欠である。
ルート単位の非同期チャンク設計
Reactの `React.lazy` や Vueの `defineAsyncComponent` を用いる際、Webpackは自動的にそれを独立したチャンク(非同期チャンク)として切り出す。
// 例:Reactでのルート単位の遅延ロード
import React, { lazy, Suspense } from ‘react’;
const DashboardView = lazy(() => import(/ webpackChunkName: “view-dashboard” / ‘./views/Dashboard’));
const SettingsView = lazy(() => import(/ webpackChunkName: “view-settings” / ‘./views/Settings’));
function App() {
return (