30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

webpack@4の splitChunks を使って CommonsChunkPlugin から移行する

この記事では、webpackのv4で追加されたsplitChunksを使って、v4で削除されたCommonsChunkPluginから移行する方法を書いていく。

使っているパッケージのバージョンは以下の通り。

  • webpack@4.1.0
  • webpack-cli@2.0.10

CommonsChunkPlugin とは

CommonsChunkPluginの説明やそれを使うメリットは、以下の記事が分かりやすい。

webpackのCommonsChunkPluginの使い方、使い所 (webpack 4で廃止) - Qiita

簡単に言ってしまうと、複数のエントリポイントで共通のライブラリを使っている場合、それぞれのファイルに個別にバンドルするのではなく、そのライブラリだけ別のファイルとして出力する。
そうすることで、全体のファイルサイズが小さくなる、キャッシュを活用しやすい、といったメリットを得られる。

例えば、以下のような場合。

entry: {
  home: './src/home.js',
  about: './src/about.js',
  error: './src/error.js',
},
output: {
  filename: '[name].js',
  path: path.resolve(__dirname, 'dest'),
},

この設定でビルドすると、dest/home.jsdest/about.jsdest/error.jsの3つのファイルが出力される。
このうち、homeでもaboutでもReactを使っていた場合、dest/home.jsdest/about.jsの両方にReactがバンドルされ、重複が発生してしまう。
共通して使っているライブラリが増えたり、エントリポイントが増えたりしていくほど、重複によるムダも大きくなっていく。

CommonsChunkPluginを使うことで、この状態を回避できる。
例えば、共通して使っているReactはdest/vendor.jsというファイルにバンドルしてしまうことで、dest/home.jsdest/about.jsにReactがバンドルされることを防げる。

しかしwebpackのv4でCommonsChunkPluginは削除されてしまった。
v4で同じことをするためには、splitChunksを使う。

ライブラリの重複

具体的な使い方を示すために、まずはsplitChunksを使わずにビルドしてみる。

エントリポイントは先程の例と同じで、それ以外に何も設定しない。

webpack.config.js

const path = require('path');

module.exports = {
  entry: {
    home: './src/home.js',
    about: './src/about.js',
    error: './src/error.js',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dest'),
  },
};

エントリポイントのファイルの内容は、それぞれ以下の通り。
JSXを使っていないのは、BabelやらLoaderやらを使って設定項目が増えて分かりづらくなるのを避けるためで、深い意味はない。

src/home.js

import React from 'react';
import ReactDOM from 'react-dom';

console.log('This is Home.');

function Home() {
  return React.createElement(
    'div',
    null,
    'home'
  );
}

ReactDOM.render(React.createElement(Home, null), document.querySelector('#app'));

src/about.js

import React from 'react';
import ReactDOM from 'react-dom';

console.log('This is About.');

function About() {
  return React.createElement(
    'div',
    null,
    'about'
  );
}

ReactDOM.render(React.createElement(About, null), document.querySelector('#app'));

src/error.js

console.log('This is Error.');

homeaboutの両方で、ReactとReactDOMを使っている。

この状態でビルドし、出力されたファイルをそれぞれ、以下のようなhtmlファイルで読み込む。

<body>
    <div id="app"></div>
    <script src="./home.js"></script>
</body>

ディレクトリ構成は以下。

├── dest
│   ├── about.html
│   ├── about.js
│   ├── error.html
│   ├── error.js
│   ├── home.html
│   └── home.js
├── package-lock.json
├── package.json
├── src
│   ├── about.js
│   ├── error.js
│   └── home.js
└── webpack.config.js

それぞれのhtmlファイルをブラウザで開くと、きちんと動いていることが分かる。
だが、dest/homs.jsdest/about.jsの両方にReactとReactDOMがバンドルされてしまい、ファイルサイズが大きくなってしまっている。

   Asset       Size  Chunks             Chunk Names
error.js  578 bytes       0  [emitted]  error
about.js   96.9 KiB       1  [emitted]  about
 home.js   96.9 KiB       2  [emitted]  home

splitChunks を使って重複を解消する

splitChunksを使うには、webpack.config.jsoptimization.splitChunksを追加する。

const path = require('path');

module.exports = {
  entry: {
    home: './src/home.js',
    about: './src/about.js',
    error: './src/error.js',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dest'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /react|react-dom/,
          name: "vendor",
          chunks: "initial",
          enforce: true,
        },
      },
    },
  },
};

上記の設定だと、reactreact-domdest/vendor.jsにバンドルされて出力され、dest/home.jsdest/about.jsには含まれなくなる。

その結果、全体のファイルサイズが小さくなっていることが分かる。

    Asset       Size  Chunks             Chunk Names
vendor.js   93.1 KiB       0  [emitted]  vendor
 error.js  578 bytes       1  [emitted]  error
 about.js   4.46 KiB       2  [emitted]  about
  home.js   4.46 KiB       3  [emitted]  home

それぞれのhtmlでdest/vendor.jsも読み込むようにすることを忘れずに行う。

<body>
    <div id="app"></div>
    <script src="./vendor.js"></script>
    <script src="./home.js"></script>
</body>
<body>
    <div id="app"></div>
    <script src="./vendor.js"></script>
    <script src="./about.js"></script>
</body>

どちらのページでも同じdest/vendor.jsを読み込んでいるからキャッシュが効くし、ライブラリをまとめたvendor.jsの更新頻度はhome.jsabout.jsよりも多くないはずなので、そういった意味でもキャッシュの恩恵を受けやすい。

参考資料

webpack@4のmodeとminimize(UglifyJS)

webpackではv4から、modeというオプションが追加された。
それに伴いminimize(コードの圧縮)の設定の仕組みも変わったので、それについても書いていく。

以下のライブラリのバージョンで動作確認している。

  • webpack@4.1.0
  • webpack-cli@2.0.10
  • uglifyjs-webpack-plugin@1.2.2
  • license-info-webpack-plugin@1.0.0
  • react@16.2.0

2種類のmode

modeは、productiondevelopmentの2種類。
以下のように、webpackを実行する際に引数として渡すことで、設定できる。

"scripts": {
  "build": "webpack --mode production",
  "develop": "webpack --mode development"
},

modeを指定しなくてもwebpackを実行することは出来るようだが、警告が出る。

modeを指定すると、特に設定を行わなくてもそれぞれに最適化してくれる。
例えばコードの圧縮については、developmentでは行われないが、productionでは行われる。

もちろんこの設定を自分でカスタマイズすることも出来る。その方法は後述。

公式ブログによると、何か手を加えなくても最適化された設定を利用できるようになっており、必要に応じて各自でカスタマイズしましょう、という考え方らしい。

With the new mode option we tried to reduce the required configuration for a useful build. We tried to cover the common use cases with these defaults.

But from our experience we also know that defaults are not for everyone.

Many people do want to change defaults to adapt to own use cases. We got you covered. Adding mode doesn’t mean that we remove configuration. Everything is still configurable. We actually made most of the internal optimization steps configurable (you can now disable them).

mode is implemented by setting default values to configuration options. No special behavior is done by mode which isn’t possible via other configuration option.

webpack 4: mode and optimization – webpack – Medium

modeと環境変数

modeで設定した値は、そのままコードのなかで環境変数として使うことが出来る。

下記のコードをproductionでビルドした場合、productionという文字列が表示される。developmentの場合も同様。

console.log(process.env.NODE_ENV);

.babelrcではenvによって使用する設定を切り替えることが出来るが、webpack経由でトランスパイルする場合、このenvmodeの値をそのまま参照する。

{
  "env": {
    "production": {
      "presets": [
        ["env", {
          "targets": {
            "browsers": ["ie 11"]
          }
        }],
      ],
    },
    "development": {
      "presets": [
        ["env", {
          "targets": {
            "browsers": ["last 2 Chrome versions"]
          }
        }],
      ],
    }
  }
}

webpack.config.jsでmodeを参照する

webpack.config.jsmodeを参照するには、一工夫必要になる。

まず、module.exportsに代入する値を、オブジェクトから、オブジェクトを返す関数、に変える。

// before
module.exports = {
  // your settings...
};

// after
module.exports = () => {
  return {
    // your settings...
  };
};

こうすると、関数の第二引数から、modeを取得出来るようになる。

module.exports = (env, argv) => {
  console.log(argv.mode); // production もしくは development
  return {
    // your settings...
  };
};

この値を使うことで、mode毎にビルドの設定を変えることが可能になる。

minimizeのカスタマイズ

前述の通り、productionにすれば、何も指定しなくてもコードが圧縮される。

3.xのときはES2015+の構文を使っているとエラーになるため別途プラグインが必要だったが、それも必要ない。
これは、UglifyJS2にアップグレードされたためらしい。

😍Upgrade to UglifyJS2

This means that you can use ES6 Syntax, minify it, without a transpiler first.

🚀webpack 4 beta — try it today!🚀 – webpack – Medium

だが、自分で設定をカスタマイズする場合は、uglifyjs-webpack-pluginというプラグインが必要になる。
使わずにカスタマイズする方法は見つけられなかった。
https://github.com/webpack-contrib/uglifyjs-webpack-plugin

$ npm i -D uglifyjs-webpack-plugin

例:consoleを消す

webpack.config.jsを以下の内容にしてビルドすると、src/index.jsの内容がdest/bundle.jsとしてビルドされる。

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dest'),
  },
};

productionでビルドすると圧縮されるが、行われるのは圧縮だけなので、プログラムの処理の内容は変わらない。

// src/index.js
console.log('hoge');

// dest/bundle.js
!function(e){var n={};function r(t){if(n[t])return n[t].exports;var o=n[t]={i:t,l:!1,exports:{}};return e[t].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=n,r.d=function(e,n,t){r.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:t})},r.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},r.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(n,"a",n),n},r.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},r.p="",r(r.s=0)}([function(e,n){console.log("hoge")}]);

上記の例では、元のコードが短いため圧縮によって却って長くなっているが、それはともかく、console.logも当然残っている。

$ node dest/bundle.js
hoge
$ 

optimization.minimizerで設定することで、consoleを消せるようになる。

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dest'),
  },
  optimization: {
    minimizer: [
      new UglifyJSPlugin({
        uglifyOptions: {compress: {drop_console: true}},
      }),
    ],
  },
};

これでビルドすると、dest/bundle.jsからconsoleが消える。

!function(e){var n={};function r(t){if(n[t])return n[t].exports;var o=n[t]={i:t,l:!1,exports:{}};return e[t].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=n,r.d=function(e,n,t){r.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:t})},r.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},r.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(n,"a",n),n},r.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},r.p="",r(r.s=0)}([function(e,n){}]);
$ node dest/bundle.js
$ 

productionのときだけ消してdevelopmentのときは残したい、という場合は次のように書いてargv.modeで処理を振り分ければいい。

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = (env, argv) => ({
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dest'),
  },
  optimization: {
    minimizer:
      argv.mode === 'production'
        ? [
            new UglifyJSPlugin({
              uglifyOptions: {compress: {drop_console: true}},
            }),
          ]
        : [],
  },
});

ライセンスコメント

個人的に気になっていたライセンスコメントの抽出についても、調べた。

ライセンスについては詳しくないのだが、多くのOSSの恩恵に乗っかっている以上、出来るだけ正しく使いたい。
そのためには、全てを圧縮してしまうのではなく、ライセンスに関するコメントはそのまま残しておく必要がある。

例として、react@16.2.0importしてみる。

import 'react';

これをビルドすると、1行のファイルに圧縮されてしまう。
そこで、optimization.minimizerを編集して、以下のようにする。

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = (env, argv) => ({
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dest'),
  },
  optimization: {
    minimizer:
      argv.mode === 'production'
        ? [
            new UglifyJSPlugin({
              uglifyOptions: {
                output: {comments: /^\**!|@preserve|@license|@cc_on/},
              },
            }),
          ]
        : [],
  },
});

このようにすると、ライセンスに関する部分だけそのままdest/bundle.jsに出力することが出来る。

/*
object-assign
(c) Sindre Sorhus
@license MIT
/** @license React v16.2.0
 * react.production.min.js
 *
 * Copyright (c) 2013-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.

license-info-webpack-plugin

license-info-webpack-pluginというプラグインを使うと、さらに詳細にライセンスコメントを出力できる。
https://github.com/yami-beta/license-info-webpack-plugin

$ npm i -D license-info-webpack-plugin
const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const LicenseInfoWebpackPlugin = require('license-info-webpack-plugin').default;

module.exports = (env, argv) => ({
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dest'),
  },
  plugins: [
    new LicenseInfoWebpackPlugin({
      glob: '{LICENSE,license,License}*',
    }),
  ],
  optimization: {
    minimizer:
      argv.mode === 'production'
        ? [
            new UglifyJSPlugin({
              uglifyOptions: {
                output: {comments: /^\**!|@preserve|@license|@cc_on/},
              },
            }),
          ]
        : [],
  },
});

ライセンスの文言も表示され、reactだけでなく、reactと依存関係にあるパッケージのライセンスも表示されている。

/*!
 * fbjs@0.8.16 (MIT)
 *   url: git+https://github.com/facebook/fbjs.git
 *
 *   MIT License
 *
 *   Copyright (c) 2013-present, Facebook, Inc.
 *
 *   Permission is hereby granted, free of charge, to any person obtaining a copy of
 *   this software and associated documentation files (the "Software"), to deal in
 *   the Software without restriction, including without limitation the rights to
 *   use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 *   the Software, and to permit persons to whom the Software is furnished to do so,
 *   subject to the following conditions:
 *
 *   The above copyright notice and this permission notice shall be included in all
 *   copies or substantial portions of the Software.
 *
 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 *   FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 *   COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 *   IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 *   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 *
 *
 // 以下略

だが、依存関係にある全てのパッケージについて表示されるわけではないらしい。
reactの依存関係は以下のようになっているが、loose-envifyについては出力されていなかった。
これについては調べていないが、恐らく、そのパッケージが自身のライセンスについて正しく記述していないと、上手く読み取れないのだと思う。

─┬ react@16.2.0
 ├── fbjs@0.8.16
 ├── loose-envify@1.3.1
 ├── object-assign@4.1.1
 └── prop-types@15.6.1

参考資料