JavaScript
webpack

webpack 4 の optimization.splitChunks の使い方、使い所 〜廃止された CommonsChunkPlugin から移行する〜

はじめに

先日リリースされたwebpack 4でCommonsChunkPluginは廃止されました(CommonsChunkPluginの解説はこちら)。

その代わりとした追加されたoptimization.splitChunksの基本的な使い方、使い所に関しての記事です。
optimization.splitChunksを利用すればサイトパフォーマンスの改善(詳細は後述)ができるため、利用する機会が多い設定(機能)です。

解説に利用しているコードの最終形態はGitHub上にあります。
hira777/webpack-split-chunks-example

webpackを理解していることを前提とした記事ですので、基礎知識を習得したい方はwebpack 4入門をご覧ください。

optimization.splitChunksとは

「複数のエントリーポイント間で利用している共通モジュールをバンドルしたファイル」を出力するための設定のこと。
設定はwebpack.config.jsに記述する。

通常の出力とoptimization.splitChunksを有効にした出力の違い(イメージ図)

以下は通常の出力

webpack.jpg

以下はoptimization.splitChunksを有効にした出力

splitChunks.png

なぜoptimization.splitChunksを有効にするのか

上記のイメージ図のように共通ファイルを出力するため、以下のようなメリットがあるから。

  • 複数のエントリーポイントが1つの共通ファイルを利用するため、全体のファイルサイズが小さくなる
  • 共通ファイルなのでキャッシュを活用できる(1度読み込めば別ページに遷移しても読み込みが不要、共通ではないファイルだけ読み込めば良い)

optimization.splitChunksを有効にしてみる

webpack.config.jsoptimization.splitChunksの設定を記述して共通のモジュールをバンドルしたファイルを出力してみる。

ディレクトリ構成

.
├── package.json
├── public
│   ├── index.html
│   ├── index2.html
│   ├── index3.html
│   └── js
│       ├── app.bundle.js
│       ├── app2.bundle.js
│       ├── app3.bundle.js
│       └── vendor.bundle.js
├── src
│   └── js
│       ├── app.js
│       ├── app2.js
│       └── app3.js
└── webpack.config.js

各ファイルの詳細

package.json

package.json
{
  "name": "webpack-split-chunks-example",
  "version": "1.0.0",
  "devDependencies": {
    "webpack": "^4.1.1",
    "webpack-cli": "^2.0.11"
  },
  "dependencies": {
    "jquery": "^3.1.1",
    "velocity-animate": "^1.5.0"
  }
}

上記のpackage.jsonが存在する階層で以下のコマンドを実行すれば、必要なパッケージ(モジュール)をインストールできる。

npm install

or

yarn install

webpack.config.js

webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  },
  optimization: {
    splitChunks: {
      name: 'vendor',
      chunks: 'initial',
    }
  }
};

以下がoptimization.splitChunksに関する記述である。

optimization: {
  splitChunks: {
    name: 'vendor',
    chunks: 'initial',
  }
}

name

共通モジュールをバンドルしたファイル(チャンク)の名前。今回は'vendor'を指定したため、出力されるファイル名はvendor.bundle.jsになる。

trueにすると自動的に名前がつけられる。

chunks

共通モジュールとしてバンドルする対象の設定。initialasyncallが存在する。

基本的な利用であればinitialを指定しておけば良い。

バンドルしたファイルを複数に分割して出力し、非同期で読み込む場合などは状況に応じてasyncallを指定する。

app.jsapp2.js

エントリーポイントであるapp.jsapp2.jsは以下の処理を記述している。

  • セレクタを指定し、文字列を出力。
  • 指定したセレクタをフェードアウトする。
app.js
import $ from 'jquery';
import velocity from 'velocity-animate';

const text = 'app';
const $body = $('body');

$body.html(text);
velocity($body, 'fadeOut', {
  duration: 1000
});
app2.js
import $ from 'jquery';
import velocity from 'velocity-animate';

const text = 'app2';
const $body = $('body');

$body.html(text);
velocity($body, 'fadeOut', {
  duration: 1000
});

どちらも外部モジュールであるjqueryvelocity-animateを利用している。
そのため、jqueryvelocity-animateは複数のエントリーポイント間で利用されている共通のモジュールである。

app3.js

エントリーポイントであるapp3.jsは以下の処理を記述している。

app3.js
const body = document.getElementsByTagName('body')[0];
body.innerText = 'Body!!';

body要素に文字列を出力しているだけのため、モジュールは何も利用していない。

webpackコマンドでバンドルしたファイルを出力

上記構成のwebpack.config.jsが存在する階層でwebpack --mode production(もしくはwebpack --mode development)コマンドを実行すれば、それぞれバンドルしたファイルが出力される。

何が共通モジュールなのかはwebpackが自動で判別し、共通モジュールをバンドルしたファイル(今回はvendor.bundle.js)を出力する。

今回の設定だと以下のファイルがpublic/js/に出力される。

  • app.bundle.js
  • app2.bundle.js
  • app3.bundle.js
  • vendor.bundle.js
webpack --mode production

# 以下のような出力がされる
Hash: bec43aa90f505c7fb3ef
Version: webpack 4.1.1
Time: 486ms
Built at: 2018-3-14 22:00:14
           Asset       Size  Chunks             Chunk Names
vendor.bundle.js    127 KiB       0  [emitted]  vendor
  app3.bundle.js  606 bytes       1  [emitted]  app3
  app2.bundle.js   1.15 KiB       2  [emitted]  app2
   app.bundle.js   1.15 KiB       3  [emitted]  app
Entrypoint app = vendor.bundle.js app.bundle.js
Entrypoint app2 = vendor.bundle.js app2.bundle.js
Entrypoint app3 = app3.bundle.js
   [2] ./src/js/app3.js 81 bytes {1} [built]
   [3] ./src/js/app2.js 418 bytes {2} [built]
   [4] ./src/js/app.js 416 bytes {3} [built]
    + 2 hidden modules

どのエントリーポイントの共通モジュールがバンドルされたのかは、コマンド実行時の出力で確認できる。
上記の出力を確認してみると、vendor.bundle.jsapp.bundle.jsapp2.bundle.jsの共通モジュールをバンドルしている(依存関係を持っている)ことがわかる。

Entrypoint app = vendor.bundle.js app.bundle.js
Entrypoint app2 = vendor.bundle.js app2.bundle.js
Entrypoint app3 = app3.bundle.js

出力されたファイルを確認してみる

それぞれのエントリーポイントから出力されたファイルを読み込んでいるHTMLは以下の通り。

index.html

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
  <script src="js/vendor.bundle.js"></script>
  <script src="js/app.bundle.js"></script>
</body>
</html>

app.gif

index2.html

index2.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
  <script src="js/vendor.bundle.js"></script>
  <script src="js/app2.bundle.js"></script>
</body>
</html>

app2.gif

index3.html

index3.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
  <script src="js/app3.bundle.js"></script>
</body>
</html>

app3.jpg

全て正常に動作していることがわかる。

今回の場合、共通モジュールを利用しているapp.bundle.jsapp2.bundle.jsvendor.bundle.jsも読み込むと正常に動作する。

app3.bundle.jsは共通モジュールを利用していないため、vendor.bundle.js自体を読み込む必要がなく、正常に動作する。

optimization.splitChunksを有効にしないとどのようなファイルが出力されるのか

通常の出力と何が違うのかを理解するために、optimization.splitChunksを有効にしないとどのようなファイルが出力するのか見てみる。
以下はoptimization.splitChunksの記述を削除したwebpack.config.js

webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    app: './src/app.js',
    app2: './src/app2.js',
    app3: './src/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  }
};

上記の設定でwebpack --mode production(もしくはwebpack --mode development)コマンドを実行すると以下のようなファイルが出力される。

webpack

# 以下のような出力がされる
Hash: fb1a840574c4d608386d
Version: webpack 4.1.1
Time: 4258ms
Built at: 2018-3-14 23:57:42
         Asset       Size  Chunks             Chunk Names
app3.bundle.js  606 bytes       0  [emitted]  app3
app2.bundle.js    128 KiB       1  [emitted]  app2
 app.bundle.js    128 KiB       2  [emitted]  app
Entrypoint app = app.bundle.js
Entrypoint app2 = app2.bundle.js
Entrypoint app3 = app3.bundle.js
   [2] ./src/app3.js 81 bytes {0} [built]
   [3] ./src/app2.js 180 bytes {1} [built]
   [4] ./src/app.js 179 bytes {2} [built]
    + 2 hidden modules

このファイルでもブラウザ上で正常に動作するが、モジュールを利用しているapp.jsapp2.jsのそれぞれにjqueryvelocity-animateがバンドルされて出力されてしまっている
そのため、先ほどoptimization.splitChunksを有効にして出力したファイルと比べ、以下の問題点がある。

  • エントリーポイントを更新する度にjqueryvelocity-animateもバンドルしたファイルが更新されるため、キャッシュを活用できない
  • app.bundle.jsapp2.bundle.jsのファイルサイズが大きくなってしまう
  • それぞれのエントリーポイントに同じモジュールを読み込んでいるため、無駄にファイルサイズが大きくなっている

ということでoptimization.splitChunksを有効にすれば、キャッシュを活用するだけではなく、全体のファイルサイズも少なくできる。

optimization.splitChunksの様々な設定や使い方

上記は最低限の利用方法のため、利用シーンに応じた設定や使い方をいくつか紹介する。

optimization.splitChunksでバンドルする共通モジュールを指定する

特に指定をしなければoptimization.splitChunksは自動で共通するモジュールをバンドルして出力をする。
今回はjqueryvelocity-animateが共通で利用されているため、それらがバンドルされて出力される。
自動で共通モジュールをバンドルする機能は便利だが、その機能がこちらの意図に削ぐわない時もある。

前述した構成にユーザーが作成したmoduleA.jsを追加するシーンを想定してみる。
ディレクトリ構成は以下のようになる。

.
├── README.md
├── package.json
├── public
│   ├── index.html
│   ├── index2.html
│   ├── index3.html
│   └── js
│       ├── app.bundle.js
│       ├── app2.bundle.js
│       ├── app3.bundle.js
│       └── vendor.bundle.js
├── src
│   └── js
│       ├── app.js
│       ├── app2.js
│       ├── app3.js
│       └── modules
│           └── moduleA.js
└── webpack.config.js

moduleA.jsの内容は以下の通り。

moduleA.js
export default function () {
  console.log('moduleA!!!');
  console.log('moduleA!!!');
  console.log('moduleA!!!');
  console.log('moduleA!!!');
  console.log('moduleA!!!');
};

上記モジュールを以下のようにapp.jsapp2.jsで読みこんで利用する。

app.js
import $ from 'jquery';
import velocity from 'velocity-animate';
import moduleA from './modules/moduleA';

const text = 'app';
const $body = $('body');

$body.html(text);
velocity($body, 'fadeOut', {
  duration: 1000
});
moduleA();
app2.js
import $ from 'jquery';
import velocity from 'velocity-animate';
import moduleA from './modules/moduleA';

const text = 'app2';
const $body = $('body');

$body.html(text);
velocity($body, 'fadeOut', {
  duration: 1000
});
moduleA();

この状態でwebpack --mode production(もしくはwebpack --mode development)コマンドを実行するとjqueryvelocity-animatemoduleA.jsがバンドルされたvendor.bundle.jsが出力される。

自動で共通のモジュールがバンドルされたが、moduleA.jsが頻繁に更新されるモジュールであったり、moduleB.jsのような新しいモジュールが追加される度にvendor.bundle.jsが更新されてしまうため、キャッシュをあまり活用できない

jqueryvelocity-animateのような頻繁に更新しない外部モジュール(node_modules配下のモジュール)のみをバンドルしたい場合、以下のようにwebpack.config.jsに記述を変更する。

webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  },
  optimization: {
    splitChunks: {
      // cacheGroups内にバンドルの設定を複数記述できる
      cacheGroups: {
        // 今回はvendorだが、任意の名前で問題ない
        vendor: {
          // node_modules配下のモジュールをバンドル対象とする
          test: /node_modules/,
          name: 'vendor',
          chunks: 'initial',
          enforce: true
        }
      }
    }
  }
};

この状態でwebpack --mode production(もしくはwebpack --mode development)コマンドを実行すると以下のようにjqueryvelocity-animateのみがバンドルされたvendor.bundle.jsが出力され、moduleA.jsapp.jsapp2.jsのそれぞれにバンドルされて出力される。

webpack --mode production

# 以下のような出力がされる
Hash: a69a92dbc66a89af47e2
Version: webpack 4.1.1
Time: 421ms
Built at: 2018-3-15 00:17:32
           Asset       Size  Chunks             Chunk Names
vendor.bundle.js    128 KiB       0  [emitted]  vendor
  app3.bundle.js  606 bytes       1  [emitted]  app3
  app2.bundle.js   1.38 KiB       2  [emitted]  app2
   app.bundle.js   1.38 KiB       3  [emitted]  app
Entrypoint app = vendor.bundle.js app.bundle.js
Entrypoint app2 = vendor.bundle.js app2.bundle.js
Entrypoint app3 = app3.bundle.js
   [0] ./src/js/modules/moduleA.js 177 bytes {2} {3} [built]
   [3] ./src/js/app3.js 81 bytes {1} [built]
   [4] ./src/js/app2.js 232 bytes {2} [built]
   [5] ./src/js/app.js 231 bytes {3} [built]
    + 2 hidden modules

共通モジュールをバンドルしたファイルを複数出力する

上記のmoduleA.jsのようなサイズが小さいモジュールであれば、それぞれのエントリーポイントにバンドルしても問題ない。

しかし、サイズが大きいモジュールをそれぞれのエントリーポイントにバンドルしてしまうと、全体のファイルサイズが大きくなる等のデメリットが生じてしまう。
とは言えども、頻繁に更新をしない外部モジュールはそれらだけをバンドルしてキャッシュを活用したい。

そのため、外部モジュールをバンドルしたファイルとは別の「自分たちが作成した共通モジュールをバンドルしたファイル」を出力すれば、全体のファイルサイズが大きくなるデメリットも解消される。

以下のようにwebpack.config.jsに記述を追加すれば、共通モジュールをバンドルしたファイルを複数出力できる。

webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        // 今回はvendorだが、任意の名前で問題ない
        vendor: {
          // node_modules配下のモジュールをバンドル対象とする
          test:/node_modules/,
          name: 'vendor',
          chunks: 'initial',
          enforce: true
        },
        vendorModules: {
          // 今回はsrc/js/modules配下にバンドルしたいモジュールが存在するため指定は以下になる
          test: /src\/js\/modules/,
          name: 'vendor-modules',
          chunks: 'initial',
          enforce: true
        }
      }
    }
  }
};

この状態でwebpack --mode production(もしくはwebpack --mode development)コマンドを実行すると以下のようにjqueryvelocity-animateがバンドルされたvendor.bundle.jsmoduleA.jsがバンドルされたvendor-modules.jsが出力される。

webpack --mode production

# 以下のような出力がされる
Hash: fb9a4aca38f413e48c5f
Version: webpack 4.1.1
Time: 434ms
Built at: 2018-3-15 01:24:59
                   Asset       Size  Chunks             Chunk Names
vendor-modules.bundle.js  236 bytes       0  [emitted]  vendor-modules
        vendor.bundle.js    128 KiB       1  [emitted]  vendor
          app3.bundle.js  606 bytes       2  [emitted]  app3
          app2.bundle.js   1.21 KiB       3  [emitted]  app2
           app.bundle.js   1.21 KiB       4  [emitted]  app
Entrypoint app = vendor.bundle.js vendor-modules.bundle.js app.bundle.js
Entrypoint app2 = vendor.bundle.js vendor-modules.bundle.js app2.bundle.js
Entrypoint app3 = app3.bundle.js
   [0] ./src/js/modules/moduleA.js 177 bytes {0} [built]
   [3] ./src/js/app3.js 81 bytes {2} [built]
   [4] ./src/js/app2.js 232 bytes {3} [built]
   [5] ./src/js/app.js 231 bytes {4} [built]
    + 2 hidden modules

以下のように出力されたvendor-modules.jsも読み込めば動作する。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
  <script src="js/vendor-modules.bundle.js"></script>
  <script src="js/vendor.bundle.js"></script>
  <script src="js/app.bundle.js"></script>
</body>
</html>

optimization.splitChunksのデフォルト設定を理解し、上書きする

webpack実行時、以下のoptimization.splitChunksに関する設定がデフォルトで有効になっている。

optimization: {
  splitChunks: {
    chunks: 'async',
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    name: true,
    cacheGroups: {
      default: {
        minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
      },
      vendors: {
        test: /[\\/]node_modules[\\/]/,
          priority: -10
      }
    }
  }
}

つまり、以下の設定でwebpackを実行すれば

const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  }
};

以下の設定でwebpackが実行されているのと同じである。

const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  },
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      name: true,
      cacheGroups: {
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        }
      }
    }
  }
};

設定項目

各設定項目は以下の通り(まだ解説していないものだけ記載)

※一部項目があまり理解できていないため、間違いがあれば修正を加えていきます。

minSize

共通モジュールとしてバンドルするモジュールの最小サイズ(単位はbyte)。

デフォルトが30000のため、30kb未満のモジュールは複数のエントリーポイントで利用されていても共通モジュールとしてバンドルされない。

minChunks

モジュールがいくつのエントリーポイントで利用されていれば、共通モジュールとしてバンドルするかの設定。

前述したmoduleA.jsは2つのエントリーポイントでしか利用されていないため、minChunks: 3を指定すると共通モジュールとしてバンドルされない。

maxAsyncRequests

エントリポイントでの並列リクエストの最大数(あまり良くわかっていない)

maxInitialRequests

オンデマンドローディング時の最大並列リクエスト数(あまり良くわかっていない)

cacheGroups

前述したvendor.bundle.jsvendor-modules.jsのように、指定した共通モジュールを出力したい時に設定する項目。

minSizeminChunksなども指定できるため、出力するファイル毎に設定が可能。

priority

cacheGroups内の設定の優先順位(値が大きいほど優先順位が高い)。

ユーザーが追加したcacheGroupspriorityはデフォルトが0のため、デフォルトで存在するdefaultvendorsより優先度が高い。

そのため、以下の場合どちらもnode_modules内のモジュールを対象として共通モジュールをバンドルする設定だが、vendorの設定が優先されてファイルが出力される。

splitChunks: {
  cacheGroups: {
    vendors: {
      test: /[\\/]node_modules[\\/]/,
      priority: -10
    },
    vendor: {
      test: /node_modules/,
      name: "vendor",
      minSize: 1,
      minChunks: 2,
    }
  }
}

reuseExistingChunk

モジュールが正確に一致したときに新しいチャンクを作成する代わりに、既存のチャンクを再利用することができる(あまり良くわかっていない)。

設定を上書きする

デフォルト設定ではchunks: 'async'が指定されているため、この状態でwebpackを実行しても共通モジュールをバンドルしたファイルは出力されないことが多い。

そのため、chunks: 'all' に上書きしてみる。設定を上書きしたい場合は以下のようにwebpack.config.jsに設定を記述すれば良い。

const path = require('path');

module.exports = {
  entry: {
    app: './src/js/app.js',
    app2: './src/js/app2.js',
    app3: './src/js/app3.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public/js'),
  },
  optimization: {
    splitChunks: {
      // この設定以外はデフォルトのまま
      chunks: 'all'
    }
  }
};

上記の設定でwebpackを実行すると、以下のような出力がされる。

Hash: f59c69b9f0a1f49b9081
Version: webpack 4.1.1
Time: 489ms
Built at: 2018-3-15 18:17:53
                     Asset       Size  Chunks             Chunk Names
vendors~app~app2.bundle.js    127 KiB       0  [emitted]  vendors~app~app2
            app3.bundle.js  606 bytes       1  [emitted]  app3
            app2.bundle.js   1.38 KiB       2  [emitted]  app2
             app.bundle.js   1.38 KiB       3  [emitted]  app
Entrypoint app = vendors~app~app2.bundle.js app.bundle.js
Entrypoint app2 = vendors~app~app2.bundle.js app2.bundle.js
Entrypoint app3 = app3.bundle.js
   [0] ./src/js/modules/moduleA.js 176 bytes {2} {3} [built]
   [3] ./src/js/app3.js 81 bytes {1} [built]
   [4] ./src/js/app2.js 234 bytes {2} [built]
   [5] ./src/js/app.js 232 bytes {3} [built]
    + 2 hidden modules

app.bundle.jsapp2.bundle.jsの共通モジュールをバンドルしているvendors~app~app2.bundle.jsが出力された。

デフォルト設定であるminSize: 30000が有効であるため、30kb以下であるmoduleA.jsapp.jsapp2.jsのそれぞれにバンドルされて出力される。moduleA.jsも共通モジュールとしてバンドルしたいのであれば、minSize: 1などを指定すればバンドルされる。

自分でoptimization.splitChunksの設定をしたのに予期しないファイルが出力された場合、デフォルト設定が有効になっている可能性もあるので注意。

終わり

自分で設定を書いてみて、どんなファイルが出力されるのか確認してみると理解が深まるかと思います。非常に有用な設定ですので、状況に応じて利用していきましょう。

基本的な機能はCommonsChunkPluginと変わりありませんが、まだドキュメントが存在せずissue等を見ながら手探りで触ってみたので間違いがあればご指摘いただけると幸いです。

最適化に関連する webpack を 利用した Tree Shaking に関しての記事を書きましたので、興味があるかはこちらの記事もどうぞ。

webpackのTree Shakingを理解してファイルサイズを削減する