技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

webpackの仕組みを簡潔に説明する

この記事は、Node.js Advent Calendar 2018の18日目の記事です。

遅れてしまい本当に申し訳ありません。

この記事は、HTML5カンファレンスで話した内容が中心となります。
Node.jsとはかけ離れていますが、自分が書きたかった内容だったので、理解してくださると嬉しいです。

events.html5j.org

モジュール

webpackは以下のモジュールをサポートします。

// ESM (ECMAScript Modules)
import foo from './foo';
export default foo;
import('./foo.wasm'); // native support for WebAssembly
import('./foo.json'); // native support for JSON

// CJS (CommonJS Modules)
const foo = require('./foo');
module.exports = foo;

// AMD (Asynchronous Module Definition)
define(['./foo'], (foo) => foo);
@import url('foo.css');
<img src="./foo.png">

モジュールタイプ

webpackは以下のモジュールタイプをサポートします。

モジュールタイプは自動的にmjs, json, wasmに対し選択されます。
他の拡張子は、それにあったloaderが必要となります。

  • javascript/auto
    • CJS、AMD、ESM のすべてをサポート
  • javascript/esm
    • ESM のみをサポートし、.mjsのデフォルト
  • javascript/dynamic
    • CJS と AMD のみをサポート
  • json
    • son をサポート
  • webassembly/experimental
    • WebAssembly モジュールのサポート

もし、指定したい場合は、以下のように書きます。

{
  test: /\.mjs$/,
  include: /node_modules/,
  type: 'javascript/auto'
}

また、現在進行中の作業として、css, url, htmlがあります。

[WIP] feat(css): add CSS module support (`css`) by michael-ciniawsky · Pull Request #6448 · webpack/webpack · GitHub

feat(url): add asset module support (`url`) by michael-ciniawsky · Pull Request #6446 · webpack/webpack · GitHub

[WIP] feat(html): add HTML module support (`html`) by michael-ciniawsky · Pull Request #6447 · webpack/webpack · GitHub

実行の仕組み

webpackでは、__webpack_require__という関数を用いて、依存の走査を行います。

少し語弊がありますが、図は以下のような感じになります。

f:id:about_hiroppy:20181216154323p:plain

長いので部分的に省略していますが、コードにすると以下のような感じです。

(function(modules) {
  var installedModules = {}; // すでに読み込んだモジュールのキャッシュ

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) return installedModules[moduleId].exports;

    var module = (installedModules[moduleId] = { i: moduleId, l: false, exports: {} });

    // module.exportsをbindし、function(module, exports, __webpack_require__) を実行する
    // moduleのexportsにそのファイルからexportsされた実行結果が入る
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true; // 読み込み済みフラグ

    return module.exports;
  }
  return __webpack_require__((__webpack_require__.s = 0)); // entry pointを実行(初回キック)
})({
  0: function(module, exports, __webpack_require__) {
    // 実行コード内の require が __webpack_require__ へ置換される
    // そして、このスコープのために作られた関数の引数にある __webpack_require__ を実行する
    eval(
      'module.exports = __webpack_require__(/*! ./index.js */"./index.js");\n\n\n//# sourceURL=webpack:///multi_./index.js?'
    );
  }
});

実行は、IIFE(即時関数)で行われ、引数が各ファイルとなります。
webpackはentryが文字列、オブジェクト、配列で受け取れるため引数も文字列、オブジェクト(productionではオブジェクトではなく配列へと変わります)、配列で渡されます。

また__webpack__require__は関数ですが、様々なプロパティも保持します。

webpack/MainTemplate.js at 9fe42e7c4027d0a74addaa3352973f6bb6d20689 · webpack/webpack · GitHub

moduleという変数を経由し、__webpack__require__での結果のexportなどが渡ります。

Hot Module Replacement (v1)

ソースコードが変更されるとブラウザをリロードせずに自動的に変更されたモジュールを新しいモジュールへ置換する機能です。

公式では、以下のライブラリがサポートをしています。

  • webpack-dev-server
  • webpack-hot-middleware
  • webpack-hot-client

webpackがファイル変更を監視し、変更があればコンパイルしたjsとjsonを生成し、webpack-dev-serverで配信します。

以下のような仕組みになっていて、websocketで会話しつつ、webpack pluginで仕込んだRuntimeと呼ばれる部分でwebpack-dev-serverで配信された新しいアセットを取得しにいきます。

f:id:about_hiroppy:20181225210420p:plain

ここのwebsocketでは、次にRuntimeが取りに行くhashIDを送ります。
このhashIDをファイル名につけて、webpack-dev-serverへfetchを行います。

f:id:about_hiroppy:20181225210908p:plain

配信されるJavaScriptjsonの中身は以下のようになります。

// "output.hotUpdateChunkFilename": "[id].[hash].hot-update.js"

webpackHotUpdate("bundle",{
/***/ "./a.js":
/*!**************!*\
  !*** ./a.js ***!
  \**************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {
  eval(...);
/***/ })
})
// "output.hotUpdateMainFilename": "[hash].hot-update.json"

{
  "h": "5946277f0fe1b6e0144e",
  "c": { "bundle": true }
}

Tapable (v1)

webpackには、tapableというプラグインシステムを持ちます。
webpackのプラグインを書いたことがある人は、触ったことがあるかもしれません。

例えば、下の例はwebpackがクライアントコードのテンプレートを生成するコードです。
renderというのがコール(render.call())されると以下のMainTemplateと呼ばれるタスクが実行される仕組みです。

// https://github.com/webpack/webpack/blob/master/lib/MainTemplate.js

this.hooks.render.tap(
  'MainTemplate',
  (bootstrapSource, chunk, hash, moduleTemplate, dependencyTemplates) => {
    const source = new ConcatSource();
    source.add('/******/ (function(modules) { // webpackBootstrap\n');
    ...
    return source;
  }
);

また、webpackには以下の多くのhooksを持ち、多彩に様々なことを表現することができます。

Compiler Hooks

Tree Shaking & Dead Code Elimination (v2)

Tree Shakingは別の言い方で、Unused Exports Eliminationとも呼ばれます。

  • Tree Shaking
    • ESM を使うことにより、未使用のモジュールを検知しバンドル時に分解する
  • Dead Code Elimination
    • 実行に影響しない未使用のコードを発見しそれを削除する
    • webpack の場合は、uglifyJS(or terser) が使われる

上記のように、実際はTree Shakingではコードが消されないのを注意してください。

歴史

案外、Tree Shakingという名前は昔からあり、1990年代のLISPにさかのぼります。

https://groups.google.com/forum/#!msg/comp.lang.lisp/6zpZsWFFW18/-z_8hHRAIf4J

また、2012年や2013年になると、Google Closure Toolsやdart2jsでも実装されました。

おそらく、多くの人がこの単語を知ったのは、2015年のRollupでしょう。

medium.com

Tree Shaking

// index.js (entry point)
import a from './a';

console.log(a);

// a.js
import { b1 } from './b';

const a = `${b1} from b`; // 使われる

export default a;

export const test = () => 2 * 2; // 使われない

// b.js
export const b1 = 'b1'; // 使われる
export const b2 = 'b2'; // 使われない
const b3 = 'b3'; // ローカル変数
(function(module, __webpack_exports__, __webpack_require__) { // index.js (entry point)
  __webpack_require__.r(__webpack_exports__);
  /* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./a */ './a.js');

  console.log(_a__WEBPACK_IMPORTED_MODULE_0__[/* default */ 'a']);
});

(function(module, __webpack_exports__, __webpack_require__) { // a.js
  /* unused harmony export test */
  /* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b */ './b.js');

  const a = `${_b__WEBPACK_IMPORTED_MODULE_0__[/* b1 */ 'a']} from b`; // b.jsのb1を参照する
  /* harmony default export */ __webpack_exports__['a'] = a; // index.jsのexportsへ'a'キーとして結果を渡す
  const test = () => 2 * 2;
});

(function(module, __webpack_exports__, __webpack_require__) { // b.js
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, 'a', function() {
    return b1;
  });
  /* unused harmony export b2 */
  const b1 = 'b1'; // a.jsによって使われている変数
  const b2 = 'b2'; // b2はexportしているが、未使用な変数
  const b3 = 'b3'; // b3はexportしていない変数
});

/* unused harmony export xxxx */というコメントがついていれば成功です。 これはその文の通り、exportされているが使ってないことを意味します。

  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, 'a', function() {
    return b1;
  });

そして、b.jsを見るとわかりますが、使われる変数だけが__webpack_require__.dを経由して登録されます。
b2はexportしてますが、a.jsで使われないため、bindingされません。

つまり、このファイルからはこの変数を使っているということをここで表明します。

このようにTerserのようなコードを削除するツールにその変数が使われるかどうかを知らせるコードに変形させるのが、tree shakingです。

Dead Code Elimination

上記のtree shakingされたコードをTerserへかけると以下のようになります。
b.jsを見ると、b2, b3という変数がなくなって、a.jsで使われるb1だけが残りました。

function(e,t,n){ // index.js (entry point)
  "use strict";
  n.r(t);
  var r=n(/*! ./a */"./a.js");
  console.log(r.a)
}

function(e,t,n){ // a.js
  "use strict"; // testという関数がなくなった
  const r=`${n(/*! ./b */"./b.js").a} from b`;
  t.a=r
}

function(e,t,n){ // b.js
  "use strict";
  n.d(t,"a",function(){return r});
  const r="b1" // b2, b3がなくなった
}

これで不要なコードが削除されたことが確認できました!

Scope Hoisting (v3)

別名、Module Concatenationとも呼ばれます。

ESM を使うことによりインポートチェーンをフラット化し、1 つのインライン関数に変換できる場所を検出します。
つまり、バンドル時に事前に同一階層のスコープを解決する仕組みです。
これにより余分な関数呼び出しを減らし、実行時間・コード量を減らすことを期待できます。

以下の難しいグラフを見てみます。
特に何も書かれていないものはESMを使っています。

f:id:about_hiroppy:20181225215224p:plain

この構造の問題点があります。

  • lazy, c, d, cjs はexampleと別チャンクにする必要がある
  • sharedは2つの異なるスコープから参照される
  • cjsはCommonJS moduleである

これらを単純にチャンク分解すると以下のようになります。

f:id:about_hiroppy:20181225215144p:plain

しかし、ESM同士は静的解析するためビルド時にモジュール解決を行うことができます。
なので、さらにチャンク内でESMの同レイヤーのスコープ同士をくっつけることが可能です。

f:id:about_hiroppy:20181225215407p:plain

つまり、上記のように同一スコープをまとめることが可能です。

  • example + a + b
  • shared + shared2
    • この2種類は、lazyにも使われるため、exampleと一緒にはできない
  • lazy + c + d
  • cjs
    • CJSは静的解析ではない

このようにグループ化することが可能となります。
そして、グループ化したときは、モジュール解決を事前に行うことができ、無駄なコールを減らします。

コード例は以下のとおりです。

この例では、以下が同一スコープとなります。

  • index.js + a.js
  • shared.js + shared2.js
  • lazy.js
// index.js
import a from './a';

(async () => {
  const { default: res } = await import(/* webpackChunkName: 'lazy' */ './lazy');
})();

// a.js
import shared from './shared';      //      +----------+         +----------+
                                    //      |  index   +--------->   lazy   |
const a = `${shared}: a`;           //      +----+-----+         +-----+----+
export default a;                   //           |                     |
                                    //           |                     |
// lazy.js                          //      +----v-----+               |
import shared from './shared';      //      |    a     |               |
                                    //      +----+-----+               |
const res = `${shared}: lazy`;      //           |                     |
export default res;                 //           |                     |
                                    //      +----v-----+               |
// shared.js                        //      |  shared  <---------------+
import shared2 from './shared2';    //      +----+-----+
                                    //           |
export default 'shared';            //           |
                                    //      +----v-----+
// shared2.js                       //      | shared2  |
export default 'shared2';           //      +----------+

もし、Scope Hoistingを有効にしなかった場合、以下のように通常展開されます。

{ // dist/main.js
/***/ "./a.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ "./index.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ }),
/***/ "./shared.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ }),
/***/ "./shared2.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ })
/******/ };

// lazy.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["lazy"],{
/***/ "./lazy.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ })
}]);

このように全ファイルが列挙された状態でのバンドルとなります。
しかし、index.js + a.js, shared + shared2 は同一スコープであるため、そのスコープ内のモジュール解決の処理は無駄となります。

以下が有効にした場合の例です。
同一スコープになっていることがわかります。

{
/***/ "./index.js":   // index.js + a.js
/*!******************************!*\
  !*** ./index.js + 1 modules ***!
  \******************************/
/*! no exports provided */
/*! all exports used */
/*! ModuleConcatenation bailout: Cannot concat with ./shared.js because of ./lazy.js */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ }),

/***/ "./shared.js": // shared.js + shared2.js
/*!*******************************!*\
  !*** ./shared.js + 1 modules ***!
  \*******************************/
/*! exports provided: default */
/*! exports used: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
  // CONCATENATED MODULE: ./shared2.js
  /* harmony default export */
  var shared2 = ('shared2'); // すでにここでモジュール解決を行っている
  // CONCATENATED MODULE: ./shared.js
  /* harmony default export */ var shared = __webpack_exports__["a"] = ('shared');
  //# sourceURL=webpack:///./shared.js_+_1_modules?");
/***/ })
/******/ };


// lazy.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["lazy"],{
/***/ "./lazy.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ })
}]);

ソースコード内のコメントに、!*** ./index.js + 1 modules ***!とあります。
これが、Scope Hoistingでバンドル時に同一スコープを先に解決し、同じファイルに結果がまとめられたということがわかります。
この時すでに__webpack__require__を経由せずに値の解決をバンドルされたJS内で行われているため、無駄な走査を省くことができます。

github.com

SplitChunksPlugin (v4)

CommonsChunkPluginが廃止され、新しく追加されたプラグインです。

廃止された理由は以下のようになります。

  • 表現力が低く、非同期チャンクにそのときに不必要な無駄なものが入り、必要以上のダウンロードが発生する可能性がある
  • 制御構文が難しい(e.g. minChunks)

例えば、node_modulesをbundle.jsとして生成するがそのページで必要なものは実際その全てではないということが今までで経験したことと思います。

SplitChunksPluginでは、モジュールの重複回数とモジュールのカテゴリー(e.g. node_modules)により、 自動的にチャンクとして分割するべきモジュールを識別し、分割します。

以下の点がCommonsChunkPluginとの大きな違いです。

  • 不要なモジュールをダウンロードしないため、非同期チャンクでも効率的
  • 扱いが簡単で自動的
  • チャンクグラフを弄らなくて良い

以下の例を見てもらうとわかりやすいです。
左側が生成されたチャンクで、右側がSplitChunksPluginを実行した結果です。

f:id:about_hiroppy:20181225220618p:plain

各チャンクすべての共通箇所をまとめて、最小単位の共通チャンクに再分解しているのがわかります。
デフォルトでは、ファイル名は~でつながり、中身で使われているコードの元ファイル名が連結されます。
このように分けることにより、必要なときに必要なファイルをダウンロードすることができます。
これは、パフォーマンスチューニングにおいて大切な要素です。

また、まとめられた各チャンクの最大・最小ファイルサイズも指定することができるようになりました。

module.exports = {
  splitChunks: {
    minSize: 100000,  // bytes
    maxSize: 1000000, // bytes
    cacheGroups: {
      vendor: {
        test: /node_modules/,
        name: 'vendor',
        chunks: 'initial',
        enforce: true
      }
    }
  }
};
vendor.e01916c600d5e12dd9aa.16.bundle.js   1.41 MiB

↓ 

vendor~253ae210.e46c3fe01b7780f11d81.bundle.js    316 KiB
vendor~7274e1de.a2d5e8d87c5e36752b28.bundle.js    183 KiB
vendor~7d359b94.79f7863fa304fe20067e.bundle.js   53.5 KiB
vendor~9c5b28f6.71223a4ff0625388be27.bundle.js    610 KiB
vendor~b5906859.2b626aa82671c8667e3a.bundle.js   95.2 KiB
vendor~db300d2f.d22d5b79be58987d729e.bundle.js   92.9 KiB
vendor~ec8c427e.59a4800bc2621be8d855.bundle.js     95 KiB

このように分けれる範囲で分解をすることも可能です。


webpackでは、まだまだ様々なアルゴリズムが存在します。 また、今回の説明で出したものは、v4のproductionモードを有効にすると最適化はすべて行えます。

現在、v5.0.0-alpha.1も出ているので、そちらも楽しんでみてください!

github.com

もしなにかありましたら、twitterまでどうぞ!