このポストは、JavaScript Modules Part 2: Module Bundling
の意訳になります。
ちなみにPart1を意訳したものが[意訳]初学者のためのJavaScriptモジュール講座 Part1です。
何か間違いなどありましたらご指摘いただけると幸いです。
(以下、意訳)
Part1の記事では、モジュールとは何か、なぜ開発者はモジュールを使うべきか、そしてプログラムにモジュールを組み込む色々な方法について話しました。
このパート2の記事では、モジュールを”バンドル”するとはどういうことなのかについて取り組みます。そして、なぜ私たちはモジュールをバンドルするのか、それを実現するための方法について、そして、Web開発におけるモジュールの未来について話していこうと思います。
モジュールバンドルとは
広義のモジュールバンドルとは、正しい順番で、複数のモジュール(及び依存性)を1つ(あるいは複数)のファイルへとくっつけることを言います。
Web開発のあらゆる側面と同様に、悪魔は細部に宿ります。
いったいなぜモジュールをバンドルするのか
プログラムをモジュール単位に分割するとき、あなたは普通、複数の異なるファイルやフォルダにモジュールを整理するでしょう。おそらく、UnderscoreやReactのような使用ライブラリのためのモジュールがあるはずです。
結果として、それらのファイルはそれぞれメインとなるHTMLに<script>
タグとして読み込まれるはずです。読み込んだモジュールは、ユーザーがサイトに訪れた際にブラウザによってロードされるます。複数ファイルのための<script>
タグがあると、ブラウザは別個にロードしなければならなりません。1つ…1つ…です。
…それは、ページ読み込み時間にとって良くない知らせです。
この問題を回避するためには、私たちは、リクエストの数を減らすために全てのファイルを1つの大きなファイルへとバンドル、あるいは”鎖状につなぎ”ます。開発者が”ビルドステップ”とか”ビルドプロセス”なんて言ってるのを聴いたことがあるかもしれないが、これが彼らの話してたことです。
バンドルスピードを早めるもう1つの有名な手法は、バンドルされたコードを”圧縮”することです。圧縮はソースコードから不要な文字を除去する作業です。(例、空白・コメント・改行文字。)その目的は、コードの役目を変えることなく、ファイルの総サイズを減らすことです。
データが少ないほど、ブラウザのプロセス時間は短くなります。それはつまり、ファイルのダウンロード時間の短縮に繋がるのです。”underscore.min.js”のように”min”という拡張子がついたファイルを見たことがあるかもしれませんが、圧縮されたファイルのサイズは、そうでないファイルに比べて極めて小さいと気づいたはずでしょう。(そして、読むこともできません)
GulpやGruntのようなタスクランナーは、開発者のためにファイルの鎖つなぎや圧縮をしてくれます。そしてその結果、人間の読めるコードは開発者の目の見えるところに残しつつ、ブラウザのためにバンドルされたコードもまた生まれるのです。
モジュールをバンドルするための色々な方法とは?
(前回のポストで話したような)モジュール定義のためにスタンダードなモジュールパターンを取っていれば、ファイルの鎖つなぎと圧縮はとても上手く行くでしょう。あなたがするべきことは、プレーンなJavaScriptコードの集まりを、1つに混ぜることだけです。
しかし、もしあなたがCommonJSやAMDのようなネイティブではないモジュールシステムにまだ固執しているなら、モジュールをブラウザフレンドリーなコードに変換するために特殊なツールを使う必要があります。それが、BrowserifyやRequireJSやWebpack, そして他の”モシュールバンドラ”や”モジュールローダー”の出番です。
モジュールのバンドルとロードに加えて、変更を加えた時のコード自動コンパイルやデバッグのためのソースマップの生成など、モジュールバンドラは無数の付属機能を提供します。
よく知られたモジュールバンドル方法について、見ていきましょう。
CommonJSのバンドル
Part1でお話したように、CommonJSは同期的にモジュールをロードしますが、それはブラウザでなければ上手くいきます。以前言及したように、これには回避策があります。1つは、Browserifyというモジュールバンドラを使うことです。BrowserifyはCommonJSのモジュールをブラウザ用にコンパイルするツールです。
例えば、配列要素の平均値を求めるモジュールをimportする以下のようなmain.jsがあるとしましょう。
var myDependency = require(‘myDependency’);
var myGrades = [93, 95, 88, 0, 91];
var myAverageGrade = myDependency.average(myGrades);
この場合、(myDependencyという)1つの依存性があります。以下のコマンドを打つことで、Browserifyはmain.jsから始めるモジュールを読み込み、単一のbundle.jsというファイルへと再帰的にバンドルします。
$browserify main.js -o bundle.js
Browserifyは、プロジェクトの依存性グラフ全体をトラバースするために、それぞれの_require_呼び出しのASTをパースしてバンドルを実行します。ひとたび依存性を把握すると、全てのモジュールを1つのファイルへと正しい順番でバンドルします。この時点でようやく、1つにHTTPリクエストで全てのソースコードがダウンロードされるべく、_”bundle.js”_を読み込む<script>
タグをHTMLに挿入すればよいのです。
複数の依存性を持った複数ファイルの場合でも同様に、entryファイルがどれなのかをBrowserifyに伝えて、ただ魔法が実行されるのを座って待っていればいいのです。
最終的には、バンドルされたファイルが用意されて、Minify-JSのような圧縮ツールにかけることができます。
AMDのバンドル
もしAMDをお使いなら、RequireJSやCurlのようなAMDローダーを使いたいでしょう。モジュールローダーは(バンドラと比べて)、プログラムが実行に必要なモジュールを動的にロードします。
思い出して欲しいのですが、CommonJSとAMDの主な違いとは、モジュールを非同期にロードする点でした。つまりAMDを使うと、モジュールを非同期でロードするので、モジュールを1つのファイルにバンドルするようなビルドステップが不要なのです。その意味するところは、ユーザーがサイトへ訪問した際にすべてのファイルを1度にダウンロードする代わりに、プログラムを実行するために本当に必要なファイルだけを段階的にダウンロードしているということです。
しかし現実はこうです。実際の環境において、全てのユーザーのアクションごとに高容量のリクエストをやりとりするにはコストがかかりすぎます。ほとんどのWeb開発者は、例えばRequireJSオプティマイザであるr.jsのようなビルドツールを使って、パフォーマンス最適化のためにAMDモジュールをバンドルして圧縮しています。
結局のところ、ビルドにおけるAMDとCommonJSの違いとは次のとおりです。開発の間、AMDで作られたアプリケーションはビルドステップを必要としません。少なくとも、r.jsのようなオプティマイザがコードに干渉することができるような、コードの公開段階までは。
CommonJSとAMDの興味深い比較議論については、AMD is Not the Answerという記事をご覧ください。
Webpack
バンドラに関する限りは、Webpackはその分野での新生児です。Webpackはモジュールシステムを気にしないでいいように設計されており、開発者はCommonJSやAMDや、あるいはES6を必要に応じて使えるようになります。
BrowserifyやRequireJSのような充分なバンドラが既に存在するのに、なぜWebpackが必要なのかとあなたは疑問に思うかもしれません。まず第一に、Webpackは"コード分割"のような便利な機能を提供します。コード分割とは、必要に応じてロードされる”chunks”へとコードベースを分割する仕組みです。※chunk = 塊
例えば、特定の条件でのみ必要となるコードの塊があるとしましょう。1つの巨大なバンドルファイルにそれを組み込んでしまうのは、効率的とは言えないでしょう。このとき、バンドル済みのchunkへとコードを抽出するためにコード分割が使えます。バンドル済みのchunkは必要に応じてロードすることができるので、アプリケーションのコア部分のみが必要であるような時に、大量のロードが行われてしまうような問題を防ぎます。
コード分割は、Webpackの提供する素晴らしい機能の1つに過ぎません。そして、ネット上にはWebpackかBrowserifyかと言った過激な議論がたくさん溢れています。有意義だと思われる、分別のある議論のいくつかを載せておきます。
- https://gist.github.com/substack/68f8d502be42d5cd4942
- http://mattdesl.svbtle.com/browserify-vs-webpack
- http://blog.namangoel.com/browserify-vs-webpack-js-drama
ES6のモジュール
読み終わりましたか?良いですね!ここからは、将来的にはバンドラの必要性を薄めてくれるだろうES6のモジュールについて話したいと思います。(あなたは私の意図がすぐに分かるはずです)まず、ES6のモジュールがどのようにロードされるのかを理解しましょう。
現行の(CommonJSとAMDのような)JSモジュールのフォーマットと、ES6のモジュールの最も大切な違いとは、ES6のモジュールが静的解析を念頭に設計されていることです。これは、モジュールをimportする時に、コンパイル時にimportが解析されるということを意味します。そしてコンパイル時とはつまり、スクリプトが実行される前ということです。これによってプログラムを動かす前に、どのモジュールからも使われていないexportsを削除することができます。不使用のexportsを削除することで、大幅な容量の確保につながりますし、ブラウザのストレスを削減することができます。
ここで1つの疑問が浮かび上がってきます。コード圧縮のためにUgilifyJSのようなものを使うときに起こるDeadCodeEliminationとはどう違うのでしょうか?答えは”場合による”としか言えません。
(注訳:DeadCodeEliminationは不使用のコードと変数を削除する最適化ステップです。バンドル後のプログラムを実行のために不要な過剰ゴミを捨て去るようなものだと考えてください。だたし、それはバンドル後に行われます)
DeadCodeEliminationはUgilifyJSとES6のモジュールで同じ挙動をするときもあれば、そうでないときもあります。もし気になるようでしたら、Rollupのwikiに良い例があります。
ES6のモジュールが異なる点は、"Tree shaking"と呼ばれる異なったDeadCodeEliminationの手法です。TreeShakingとは、本質的にはDeadCodeEliminationの逆です。バンドルの際に、不要なコードを除外するのではなく、必要なコードのみを”含む”のです。TreeShakingの例を見ていきましょう。
以下のような関数を持つutil.jsというファイルがあるとしましょう。それぞれの関数は、ES6のシンタックスによってexportされています。
export function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
}
export function filter(collection, test) {
var filtered = [];
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
}
export function map(collection, iterator) {
var mapped = [];
each(collection, function(value, key, collection) {
mapped.push(iterator(value));
});
return mapped;
}
export function reduce(collection, iterator, accumulator) {
var startingValueMissing = accumulator === undefined;
each(collection, function(item) {
if(startingValueMissing) {
accumulator = item;
startingValueMissing = false;
} else {
accumulator = iterator(accumulator, item);
}
});
return accumulator;
}
次に、どのutil関数が必要となるか分からないとしましょう。なので、main.jsで以下のように全てのモジュールをimportしておきましょう。
import * as Utils from ‘./utils.js’;
そして結局each関数しか使わなかったとしましょう。
import * as Utils from ‘./utils.js’;
Utils.each([1, 2, 3], function(x) { console.log(x) });
TreeShaking風のmain.jsファイルでは、モジュールがロードされると以下のようになります。
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
each([1, 2, 3], function(x) { console.log(x) });
__each__という使用するexportだけが含まれていることにお気づきでしょうか?
もしeach関数の代わりにfileter関数が必要になったとしたら、以下のようになるでしょう。
import * as Utils from ‘./utils.js’;
Utils.filter([1, 2, 3], function(x) { return x === 2 });
TreeShaking風だとこのようになります。
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
function filter(collection, test) {
var filtered = [];
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
};
filter([1, 2, 3], function(x) { return x === 2 });
__each__と__filter__関数の両方が含まれていることに気づくでしょう。これは__filter__が__each__を使うように定義されているためであり、モジュールが作用するためには2つのexportsが必要なのです。
とても巧妙でしょう?
Rollup.jsのライブデモとライブエディタでTreeShakingを試してみてください。
ES6のビルド
さて、ES6のモジュールのロードは、他のモジュールのフォーマットは異なるということを学びました。しかし、ES6のモジュールをどうやってビルドするかについてはまだ触れていません。
残念ながら、ES6のモジュールを使うには追加の作業が必要です。なぜならES6のモジュールをロードする方法はまだ、ブラウザには実装されていないためです。
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
ブラウザでES6のモジュールを作用させるためには、いくつかのビルド・変換方法があります。現時点では、1の手法がもっとも一般的です。
- CommonJSやAMDやUMDのフォーマットとしてES5にES6のコードをトランスパイルするために(BabelやTraceurのような)トランスパイラを使う。そして、BrowserifyやWebpackのようなモジュールバンドラを利用してトランスパイルされたコードを1つ、あるいは複数のバンドル済みファイルへとつなぎ合わせる。
- Rollup.jsを使う。これは1の手法ととても近いものの、RollupはES6のモジュールにパワーを搭載しているので、バンドル前にES6のコードと依存関係を静的に解析する点が異なります。バンドルに際しては、”TreeShaking”を利用して最低限必要なコードのみを持ち込みます。つまるところ、ES6のモジュールを利用するにあたってRollup.jsがBrowserifyとWebpackに優る点とは、TreeShakingがバンドル済みファイルを小さくしうる点です。気をつけるポイントとしては、RollupはコードをCommonJSやAMDやUMDやIIFEへとバンドルするにあたって、複数のフォーマットに対応しています。IIFEとUMDのバンドル後のファイルは、そのままでブラウザで使うことができます。しかしAMDとCommonJSとES6のフォーマットを選んだ場合は、コードをブラウザが理解できるものへと変換する必要があります。(例, BrowserifyやWebpackやRequireJSなどを使う)
輪っかをくぐる
Web開発者として、私たちはたくさんの輪っかをくぐらなければいけません。※輪っかをくぐる(= Jumping through hoops)は「たいへんなことをする」という意味のスラング
美しいES6のモジュールをブラウザが解析できるように変換するのは、いつだってカンタンじゃあありません。
問題は、いつブラウザがES6のモジュールを実行できるようになるのかということです。
ありがたいことに答えは”まもなく”です。
ECMAScriptには現在、ECMAScript6 モジュールローダーAPIという名前の仕様があります。要するに、これはプログラム的なPromiseをベースとしたAPIです。それはつまり、モジュールを動的にロードしてキャッシュも行うはずなので、次回のimportが新しいモジュールを再読み込みすることはありません。
これは以下のようになるでしょう。
myModule.js
export class myModule {
constructor() {
console.log('Hello, I am a module');
}
hello() {
console.log('hello!');
}
goodbye() {
console.log('goodbye!');
}
}
main.js
System.import(‘myModule’).then(function(myModule) {
new myModule.hello();
});
// ‘hello!’
代わりに、scriptタグで"type=module"とすることで、モジュールを定義することもできます。
<script type="module">
// loads the 'myModule' export from 'mymodule.js'
import { hello } from 'mymodule';
new Hello(); // 'Hello, I am a module!'
</script>
まだモジュールローダーAPIをチェックしていないのなら、ちょっとでも見ることを強くオススメします。
さらに言えば、この手法をテスト駆動で行いたいのなら、SystemJSをチェックするとよいでしょう。これは、ES6のモジュールローダーのぽリフィルとして作られました。SystemJSはどんなモジュールのフォーマットを採用したとしても、ブラウザでもNodeでも動的にそれらをロードします。(ES6モジュール、AMD、CommonJS、あるいはグローバルスクリプト等)さらに、ロードされたすべてのモジュールを"モジュールレジストリ"に記憶するので、モジュールを再びロードしてしまうことを防げます。ES6のモジュールを自動でトランスパイラすることは言うまでもないでしょう。ただオプションとして指定すれば良いのです。とんだ優れものですね。
いまやネイティブのES6モジュールがあるのに、まだバンドラは必要なのか?
ES6のモジュール人気の高まりは、面白い結果を生みました。
HTTP2はモジュールバンドラを時代遅れにするか?
HTTP1では、TCP接続毎に1つのリクエストしか許されていませんでした。だから、複数のリクエストで複数リソースをロードするのです。HTTP2では、全てが変わります。HTTP2は完全に多重化されているので、複数のリクエストとリスポンスが平行して行われます。その結果、1回の接続で同時に複数のリクエストをさばくことができます。
HTTP1に比べてHTTPリクエストのコストは驚くほど低いので、複数モジュールのロードは長期的にはパフォーマンス上の大きな問題とならなくなるでしょう。これによってモジュールバンドルが必要なくなるのだと主張する人もいます。それはおおいにありうるでしょうが、状況によるとしか言いようが無いでしょう。
1つの理由として、モジュールバンドルは、例えば容量削減のために不使用のexportsの削除するようなHTTP2にはないような利点があります。もし些細なパフォーマンスが問題となるようなウェブサイトを構築しているのなら、長い目で見ればバンドルは小さなご利益をもたらし続けるでしょう。ということは、高パフォーマンスがそこまで求められないのなら、ビルドステップを完全にスキップして時間を節約することもできます。
結局のところ、ウェブサイトのコードをHTTP2仕様に書くのは、まだまだ先のことです。_少なくとも_直近では、ビルドプロセスは現行のままだろうという気がしています。
追記:HTTP2との違いについては、まだ他にもあります。もし興味がおありでしたら、素晴らしい資料があります。
CommonJSとAMDとUMDは時代遅れになるのか
ES6がモジュールのスタンダードになったとき、ネイティブではない他のモジュールのフォーマットって、本当に必要なのでしょうか?
JavaScriptにおけるモジュールのimportとexportに関して、中間ステップを排して1つの標準化された手法に従うことでWeb開発は快適になります。ES6がモジュールの標準になるまでに、どれくらいかかるのでしょうか?
たぶん、かなり長い間がかかるでしょう。
それに、多くの人は選べる”風味”があることを好みます。なので、”1つの真実の手法”が現実になることはないのでしょう。
結論
モジュールやモジュールバンドルについて開発者たち使う専門用語がありますが、この全2回のポストによってそれらがクリアになったことを望みます。モジュールやモジュールバンドルに関して、まだ混乱しているのならば、Part1を見に行ってください。
いつものように、コメントで気軽に質問してくださいね!
ハッピーバンドル :)