JavaScript
es6
ESModules
Fringe81Day 16

JavaScriptのexport defaultアンチパターンについて、検証してみた

この記事はFringe81 アドベントカレンダー2017の16日目です。

始めに

ES6のimport/exportについては
・基本的にexport defaultを使う
・exportする対象が複数あれば、exportを使う
・exportされたモジュールはimportで受け取る
ぐらいの雑な理解をしてました。

「基本的にexport defaultを使う」については、Design goals for ES6 modulesにて、以下のように言及されてます。

16.8 Design goals for ES6 modules
If you want to make sense of ECMAScript 6 modules, it helps to understand what goals influenced their design. The major ones are:
・Default exports are favored
・Static module structure
・Support for both synchronous and asynchronous loading
・Support for cyclic dependencies between modules

ECMAScript 6 favors the single/default export style, and gives the sweetest syntax to importing the default.
Importing named exports can and even should be slightly less concise.

ES6は、単一値をexport defaultするスタイルを好む。なるほど〜。

export defaultは使わないほうがいい?

ところが先月はてブで、ES modulesのexport defaultは使わないほうがよいという記事タイトルを拝見しました。

あれ?そうだっけ?

「export default推奨」程度の理解しかなかったので、気になって色々調べてみました。(ちなみに先程の記事は、export対象に名前が必須でないため、コード補完と相性が悪いというのが主な内容でした)

色々漁ってみると
こちらの記事では、以下の事が言及されてました。

Note that default-exporting objects is usually an anti-pattern (if you want to export the properties).
You lose some ES6 module benefits (tree-shaking and faster access to imports).

オブジェクトをexport defaultするのはアンチパターンである(何故なら、ツリーシェイキングやimportへの早いアクセスという観点で、ES6のモジュールの恩恵を受けられない)

へぇ。

でもぶっちゃけると、アンチパターンの理由というか、具体的に何が困るのかしっくり来なかったので
ツリーシェイキング観点で、調査してみました。

そもそもTree-Shakingとは

簡単に言うと「JSファイルを1つにbundleする際にimportされていないexport文を消す。そしてminifyする際に完全に削除する」機能です。
ES6の静的解析で、使われていない文をジャッジ出来るそうです。詳しくはTree-shaking with webpack 2 and Babel 6にて。

Tree-Shakingを試してみた

webpackとbabelを使って、Tree-Shakingの機能を確認してみました。webpack.configとbabelrcは以下のように設定。

// webpack.config.js
const path = require('path');
module.exports = {
  entry: {
    app: ['./entry.js']
  },
  output: {
    path: path.join(__dirname, 'build'),
    filename: '[name].bundle.js'
  },
  devtool: '#source-map',
  module: {
    loaders: [
      {loader: 'babel-loader', exclude: /node_modules/, test: /\.js$/}
    ]
  },
  resolve: {
    extensions: ['.js']
  }
};

// .bablerc
{
  "presets": ["es2015"]
}

早速、以下のサンプルコードをwebpackでbundleします。

// exportA.js
export function foo() {
  return 'foo';
}
export function bar() {
  return 'bar';
}

// entry.js
import {foo, bar} from './exportA';
console.log(foo(), bar());

//app.bundle.js
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.foo = foo;
exports.bar = bar;
function foo() {
  return 'foo';
}
function bar() {
  return 'bar';
}

fooとbarが両方exportsされてます。
では、サンプルコードからbarを使わないように修正して再度出力します。

// entry.js
import {foo, bar} from './exportA';
console.log(foo());

// app.bundle.js
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.foo = foo;
exports.bar = bar;
function foo() {
  return 'foo';
}
function bar() {
  return 'bar';
}

あれ?barもexportされてるやん!

ESmoduleからcommonJSへの変換を無効にする必要あり

webpackのpluginでbabel-loaderを使っている場合、ESモジュールがcommonJSに変換されてしまい、使われていないexport文が削除されなくなるそうです(ここは正直あまりわかっておらず)、詳しくはWebpack2のTree Shakingを試すにて。

commonJSへの変換を無効化するため、babelrcを以下のように修正して、再チャレンジ。

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

// entry.js
import {foo, bar} from './exportA';
console.log(foo(), bar());

// app.bundle.js
"use strict";
/* harmony export (immutable) */ __webpack_exports__["b"] = foo;
/* harmony export (immutable) */ __webpack_exports__["a"] = bar;
function foo() {
  return 'foo';
}
function bar() {
  return 'bar';
}

bundleファイルの内容が変わりましたね。先程の設定では exports.XXX でしたが、今回はexport対象は __webpack_exports__ に格納されてるようです。
では、サンプルコードからbarを消します。

// entry.js
import {foo, bar} from './exportA';
console.log(foo());

// app.bundle.js
"use strict";
/* harmony export (immutable) */ __webpack_exports__["a"] = foo;
/* unused harmony export bar */
function foo() {
  return 'foo';
}
function bar() {
  return 'bar';
}

おおーー!
__webpack_exports__barが格納されなくなりましたね!
代わりにunused harmony export barとしてコメントアウトされてますね。

ちなみにbundleファイルにはbar関数の定義が残っておりますが、minifyすると完全にbarという文字列が削除されます。(確認程度のためだったので、Minify your Javascriptでminifyしました。)

export defaultでTree-Shakingしてみる

Tree-Shakingの機能自体はわかったので、オブジェクトをexport defaultし、Tree-Shakingの恩恵を受けられないかどうか、確認してみましょう。

export default オブジェクト

以下、サンプルコードその2。

// defaultA.js
export default {
  foo: function() {
    return 'foo';
  },
  bar: function() {
    return 'bar';
  }
}

// entry.js
import TestFunc from './defaultA';
console.log(TestFunc.foo(), TestFunc.bar());

// app.bundle.js
"use strict";
/* harmony default export */ __webpack_exports__["a"] = ({
  foo: function foo() {
    return 'foo';
  },
  bar: function bar() {
    return 'bar';
  }
});

なるほど。__webpack_exports__には、foo、barをプロパティとして持つオブジェクトが丸ごと格納されてますね。
では、サンプルコード2からTestFuncを消して再度出力。

// entry.js
import TestFunc from './defaultA';
console.log(TestFunc.foo());

// app.bundle.js
"use strict";
/* harmony default export */ __webpack_exports__["a"] = ({
  foo: function foo() {
    return 'foo';
  },
  bar: function bar() {
    return 'bar';
  }
});

なるほどー。bar()はこのタイミングでは削除されないんですね。
export foo() export bar()の時は、fooとbarをそれぞれexportしていたので、未使用のbarがexportされなくなったのですが
export defaultの場合、fooもbarもプロパティとして1つのオブジェクトがexportされるので、消えないのですかね。

ちなみに minifyしてもbarの文字列は消えませんでした。

では、fooもbarも両方消してみましょう。

// entry.js
import TestFunc from './defaultA';

// app.bundle.js
"use strict";
/* unused harmony default export */ var _unused_webpack_default_export = ({
  foo: function foo() {
    return 'foo';
  },
  bar: function bar() {
    return 'bar';
  }
});

おおお。__webpack_exports__に何も格納されず、 _unused_webpack_default_export にexportされたオブジェクトが格納されてますね! _unused_webpack_default_export はコード内でどこにも使われておりません。
unsedに格納されているからかと思いますが、minifyするとfooもbarも完全に消えました。

export default class

classでも一応確認してみました。(Reactのコンポーネントはexport default classをよく使うため)
以下サンプルコード3。

// defaultB.js
export default class {
  constructor() {
    this.foo = function() {
      return 'foo';
    };
    this.bar = function() {
      return 'bar';
    };
  }
}

// entry.js
import TestClass from './defaultB';
const t = new TestClass();
console.log(t.foo(), t.bar());

// app.bundle.js
"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var _class = function _class() {
  _classCallCheck(this, _class);

  this.foo = function () {
    return 'foo';
  };
  this.bar = function () {
    return 'bar';
  };
};
/* harmony default export */ __webpack_exports__["a"] = (_class);

予想通り、__webpack_exports__ にclassが丸ごと格納されてます。
では、TestClassを削除して再チャレンジ。

// entry.js
import TestClass from './defaultB';

// app.bundle.js
"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var _class = function _class() {
  _classCallCheck(this, _class);

  this.foo = function () {
    return 'foo';
  };
  this.bar = function () {
    return 'bar';
  };
};

/* unused harmony default export */ var _unused_webpack_default_export = (_class);

勿論でしたが、classはオブジェクトと同じ動きでした。

export default プリミティブ型

オブジェクト
適当な文字列をexport defaultしてみよう。以下サンプルコード4。

// defaultC.js
const Foo = 'foo';
export default Foo

// entry.js
import Foo from './defaultC';
console.log(Foo);

// app.bundle.js
"use strict";
var Foo = 'foo';
/* harmony default export */ __webpack_exports__["a"] = (Foo);

では、Fooを削除しましょう。

// entry.js
import Foo from './defaultC';

// app.bundle.js
"use strict";
var Foo = 'foo';
/* unused harmony default export */ var _unused_webpack_default_export = (Foo);

お!?
プリミティブ型でも、_unused_webpack_default_export に格納されるんですね(minifyすると削除されました。)

ああーー、なるほど。
どういう型であれ、import側で使われるものは __webpack_exports__ に格納され、使われないものは _unused_webpack_default_exportに格納されると。(プリミティブ型は、bundleのタイミングで消されるとか思っちゃってました)

考察/まとめ

Tree-Shakingの観点だと以下のことが言えるのかな〜と思いました。
・export defaultの対象は「単一値」であるもの(プリミティブ型や、クラス型)が推奨される。
・export defaultの対象が、複数のプロパティを持ち得るオブジェクトの場合、importで使われないプロパティも、exportされるし、minifyしても消えない。何故ならオブジェクト丸ごとexportするため。
・一方でexportA/exportBでは、個々をexportしているため、impotで使われないプロパティはexportされない。
・bundle.jsの容量が大きいことが、何かしらのボトルネックになる場合、export defaultオブジェクトはやめた方がいいかも。

以上です。ありがとうございました!