KDE BLOG

バイブス

【webpack速習】vol.6: Tree Shaking

下記ページの要点をまとめます。 webpack.js.org

また一部を下記ページより引用、大いに参考にさせていただいています。 www.kabuku.co.jp

tree shakingとは

  • Tree Shaking は デッドコード(使われていない不要なコード)を除去するために使われる用語
  • webpack などのモジュールバンドラーを使ってビルドする際に、余計なものを取り除き、本当に使われているコードだけを残し生成されるファイルのサイズを極力小さくするための処理
  • 名前と概念は モジュールバンドラ―である Rollup によって普及した
  • webpackでは webpack2 から導入された

実際に試してみる

tree shaking を有効にする

webpack4 では、mode: 'production' でビルドすると基本的にtree shakingを使ってビルドされる。

mode: development でもtree shaking が有効にするには、optimization.usedExportstrue にする必要がある(production ではデフォルトで true になっている)。

▼ webpack.config.js

module.exports = {
+  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
+  optimization: {
+    usedExports: true,
+  },
};

関数をexport するファイルを作る

▼ src/math.js(新規作成)

export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

関数を import するファイルを作る

▼ src/index.js

import { cube } from './math.js'; // square は import しない

function component() {
  var element = document.createElement('pre');

  element.innerHTML = [
    'Hello webpack!',
    '5 cubed is equal to ' + cube(5)
  ].join('\n\n');
  return element;
}

document.body.appendChild(component());

ここでは、math.js がexport している関数のうち、cube 関数だけimport した。
これをビルドして確認してみる。

yarn webpack --display-used-exports

--display-used-exports は、export されたものの中でimport されたもの、つまり実際に使われているものを表示するオプション
これによってtree shakingがされているか確認できる。

tree shaking 無効にしてビルドした場合

比較用として optimization.usedExports: true を削除して結果を見てみる。

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
-  optimization: {
-    usedExports: true,
-  },
};

yarn webpack --display-used-exports の実行結果

Hash: 6f8fa3ee0588ed7315cf
Version: webpack 4.29.4
Time: 202ms
Built at: 2019-02-24 22:06:01
  Asset      Size  Chunks             Chunk Names
main.js  4.91 KiB    main  [emitted]  main
Entrypoint main = main.js
[./src/index.js] 267 bytes {main} [built]
[./src/math.js] 94 bytes {main} [built]
Done in 2.13s.

ビルドされたバンドルファイル(main.js)のサイズ:4.91 KiB となった。
そしてmain.js の中身を見てみる。

▼ dist/main.js

// 略

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./math.js */ \"./src/math.js\");\n\n\nfunction component() {\n  var element = document.createElement('pre');\n\n  element.innerHTML = [\n    'Hello webpack!',\n    '5 cubed is equal to ' + Object(_math_js__WEBPACK_IMPORTED_MODULE_0__[\"cube\"])(5)\n  ].join('\\n\\n');\n  return element;\n}\n\ndocument.body.appendChild(component());\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ }),

/***/ "./src/math.js":
/*!*********************!*\
  !*** ./src/math.js ***!
  \*********************/
/*! exports provided: square, cube */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"square\", function() { return square; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"cube\", function() { return cube; });\nfunction square(x) {\n  return x * x;\n}\n\nfunction cube(x) {\n  return x * x * x;\n}\n\n//# sourceURL=webpack:///./src/math.js?");

/***/ })

// 略

抜粋すると下記の箇所で、import していない(つまり使われていない)square 関数が math.js から export されていることが分かる。

__webpack_require__.d(__webpack_exports__, \"square\", function() { return square; });

tree shaking 有効にしてビルドした場合

次に、先ほど削除した optimization.usedExports: true を戻して tree shaking 有効にして結果を見てみる

yarn webpack --display-used-exports の実行結果

Hash: f0db8905752845fa5f0d
Version: webpack 4.29.4
Time: 127ms
Built at: 2019-02-24 22:01:14
  Asset      Size  Chunks             Chunk Names
main.js  4.83 KiB    main  [emitted]  main
Entrypoint main = main.js
[./src/index.js] 267 bytes {main} [built]
[./src/math.js] 94 bytes {main} [built]
    [only some exports used: cube]
Done in 1.69s.

バンドルファイル(main.js)のサイズ:4.83 KiB と先ほどよりも小さくなった
そして [only some exports used: cube] と「export されて使われているもの: cube 関数」と表示されており、tree shaking されていることが分かる。 更に main.js の中身を見てみる。

▼ dist/main.js

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/*! all exports used */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./math.js */ \"./src/math.js\");\n\n\nfunction component() {\n  var element = document.createElement('pre');\n\n  element.innerHTML = [\n    'Hello webpack!',\n    '5 cubed is equal to ' + Object(_math_js__WEBPACK_IMPORTED_MODULE_0__[/* cube */ \"a\"])(5)\n  ].join('\\n\\n');\n  return element;\n}\n\ndocument.body.appendChild(component());\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ }),

/***/ "./src/math.js":
/*!*********************!*\
  !*** ./src/math.js ***!
  \*********************/
/*! exports provided: square, cube */
/*! exports used: cube */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("/* unused harmony export square */\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return cube; });\nfunction square(x) {\n  return x * x;\n}\n\nfunction cube(x) {\n  return x * x * x;\n}\n\n//# sourceURL=webpack:///./src/math.js?");

/***/ })

下記抜粋個所を見てみると、square 関数はexport されずに、cube 関数だけexport されているのが分かる。

/* unused harmony export square */\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"a\", function() { return cube; });

ここ以外にも、main.js 内のコメントを見ると、そのモジュールは何をexport していて、何が使われているかわかる。

/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/*! all exports used */

// 略

/*!*********************!*\
  !*** ./src/math.js ***!
  \*********************/
/*! exports provided: square, cube */
/*! exports used: cube */

ここまでのまとめ

  • tree shaking とは、使われていない不要なデッドコードをバンドル時に削除する処理のこと
  • webpack4 では production モードでデフォルトで tree shaking が有効になっている
    • development モードで有効にするには、webpack.config.js で optimization.usedExports: true を設定する
  • tree shakingになっているかの確認は CLIコマンド時に --display-used-exports オプションをつける
    • export -> import されているモジュールを確認できる

Production モードでも不要なコードが含まれてしまう

先ほど作成した math.js で、使われていない square 関数は、development モードでは import こそされていなかったものの、バンドルファイルには含まれていた。
これは production モードでビルドすることで自動的に削除される。

次に下記のような式を含めてみる。

▼ src/math.js

export function square(x) {
  return x * x;
}
export function cube(x) {
  return x * x * x;
}
+ export const randomNum = Math.random();
+ export const aPromise = Promise.resolve('a');

これで production ビルドしてみると、バンドルされたファイルに Math.random()Promise.resolve('a') が含まれてしまっていた。

これはなぜかというと、副作用(side effect)を持つコードとして扱われるためと思われる。

副作用(side effect)とは

import 時に、特別な動作を実行すること。
バンドルから削除するかしないかで挙動が変わってしまうことを「副作用がある」という。

具体的な例で確認してみる。

例えば先ほどの math.js に副作用を持つコードを追加する。

export function square(x) {
  return x * x;
}
export function cube(x) {
  return x * x * x;
}
export const randomNum = Math.random();
export const aPromise = Promise.resolve('a');
+ export const aAlert = window.alert('a');

import する index.js側は特に変更せず、cube 関数のみ import している。

▼ src/index.js

import { cube } from './math.js';
// 以下略

これでビルドしてブラウザでアプリケーションを確認するとアラートが表示される。
なぜかというと、cube 関数を import するために math.js を解析した際に window.alert('a') が実行されるから。

もしバンドルからこの export const aAlert = window.alert('a'); を削除すれば、アラートは表示されなくなってしまう。
これが「副作用」である。

では、export const randomNum = Math.random();export const aPromise = Promise.resolve('a'); の場合はどうか?

もちろん副作用はないのだが、それはJavaScriptを知っている人間から見ているから分かることであって、モジュールバンドラ―は、export const aAlert = window.alert('a'); には副作用があって、そのほかは副作用がない、というのは通常は判断できない。
そのため、モジュールバンドラーは安全のために、副作用を持つ箇所がどこからも import されなかったとしても削除しない

副作用がないことをwebpackに伝える

逆を返すと、副作用がないことをモジュールバンドラーに伝えることができれば削除できる。

webpackの場合、UglifyJsPlugin の設定で明示的に pure_funcs: ['Math.random'] などと書くことで、副作用がないことを伝えることができる。

▼ webpack.config.js

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

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    usedExports: true,
+  minimizer: [new UglifyJsPlugin({ uglifyOptions: { compress: {
+    pure_funcs: ['Math.random', 'Promise.resolve']
+  }}})]
  },
};

これで改めて production ビルドしてみると、バンドルファイルから Math.random()Promise.resolve('a') が無くなっていることが確認できる。 UglifyJsPlugin を使用する場合は、pure_funcs を指定するのが良いかもしれない。

ちなみに Rollup の pureFunctions.ts に副作用のない関数の一覧があるので参考にできそう。 rollup/pureFunctions.ts at v0.59.4 · rollup/rollup · GitHub

package.jsonsideEffects プロパティを指定

package.jsonsideEffects は、副作用が含まれるファイルを指定するためのプロパティとして、 webpack4 で導入された。

下記のように false を指定した場合、全ファイルが副作用を含まないことを示す。

{
  "name": "your-project",
  "sideEffects": false
}

副作用があるコードを指定する場合は下記のように配列で指定する(それ以外は副作用がないと見なされる)。

{
  "name": "your-project",
  "sideEffects": [
    "./src/some-side-effectful-file.js",
    "*.css" // ワイルドカードも使用可。この場合、全CSSファイルが対象
  ]
}

検証

まずは sideEffects を指定せずに検証してみる。

▼ src/expo/foo.js(新規作成)

export const foo = 'foo';
console.log('foo.js was loaded.');

▼ src/expo/bar.js(新規作成)

export const bar = 'bar';
console.log('bar.js was loaded.');

▼ src/expo/index.js(新規作成)

export * from './foo';
export * from './bar';

▼ src/index.js

import { cube } from './math.js';
+ import { foo } from './expo';

function component() {
  var element = document.createElement('pre');
+  console.log(foo);
  // 略
}

document.body.appendChild(component());

src/expo/foo.jssrc/expo/bar.jsconsole.log() が含まれているため副作用をもっている。
これをそのままビルドして表示を確認してみると、bar をエントリーポイント(src/index.js)上でimport していないが、コンソール上で

foo.js was loaded.
bar.js was loaded.
foo

と表示される。

次に sideEffects: false にしてビルドすると bar は読み込まれずにファイル容量も小さくなっていた。

webpack4では sideEffects の指定で副作用がないことになっているファイルの exports がどこからも imports されない場合、副作用の有無の検証なしでUglifyJS に渡す手前でそのファイルを削除するとのこと。

そのため、実際にこれを利用するには対象のファイルが本当に副作用を含まないのか、含んだとしても削除しても良いのかを確認する必要がある。

※プロジェクトで css-loaderのようなものを使用してCSSファイルをインポートする場合は、production モードで誤って削除されないようにsideEffects に追加する必要がある。

まとめ

  • tree shaking を有効に使うためには、ES2015 の import / export を使う
  • ES2015モジュールの構文をCommonJSモジュールに変換するコンパイラがないことを確認する(CommonJSをtree shakingするのは困難)
  • production モードを利用することで、ある程度最適化できる
  • プロジェクトのpackage.jsonsideEffects プロパティを追加することでよりtree shakingを促進できる
    • 本当に副作用があるのかないのかファイルごとに確認する必要あり

参考