JavaScript
最適化
webpack

webpackのTree Shakingを理解して不要なコードがバンドルされるのを防ぐ

はじめに

webpack の Tree Shaking に関する備忘録です。以下を目的とした記事になります。

  • Tree Shaking とは何か、なぜ Tree Shaking をするのかを理解する
  • webpack を利用して Tree Shaking するためにはどうすれば良いのかを理解する
  • Tree Shaking 以外の「デッドコードをバンドルさせない」ための手段を理解する

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

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

Tree Shaking とは

  • webpack などでファイルをバンドルする際に、デッドコード(利用されていない不要なコード)を除去してファイルを出力すること
  • デッドコードを除去する機能のこと

そのため、以下の言葉の意味は大体同じ。

  • 「Tree Shaking する」=「デッドコードを除去する」
  • 「Tree Shaking を利用する」=「デッドコードを除去する機能を利用する」

webpack や Rollup などのモジュールバンドラに Tree Shaking 機能が備わっている。

なぜ Tree Shaking するのか(Tree Shaking を利用するのか)

  • デッドコードを除去することにより、不要なコードがバンドルされるのを防ぐため(ファイルサイズが無駄に増加するのを防ぐため)

Tree Shaking は必ず利用した方が良いのか

Tree Shaking 以外にも「デッドコードをバンドルさせない」ための手段はあるため、あくまで手段の一つという認識で問題ない(別の手段は後述)。

webpack を利用して Tree Shaking されたファイルを出力する

以下の条件を満たしていれば、webpack で Tree Shaking されたファイルを出力できる(そのため、こちらが意図せずとも Tree Shaking されていることもある)。

  • ES2015(ES6)のimport/export構文でモジュールのエクポート、インポートする
  • productionモードで実行(Tree Shaking するための設定が有効になる)

上記を満たしたファイルを準備し、Tree Shaking されたファイルを出力してみる。

ディレクトリ構成

.
├── dist
│   └── bundle.js
├── package.json
├── src
│   ├── index.js
│   └── modules.js
└── webpack.config.js

各ファイルの詳細

package.json

package.json
{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "devDependencies": {
    "webpack": "^4.14.0",
    "webpack-cli": "^3.0.2"
  }
}

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

npm installl

or

yarn

webpack.config.js

Tree Shaking するためにmode: 'production'を指定する。

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

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

index.js

modules.jsmoduleBをインポートして利用しているエントリーポイント。

index.js
import { moduleB } from './modules';

moduleB();

Tree Shaking するためには、ES2015(ES6)のimport/export構文で記述をする必要がある。そのため、require()でモジュールをインポートしても Tree Shaking されないので注意。

modules.js

複数のモジュールをエクスポートしているファイル。

modules.js
// どこにもインポートされていないため、デッドコード
const moduleA = () => {
  console.log('moduleA');
  console.log('moduleA');
  console.log('moduleA');
  console.log('moduleA');
  console.log('moduleA');
};

const moduleB = () => {
  console.log('moduleB');
  console.log('moduleB');
  console.log('moduleB');
  console.log('moduleB');
  console.log('moduleB');
};

export { moduleA, moduleB };

前述の通り、import/export構文で記述をする必要があるため、exportsでモジュールをエクスポートしても Tree Shaking されないので注意。

今回、index.jsでインポートされているのはmoduleBだけなので、moduleAがデッドコードになる

webpackコマンドでファイルを出力する

上記構成のwebpack.config.jsが存在する階層でwebpack --display-used-exportsコマンドを実行すれば、Tree Shaking されたファイル(bundle.js)が出力される。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 81de0f15f0b44482d5cf
Version: webpack 4.14.0
Time: 220ms
Built at: 2018-07-04 20:49:02
    Asset      Size  Chunks             Chunk Names
bundle.js  1.04 KiB       0  [emitted]  main
[0] ./src/index.js + 1 modules 544 bytes {0} [built]
    | ./src/index.js 223 bytes [built]
    | ./src/modules.js 321 bytes [built]
    |     [only some exports used: moduleB]

--display-used-exportsオプションを利用すれば、Tree Shaking されているか確認できる。modules.jsmoduleBのみをインポートしているため、[only some exports used: moduleB]が出力される。

また、出力されるbundle.jsは以下の通り。

bundle.js
!function(e){var o={};function t(n){if(o[n])return o[n].exports;var r=o[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,t),r.l=!0,r.exports}t.m=e,t.c=o,t.d=function(e,o,n){t.o(e,o)||Object.defineProperty(e,o,{enumerable:!0,get:n})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,o){if(1&o&&(e=t(e)),8&o)return e;if(4&o&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(t.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&o&&"string"!=typeof e)for(var r in e)t.d(n,r,function(o){return e[o]}.bind(null,r));return n},t.n=function(e){var o=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(o,"a",o),o},t.o=function(e,o){return Object.prototype.hasOwnProperty.call(e,o)},t.p="",t(t.s=0)}([function(e,o,t){"use strict";t.r(o);console.log("moduleB"),console.log("moduleB"),console.log("moduleB"),console.log("moduleB"),console.log("moduleB")}]);

console.log('moduleA');の記述はないため、Tree Shaking されている(デッドコードであるmoduleAが除去されている)ことがわかる。

というわけで、webpack を利用して Tree Shaking されたファイルを出力できた。

babel-loaderを利用して Tree Shaking されたファイルを出力する際の注意点

webpack を利用して Tree Shaking されたファイルを出力できたが、babel-loaderを利用する場合、設定次第では Tree Shaking されていないファイルが出力されるため注意

どのような設定にすれば、Tree Shaking されたファイルが出力されるのか確認してみる。

babel-loaderを利用するために必要なパッケージをインストール

npm install babel-core babel-loader babel-preset-env -D

or

yarn add babel-core babel-loader babel-preset-env -D

webpack.config.jsbabel-loaderを利用するための記述を追加する

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

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
      },
    ],
  },
  mode: 'production',
};

Babel の設定ファイルである.babelrcwebpack.config.jsと同じ階層に作成する

.babelrc
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}

"modules": falseを記述しないと、import/export構文が別の構文に変換されてしまうため、 Tree Shaking されなくなる

そのため、必ず記述する。

.babelrc"modules": falseを記述してファイルを出力する

上記の設定でwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 2fbecc0c60bb0018e77a
Version: webpack 4.14.0
Time: 1042ms
Built at: 2018-07-04 21:09:06
    Asset      Size  Chunks             Chunk Names
bundle.js  1.04 KiB       0  [emitted]  main
[0] ./src/index.js + 1 modules 588 bytes {0} [built]
    | ./src/index.js 222 bytes [built]
    | ./src/modules.js 366 bytes [built]
    |     [only some exports used: moduleB]

Tree Shaking されたファイルが出力された。

.babelrc"modules": falseを記述せずにファイルを出力する

.babelrc"modules": falseを記述せずにwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 3f29224989943d0f6723
Version: webpack 4.14.0
Time: 313ms
Built at: 2018-07-04 21:10:07
    Asset      Size  Chunks             Chunk Names
bundle.js  1.29 KiB       0  [emitted]  main
[0] ./src/modules.js 471 bytes {0} [built]
[1] ./src/index.js 251 bytes {0} [built]

bundle.jsのサイズが 1.04KB から 1.29KB に増えており、[only some exports used: moduleB]も出力されていない。

また、出力されるbundle.jsは以下の通り。

bundle.js
!function(e){var o={};function n(t){if(o[t])return o[t].exports;var l=o[t]={i:t,l:!1,exports:{}};return e[t].call(l.exports,l,l.exports,n),l.l=!0,l.exports}n.m=e,n.c=o,n.d=function(e,o,t){n.o(e,o)||Object.defineProperty(e,o,{enumerable:!0,get:t})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,o){if(1&o&&(e=n(e)),8&o)return e;if(4&o&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(n.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&o&&"string"!=typeof e)for(var l in e)n.d(t,l,function(o){return e[o]}.bind(null,l));return t},n.n=function(e){var o=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(o,"a",o),o},n.o=function(e,o){return Object.prototype.hasOwnProperty.call(e,o)},n.p="",n(n.s=1)}([function(e,o,n){"use strict";Object.defineProperty(o,"__esModule",{value:!0});o.moduleA=function(){console.log("moduleA"),console.log("moduleA"),console.log("moduleA"),console.log("moduleA"),console.log("moduleA")},o.moduleB=function(){console.log("moduleB"),console.log("moduleB"),console.log("moduleB"),console.log("moduleB"),console.log("moduleB")}},function(e,o,n){"use strict";(0,n(0).moduleB)()}]);

console.log('moduleA');の記述があるため、Tree Shaking されていない(デッドコードであるmoduleAが除去されていない)ことがわかる

ということで、babel-loaderを利用する場合、Babel の設定ファイルに"modules": falseの記述を必ず追加する。

外部モジュール(npm installyarn addしたパッケージ)を Tree Shaking したファイルを出力する

一部の外部モジュールも Tree Shaking できるため、今回は lodash-es を Tree Shaking してみる。

なぜ lodash ではなく lodash-es を 利用するのか。

通常のlodashimport/export構文でエクスポートされていないため、Tree Shaking できないから

そのため lodash をimport/export構文でエクスポートしたlodash-esを利用する。

lodash-es のインストール

npm install lodash-es -S

or

yarn add lodash-es -S

lodash-es が Tree Shaking されていないファイルを出力する

出力されたファイルサイズを比較するため、まずは Tree Shaking されていないファイルを出力する。

index.jslodash-esをインポートする記述を追加する。

index.js
import _ from 'lodash-es';
import { moduleB } from './modules';

moduleB();
_.map();

この状態でwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 4ebd051f340d11f02706
Version: webpack 4.14.0
Time: 6823ms
Built at: 2018-07-04 21:55:22
    Asset      Size  Chunks             Chunk Names
bundle.js  85.9 KiB       0  [emitted]  main
[6] (webpack)/buildin/harmony-module.js 573 bytes {0} [built]
[8] ./src/index.js + 611 modules 572 KiB {0} [built]
    | ./src/index.js 242 bytes [built]
    | ./src/modules.js 366 bytes [built]
    |     [only some exports used: moduleB]
    |     + 610 hidden modules
[9] (webpack)/buildin/global.js 489 bytes {0} [built]

出力されたbundle.jsのサイズは85.9KB

lodash-es が Tree Shaking されたファイルを出力する

lodash-esをインポートする記述を変更する。

index.js
import { map } from 'lodash-es';
import { moduleB } from './modules';

moduleB();
map();

この状態でwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 913ae7a5807c6b8f5465
Version: webpack 4.14.0
Time: 1942ms
Built at: 2018-07-04 21:53:13
    Asset      Size  Chunks             Chunk Names
bundle.js  16.6 KiB       0  [emitted]  main
 [6] (webpack)/buildin/harmony-module.js 573 bytes {0} [built]
 [9] (webpack)/buildin/global.js 489 bytes {0} [built]
[10] ./src/index.js + 117 modules 82.7 KiB {0} [built]
     | ./src/index.js 210 bytes [built]
     | ./src/modules.js 366 bytes [built]
     |     [only some exports used: moduleB]
     |     + 116 hidden modules

出力されたbundle.jsのサイズは16.6KB。先ほど出力したファイルのサイズは85.9KB のため、lodash-es が Tree Shaking されたファイルが出力されたことがわかる。

というわけで、外部モジュールが Tree Shaking されたファイルを出力できた。

Tree Shaking 以外の「デッドコードをバンドルさせない」ための手段

Tree Shaking は「デッドコードを除去する」という手段で「デッドコードをバンドルさせない」という目的は果たしているだけなので、Tree Shaking 以外にも「デッドコードをバンドルさせない」ための手段はある

外部モジュールの必要なものだけインポートする

lodashの場合、以下のように記述すればlodash全体はインポートされないため、デッドコードが含まれないファイルを出力できる。

// `lodash/map`をインポートしているため、`lodash`全体はインポートされない
import map from 'lodash/map';

外部モジュールによっては「必要なものだけインポートする」こともできないため、そこは留意しておく

babel-plugin-transform-imports を利用してimportの記述を簡潔にする

lodashで必要なメソッドのみを複数インポートする場合、以下のように記述する必要がある。

index.js
import map from 'lodash/map';
import differenceWith from 'lodash/differenceWith';
import divide from 'lodash/divide';
import drop from 'lodash/drop';
import dropRight from 'lodash/dropRight';
import dropRightWhile from 'lodash/dropRightWhile';
import { moduleB } from './modules';

map();
differenceWith();
divide();
drop();
dropRight();
dropRightWhile();
moduleB();

メソッドの数だけimportを記述する必要があるため、面倒だしコードの見通しが悪くなる。

そのため、本来なら以下のように記述したい。

index.js
import {
  map,
  differenceWith,
  divide,
  drop,
  dropRight,
  dropRightWhile,
} from 'lodash';
import { moduleB } from './modules';

map();
differenceWith();
divide();
drop();
dropRight();
dropRightWhile();
moduleB();

しかし、これだと前述の通りlodash全体をインポートしてしまい、デッドコードが含まれたファイルが出力されてしまう。

上記の記述でも デッドコードが含まれないように babel-plugin-transform-imports(一部のimportの記述を変換する Babel プラグイン)を利用する。

lodash と babel-plugin-transform-imports のインストール

npm install lodash -S && npm install babel-plugin-transform-imports -D

or

yarn add lodash -S && yarn add babel-plugin-transform-imports -D

babel-plugin-transform-imports を有効にする

babel-plugin-transform-imports を有効にするために.babelrcに以下の記述を追加する。

.babelrc
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": [
    [
      "transform-imports",
      {
        "lodash": {
          "transform": "lodash/${member}"
        }
      }
    ]
  ]
}

lodashのメソッドをインポートするための記述はimport map from 'lodash/map';のようになるため、"transform": "lodash/${member}"と記述する。

webpackコマンドでファイルを出力する

上記の設定でwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 88bbda292c3d7b04cd56
Version: webpack 4.15.0
Time: 1301ms
Built at: 2018-07-05 13:58:34
    Asset      Size  Chunks             Chunk Names
bundle.js  24.9 KiB       0  [emitted]  main
 [32] (webpack)/buildin/module.js 519 bytes {0} [built]
 [51] ./src/index.js + 1 modules 791 bytes {0} [built]
      | ./src/index.js 367 bytes [built]
      | ./src/modules.js 394 bytes [built]
      |     [only some exports used: moduleB]
[141] (webpack)/buildin/global.js 509 bytes {0} [built]
    + 152 hidden modules

出力されたbundle.jsのサイズは24.9KB

lodash全体をインポートして出力されるファイルのサイズは70.6KBのため、デッドコードが含まれていないファイルを出力できた。

preventFullImportでモジュール全体がインポートされるのを防ぐ

babel-plugin-transform-imports にはpreventFullImportという設定項目があり、以下のように"preventFullImport": trueを指定すると、モジュール全体がインポートされるのを防ぐ。

.babelrc
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": [
    [
      "transform-imports",
      {
        "lodash": {
          "transform": "lodash/${member}",
          "preventFullImport": true
        }
      }
    ]
  ]
}

上記の設定でindex.jsを以下のように変更する

index.js
import _ from 'lodash';

そしてwebpack --display-used-exportsコマンドを実行すると、以下のようなエラーが出力してファイルが出力されない。

Hash: cad8881fdbd6cd207941
Version: webpack 4.15.0
Time: 445ms
Built at: 2018-07-05 13:59:43
 1 asset
[0] ./src/index.js 3.01 KiB {0} [built] [failed] [1 error]

ERROR in ./src/index.js
Module build failed (from ./node_modules/babel-loader/lib/index.js):
Error: /Users/mac/GitHub/webpack-tree-shaking-example/src/index.js: babel-plugin-transform-imports: import of entire module lodash not allowed due to preventFullImport setting

babel-plugin-transform-imports を利用して React-Bootstrap のimportの記述を簡潔にする

React-Bootstrap の必要なものだけインポートするためには、以下のような記述になる。

index.js
import Row from 'react-bootstrap/lib/Row';
import MyGrid from 'react-bootstrap/lib/Grid';
import {
  map,
  differenceWith,
  divide,
  drop,
  dropRight,
  dropRightWhile,
} from 'lodash';
import { moduleB } from './modules.js';

moduleB();

babel-plugin-transform-imports を利用して、以下のように記述してもデッドコードが含まれていないファイルを出力する。

index.js
import { Row, Grid as MyGrid } from 'react-bootstrap';
import {
  map,
  differenceWith,
  divide,
  drop,
  dropRight,
  dropRightWhile,
} from 'lodash';
import { moduleB } from './modules.js';

moduleB();

React-Bootstrap を利用するために必要なパッケージをインストール

npm install react react-bootstrap react-dom -S

or

yarn add react react-bootstrap react-dom -S

.babelrcに React-Bootstrap に関する設定を追加する

.babelrc
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": [
    [
      "transform-imports",
      {
        "lodash": {
          "transform": "lodash/${member}",
          "preventFullImport": true
        },
        "react-bootstrap": {
          "transform": "react-bootstrap/lib/${member}",
          "preventFullImport": true
        }
      }
    ]
  ]
}

webpackコマンドでファイルを出力する

上記の設定でwebpack --display-used-exportsコマンドを実行すれば、以下のような出力がされる。

webpack --display-used-exports

# 以下のような出力がされる
Hash: 022da9eab2b616389757
Version: webpack 4.15.0
Time: 2031ms
Built at: 2018-07-05 14:03:23
    Asset      Size  Chunks             Chunk Names
bundle.js  57.7 KiB       0  [emitted]  main
 [63] (webpack)/buildin/module.js 519 bytes {0} [built]
 [97] ./src/index.js + 1 modules 820 bytes {0} [built]
      | ./src/index.js 386 bytes [built]
      | ./src/modules.js 394 bytes [built]
      |     [only some exports used: moduleB]
[192] (webpack)/buildin/global.js 509 bytes {0} [built]
    + 258 hidden modules

ファイルサイズは57.7KB

react-bootstrap全体をインポートして出力されるファイルのサイズは291KBのため、デッドコードが含まれていないファイルを出力できた。

終わり

自分の設定やimportなどの構文の記述が原因で Tree Shaking が動作していない可能性があります。

また、Tree Shaking を利用しなくても、importなどの記述を変更するだけで不要なファイルサイズを削減できる可能性もあります。

この機会に、設定や記述を一度見直してみると良いかもしれません。