フロントエンドのサイズを縮小する

webpack を使用してアプリをできるだけ小さくする方法

アプリを最適化する際に最初にするべきことの 1 つは、可能な限り小さくすることです。webpack で行う方法は次のとおりです。

Webpack 4 では、新しい mode フラグが導入されました。このフラグを 'development' または 'production' に設定すると、特定の環境用にアプリケーションをビルドしていることを webpack にヒントできます。

// webpack.config.js
module.exports = {
  mode: 'production',
};

本番環境用にアプリを作成する場合は、production モードを必ず有効にしてください。これにより、webpack は、圧縮、ライブラリ内の開発専用コードの削除など、その他の最適化を適用します。

関連情報

圧縮を有効にする

圧縮とは、余分なスペースの削除や変数名の短縮などにより、コードを圧縮することです。この場合、次のように指定します。

// Original code
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}

Webpack では、コードを圧縮する方法が 2 つあります。バンドルレベルの圧縮とローダ固有のオプションです。これらは同時に使用する必要があります。

バンドルレベルの圧縮

バンドルレベルの圧縮では、コンパイル後にバンドル全体が圧縮されます。仕組みは次のとおりです。

  1. 次のようなコードを記述します。

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. Webpack はこれをおおむね次のようにコンパイルします。

    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
    console.log('Rendered!');
    }
    
  3. 圧縮は、圧縮ツールによっておおよそ次のように行われます。

    // minified bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    

webpack 4 では、バンドルレベルの圧縮は、本番環境モードでも、有効にしない場合でも、自動的に有効になります。内部で UglifyJS 圧縮ツールを使用します。(圧縮を無効にする必要がある場合は、開発モードを使用するか、falseoptimization.minimize オプションに渡します)。

webpack 3 ではUglifyJS プラグインを直接使用する必要があります。このプラグインは webpack にバンドルされています。有効にするには、構成ファイルの plugins セクションに追加します。

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

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

ローダ固有のオプション

コードを圧縮する 2 つ目の方法は、ローダ固有のオプションです(ローダとは)。ローダ オプションを使用すると、圧縮ツールでは圧縮できないデータを圧縮できます。たとえば、css-loader を使用して CSS ファイルをインポートすると、ファイルは文字列にコンパイルされます。

/* comments.css */
.comment {
  color: black;
}
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

このコードは文字列であるため、圧縮ツールでは圧縮できません。ファイルの内容を圧縮するには、次のようにローダを構成する必要があります。

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};

関連情報

NODE_ENV=production を実行します。

フロントエンド サイズを小さくするもう 1 つの方法は、コードの NODE_ENV 環境変数を値 production に設定することです。

ライブラリは NODE_ENV 変数を読み取り、開発モードと本番環境のどちらで動作する必要があるかを検出します。一部のライブラリは、この変数に基づいて動作が異なります。たとえば、NODE_ENVproduction に設定されていない場合、Vue.js は追加のチェックを行い、警告を出力します。

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// …

React も同様に、警告を含む開発ビルドを読み込みます。

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
    componentClass.getDefaultProps.isReactClassApproved,
    'getDefaultProps is only used on classic React.createClass ' +
    'definitions. Use a static property named `defaultProps` instead.'
);
// …

このようなチェックと警告は通常本番環境では不要ですが、コードに残り、ライブラリのサイズが増加します。webpack 4 ではoptimization.nodeEnv: 'production' オプションを追加して削除します。

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
};

webpack 3 では、代わりに DefinePlugin を使用します。

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.optimize.UglifyJsPlugin()
  ]
};

optimization.nodeEnv オプションと DefinePlugin はどちらも同じように動作します。これらは、すべての process.env.NODE_ENV を指定された値に置き換えます。上記の構成を使用すると、次のようになります。

  1. Webpack は、すべての process.env.NODE_ENV"production" に置き換えます。

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
  2. 次に、圧縮ツールによってこのような if ブランチがすべて削除されます。これは、"production" !== 'production' が常に false であり、プラグインはブランチ内のコードが実行されないことを認識しているためです。

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    

関連情報

ES モジュールを使用する

フロントエンドのサイズを減らす方法の 2 つ目は、ES モジュールを使用することです。

ES モジュールを使用すると、webpack でツリー シェイキングが行えるようになります。ツリー シェイキングでは、バンドラが依存関係ツリー全体を走査し、使用されている依存関係を確認して未使用の依存関係を削除します。そのため、ES モジュール構文を使用すると、webpack で使用されていないコードを削除できます。

  1. 複数のエクスポートを使用して 1 つのファイルを作成しましたが、アプリが使用するのはそのうちの 1 つだけです。

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack は、commentRestEndpoint が使用されていないことを理解し、バンドル内に別のエクスポート ポイントを生成しません。

    // bundle.js (part that corresponds to comments.js)
    (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    const render = () => { return 'Rendered!'; };
    /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
    const commentRestEndpoint = '/rest/comments';
    /* unused harmony export commentRestEndpoint */
    })
    
  3. 未使用の変数は、圧縮ツールによって削除されます。

    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    

これは、ライブラリが ES モジュールで記述されている場合でも機能します。

ただし、webpack に組み込まれている圧縮ツール(UglifyJsPlugin)を正確に使用する必要はありません。 役に立たないコードの削除をサポートする任意の圧縮ツール(Babel Minify プラグインGoogle Closure Compiler プラグインなど)を使用できます。

関連情報

画像を最適化する

画像がページサイズの半分以上を占める。JavaScript ほど重要ではありませんが(レンダリングをブロックしないなど)、帯域幅の大部分を消費します。url-loadersvg-url-loaderimage-webpack-loader を使用して、webpack で最適化します。

url-loader: 小さな静的ファイルをアプリにインライン化します。構成しない場合、渡されたファイルをコンパイルされたバンドルの横に配置し、そのファイルの URL を返します。ただし、limit オプションを指定すると、この上限より小さいファイルは Base64 データ URL としてエンコードされ、この URL が返されます。これにより、画像が JavaScript コードにインライン化され、HTTP リクエストが保存されます。

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loaderurl-loader と同様に機能しますが、Base64 エンコードではなく URL エンコードを使用してファイルをエンコードする点が異なります。これは SVG 画像で有用です。SVG ファイルは単なる書式なしテキストであるため、このエンコードではサイズに対する効率が高くなります。

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: "svg-url-loader",
        options: {
          limit: 10 * 1024,
          noquotes: true
        }
      }
    ]
  }
};

image-webpack-loader は、通過するイメージを圧縮します。JPG、PNG、GIF、SVG の各画像形式に対応しているため、これらの形式すべてに使用します。

このローダはアプリに画像を埋め込みないため、url-loader および svg-url-loader との組み合わせで動作する必要があります。両方のルール(1 つは JPG/PNG/GIF 画像用、もう 1 つは SVG 画像用)にコピー&ペーストされないように、このローダーを enforce: 'pre' を使用して別のルールとして含めます。

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre'
      }
    ]
  }
};

ローダのデフォルトの設定はそのままで構いませんが、さらに設定する場合は、プラグインのオプションをご覧ください。指定するオプションを選択するには、Addy Osmani による画像最適化に関する優れたガイドをご覧ください。

関連情報

依存関係を最適化する

JavaScript の平均サイズの半分以上は依存関係に起因しており、その一部は不要な場合もあります。

たとえば、Lodash(v4.17.4 の時点で)は、圧縮された 72 KB のコードをバンドルに追加します。ただし、そのメソッドの 20 個程度しか使用しない場合、約 65 KB の圧縮コードは何もしません。

もう一つの例は、Moment.js です。2.19.1 バージョンでは、圧縮コードが 223 KB 必要です。これは非常に大きなサイズです。ページ上の JavaScript の平均サイズは、2017 年 10 月は 452 KB でした。ただし、そのうちの 170 KB はローカライズ ファイルです。Moment.js を複数の言語で使用しない場合は、これらのファイルが目的もなくバンドルを肥大化させます。

これらの依存関係はすべて簡単に最適化できます。最適化アプローチは GitHub リポジトリにまとめられています。こちらをご覧ください。

ES モジュールのモジュール連結(スコープ ホイスティング)を有効にする

バンドルをビルドすると、webpack は各モジュールを関数にラップします。

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();
}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }
})

以前は、CommonJS / AMD モジュールを互いに分離するために必要でした。ただし、これにより、各モジュールのサイズとパフォーマンスのオーバーヘッドが増加しました。

Webpack 2 では ES モジュールのサポートが導入されています。これは、CommonJS や AMD モジュールとは異なり、各モジュールを関数でラップせずにバンドルできます。webpack 3 では、モジュールの連結により、このようなバンドルが可能になりました。モジュールの連結は次の処理を行います。

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
    function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();
})

違いを実感してください。通常のバンドルでは、モジュール 0 はモジュール 1 の render を必要としていました。モジュールの連結では、require が単に必要な関数に置き換えられ、モジュール 1 が削除されます。バンドルに含まれるモジュールは少なく、モジュールのオーバーヘッドも少なくなります。

この動作を有効にするには、webpack 4optimization.concatenateModules オプションを有効にします。

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    concatenateModules: true
  }
};

webpack 3 ではModuleConcatenationPlugin を使用します。

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};

関連情報

webpack コードと Webpack 以外のコードの両方がある場合は、externals を使用します。

大規模なプロジェクトで、一部のコードが webpack でコンパイルされ、一部のコードがコンパイルされていない場合があります。たとえば、動画ホスティング サイトでは、プレーヤー ウィジェットは webpack でビルドされる一方、周囲のページは webpack でビルドされない場合があります。

動画ホスティング サイトのスクリーンショット
(完全にランダムな動画ホスティング サイト)

両方のコードに共通の依存関係がある場合は、コードを共有することで、コードを何度もダウンロードする必要がなくなります。これは、webpack の externals オプションを使用して行います。これにより、モジュールが変数または他の外部インポートに置き換えられます。

window で依存関係を利用できる場合

webpack 以外のコードが window で変数として使用可能な依存関係に依存している場合は、依存関係名を変数名にエイリアスします。

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
};

この設定では、webpack は react パッケージと react-dom パッケージをバンドルしません。代わりに、次のように置き換えます。

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

依存関係が AMD パッケージとして読み込まれている場合

webpack 以外のコードが window に依存関係を公開しない場合、状況はより複雑になります。ただし、webpack 以外のコードがこれらの依存関係を AMD パッケージとして使用している場合は、同じコードを 2 回読み込むことを回避できます。

そのためには、webpack コードを AMD バンドルとしてコンパイルし、モジュールをライブラリ URL にエイリアスとして指定します。

// webpack.config.js
module.exports = {
  output: {
    libraryTarget: 'amd'
  },
  externals: {
    'react': {
      amd: '/libraries/react.min.js'
    },
    'react-dom': {
      amd: '/libraries/react-dom.min.js'
    }
  }
};

Webpack はバンドルを define() にラップし、以下の URL に依存するようにします。

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () {  });

Webpack 以外のコードが同じ URL を使用して依存関係を読み込む場合、これらのファイルは 1 回だけ読み込まれます。追加のリクエストではローダ キャッシュが使用されます。

関連情報

まとめ

  • webpack 4 を使用している場合は、本番環境モードを有効にする
  • バンドルレベルの最小化ツールとローダのオプションを使用してコードを最小化する
  • NODE_ENVproduction に置き換えて、開発専用のコードを削除します。
  • ES モジュールを使用してツリー シェイクを有効にする
  • 画像を圧縮する
  • 依存関係固有の最適化を適用する
  • モジュールの連結を有効にする
  • 必要に応じて externals を使用してください。