これから始めるwebpack 第1回 webpackの基礎知識

webpackはモジュールバンドラと呼ばれるツールのひとつです。シリーズ第一回は、webpackがどのような問題を解決しようとして生まれたツールなのか、特徴的な機能にはどのようなものがあるかを解説します。

発行

著者 藤田 智朗 フロントエンド・エンジニア
これから始めるwebpack シリーズの記事一覧

はじめに

2017年現在、フロントエンドの開発に携わっている方であれば、webpackの名前を聞いたことがない、という方はいないのではないでしょうか。

webpackは最新のJavaScriptアプリケーション用のモジュールバンドラで、アプリケーションにおけるモジュールの依存関係を解析して1つまたは複数のパッケージにバンドルします。

競合としてrollup.jsFuseBoxがあるものの、webpackは今では多くのスポンサーやコントリビューターを獲得し、これらライバルを抑えてデファクト・スタンダードの地位を築いていると言えるでしょう。

一方で、webpackの評価については「設定の仕方がわからない」「できれば触りたくない」といった声を耳にすることもあります。どちらかと言うとネガティブな要因のほうが多いように感じています。

webpackの設定が複雑でわかりにくいことは否定しませんが、まずはなぜwebpackが必要になったかの経緯を知ってみるのもいいかもしれません。そして、いきなり全部の機能を使いこなそうとしないで、基本的な機能から理解していけば、webpackの見方もずいぶん変わるのではないかと筆者は考えています。

webpackの使い方を学ぶ前に、まずはwebpackが開発された経緯を追ってみましょう。webpackの本質を理解するには、旧サイトのwhat is webpackmotivationに目を通すのが良いと思います。

ここに記されているwebpackが開発されることになった経緯や、webpackが目指す目標について噛み砕いて説明します。

【ワンポイント】CLIツールによるwebpackの隠蔽

最近では、AngularやVue.jsの開発に使用されるCLIツールによってwebpackが隠蔽され、webpackの設定をしなくても開発が続けられる環境が整ってきています。 これらCLIツールを使うことで開発効率を向上させることができるプロジェクトでは積極的に活用していくことをおすすめします。

その上でCLIツールを介してwebpackが裏でどのようなことをしているのか理解しておくと良いでしょう。

webpackが開発された経緯

webpack以前には、BrowserifyGruntgulpを組み合わせたモジュールシステムの仕組みが、もっともポピュラーであったと思います。明言はされていませんが、おそらくwebpackの作者も以前はこれらのツールを使用していたのではないかと考えられます。

では、なぜこれら既存のモジュールシステムではいけなかったのでしょうか。

結論から言ってしまえば、webpack以前のモジュールシステムが大規模なシステムに適していなかったということに尽きます。webpack以前のGrunt、gulpやBrowserifyは、基本的にはすべてのモジュールを1つのファイルに結合して出力するように設計されています。

かつては、プロジェクト全体でapp.jsbundle.jsといった容量の大きいファイルを1つだけ出力していたという読者も多いのではないでしょうか。

しかし、昨今ではフロントエンドの担う役割は年々増大しており、それに伴ってフロントエンドの占めるコードの割合も増大しているので、それを1つのファイルに落とし込むとなると、ファイルサイズはかなりのものになります。これがページ数が何百ページにも及ぶ大規模なアプリケーションだった場合、ファイルサイズは数十MB、もしかしたら数百MBということにもなりかねません。そのファイルを初回アクセス時にユーザーに読み込ませるとなると、無関係のコードも含まれる1つのファイルの読み込みを、ユーザーは延々と待たされることになります。

また、あまり使用されない機能のちょっとした変更であっても、プロジェクト全体の処理が含まれるファイルが更新されることになるため、キャッシュが非効率になるという問題もあります。

理想はアクセスしたページに依存関係のあるコードだけが含まれるファイルが読み込まれ、初期ローディングが必要最小限に抑えられることでしょう。これらの問題を解決するために既存のモジュール・バンドラーを拡張することも試みられたそうですが、目的を果たすことができずwebpackを開発するに至ったことが記されています。

webpack以外のモジュールシステムの特徴

先ほど説明した問題を解決するためにwebpackがどのようなアプローチを取るのかを説明する前に、その他のモジュールシステムについてそれぞれの利点と欠点を確認しておきましょう。

<script>タグ

<script src="../lib/common-module.js"></script>
<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="control1.js"></script>
<script src="control2.js"></script>

<script>タグの使用は、Node.jsの登場以前から、モジュールベースのコードを実現するために取られたアプローチです。

モジュールは、グローバルオブジェクト(window)へのインタフェースをエクスポートし、グローバルオブジェクトを介して依存関係にアクセスします。

  • 利点
    • モジュールシステムのためのツール等のインストールや導入を必要としないため、小規模なJavaScriptのアプリケーションであれば手軽
  • 欠点
    • グローバルオブジェクトの衝突を気にかけないといけない
    • ファイルの読み込む順番を気にかけないといけない
    • 依存関係を開発者自身が解決しなければいけない
    • 大規模なプロジェクトでは読み込むファイル数が膨大になり、管理することが困難になる

CommonJS:同期require

function foo() {
  return 'foo!';
}

module.exports = foo;
var foo = require('./foo');

同期requireメソッドで依存関係を読み込み、エクスポートされているインターフェースを取得します。モジュール側はexportオブジェクトへプロパティを追加したり、module.exportsに値を設定することでエクスポートします。Node.jsやBrowserifyでこの形式が使用されています。

  • 利点
    • シンプルで扱いやすい形式
    • サーバーサイドのモジュールを再利用することができる
    • npm上の多くのモジュールがこの形式で書かれている
  • 欠点
    • モジュールをネットワークを介して非同期に読み込むというケースに適していない
    • モジュールを並列に読み込むことができない

AMD:非同期require

define([
  'js/lib/jquery'
], function($) {
  var Hoge = function() {...};
  Hoge.prototype.awesomeMethod = function() {/* ... */};
  return Hoge;
});
require([
  'js/module/hoge'
], function(Hoge) {
    var hoge = new Hoge();
});

ネットワークを介するブラウザに対してCommonJSの形式による同期requireでは解決できない問題を、非同期のバージョンとして解決が図られている形式です。RequireJSでこの形式が使用されています。

  • 利点
    • ネットワークを介する非同期リクエストに合った形式
    • 複数のモジュールを並列に読み込むことができる
  • 欠点
    • 依存関係に関するコードの読み書きが煩雑になる

ES modules

export default function(name) {
  console.log(`Hello, ${name}!`);
}
import greet from './greet.js';
greet('Alice');

ECMAScript 2015では、モジュールシステムが言語構成の一部としてJavaScriptに追加されました。CommonJSやAMDなど、複数のシステムがある背景には、JavaScriptの標準であるECMAScriptにそもそもモジュールの仕組みがなかったことにあります。ECMAScript 2015でついに、このような標準のモジュールシステムが導入されました。

*注:ES modulesについて

ES modulesの詳細については、CodeGridの次のシリーズも参考にしてください。

  • 利点
    • 静的な解析が容易
    • ECMAScriptの標準なので、将来的にJavaScriptを使うエコシステムで広く導入されることが期待される
  • 欠点
    • ほとんどのメジャーブラウザでサポートされ始めたものの、IE11では未だにサポートされていない
    • まだ実装され始めたばかりでノウハウが少なく、パフォーマンス面で実用に耐えうるのか不安な面がある

【ワンポイント】HTTP/2とES modules

ブラウザ実装のES modulesを使用する場合は、コネクション数の問題からHTTP/2と併用することが大前提となります。また、たとえHTTP/2と併用できる状況であっても、サードパーティのライブラリを使用するとなると、./node_modules/のモジュールに対して大量、かつ深いネストの依存関係を持つことになります。Chrome’s Loading Performance with (Many) Modulesの資料によると、現状ではモジュールが数百におよぶ場合、またはネストの深度が5以上の場合は、バンドルすることが強く推奨されています。

webpack以外のモジュールシステムが解決できない問題

webpack以外のモジュールシステムについて、それぞれの特徴や役割、利点と欠点があることを確認しました。

ここで注目したいのは、これらの特徴を用途に合わせて使い分けることが「できない」という点です。モジュールシステムが1種類しか使えないということは、たとえば、通常は同期モジュール読み込みのシンプルな書式で記述したいが、ある特定の機能(ページ)ではモジュールの非同期読み込みをしたいといった状況に応じた使い分けができないという問題があります。

また、冒頭で触れましたがファイルのバンドルをどう扱うのかという問題があります。ファイルを分ければ、必要なものだけをブラウザが読み込めてよいという点もありますが、その一方で、リクエスト数が増えることは否めません。逆に、ファイルをまとめればリクエスト数は減りますが、無駄なコードを読み込んでしまったり、ファイル容量や実行にコストがかかります。

このような、あちらを立てればこちらが立たずな状況が、これまでの「バンドルする・しない」という選択にも、バンドルするツール側にも課題になっていました。

なるべく、状況に応じて必要なものだけを提供したい、かつファイル分割のしすぎは抑えるようにしたいわけです。

webpackが提供するハイブリッドなモジュールシステム

webpackは、これらの問題を解決するための機能が備わっています。モジュールの同期・非同期読み込みに対してのアプローチ、ファイルのバンドルに対してのアプローチをそれぞれ確認してみましょう。

同期・非同期import

webpackはバージョン1では当時まだES modulesの策定が不十分なこともあり、そのままではCommonJSのrequireしか使用できませんでしたが、バージョン2からES modulesのimportが使用できるようになりました。

下記のように、非常にシンプルで扱いやすい記述をすることができます。

// 同期import
import _ from 'lodash';

function component() {
  var element = document.createElement('div');
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
  return element;
}
document.body.appendChild(component());

同期的にだけでなく、RequireJSのような非同期的なモジュール読み込みの機能も備えています。次のように書くことでimportは実行時に指定されたモジュールを非同期に取得します。

// 非同期import
function getComponent() {
  return import('lodash').then(_ => {
   var element = document.createElement('div');
   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
   return element;
  }).catch(error => 'An error occurred while loading the component');
}

また、async/await*を使用して下記のように書くことも可能です。

// asyncを使用した非同期import
async function getComponent() {
  var element = document.createElement('div');
  const _ = await import('lodash');
  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
  return element;
}

*注:async/awaitについて

async/awaitについては、次のシリーズなども参照してください。

複数のモジュールを並列に読み込むことも可能です。

Promise.all([
    import('./module1.js'),
    import('./module2.js'),
    import('./module3.js'),
])
.then(([module1, module2, module3]) => {
    //···
});

コード分割(Code Splitting)

先ほどバンドルするツール側の課題について説明しました。理想はユーザーのアクセスするページで必要なコードのみがバンドルされ、読み込まれることでしょう。

webpackでは「コード分割(Code Splitting)」と呼ばれる機能によって、各ページで必要とされるコードとその依存関係にあるファイルだけをバンドルして提供する機能を備えています。コード分割によってバンドルされたファイルの固まりは「チャンク(chunk)」と呼ばれます。

webpackが開発された経緯を踏まえると、webpackにとって重要な機能の1つであると言えるでしょう。

また、各ページから依存される共通モジュールのコードが複数のチャンクで重複してしまうようなケースでは、その重複したコードを抜き出して新しいチャンクを作成するといった機能も備えています。

コード分割の具体的な方法はここでは説明しきれないため、次回の記事で実際にwebpackの設定・実行を踏まえながら説明します。

JavaScriptだけを対象にしないモジュールシステム

webpackでは提供するモジュールシステムが、JavaScriptしか対象にしない理由がないという考えのもと、下記の静的リソースもJavaScriptと同様にモジュールシステムを利用することが可能になっています。webpackを使用すると、これら静的リソースに対してもimportすることが可能になります。こちらも、次回以降に詳しく説明します。

  • スタイルシート
  • 画像
  • ウェブフォント
  • テンプレート用のHTML
  • その他

まとめ

今回は主にwebpackが開発された経緯やその他のモジュールシステムとの違い、そしてwebpackが提供する理想的なモジュールシステムのアプローチについて説明しました。次回は実際にwebpackを導入し、基本的な使い方について解説します。