webpackの仕組みを簡潔に説明する
💁♀️ ブログが移管されたので,新しい方へ移動します。
この記事は、Node.js Advent Calendar 2018の18日目の記事です。
遅れてしまい本当に申し訳ありません。
この記事は、HTML5カンファレンスで話した内容が中心となります。
Node.jsとはかけ離れていますが、自分が書きたかった内容だったので、理解してくださると嬉しいです。
- 💁♀️ ブログが移管されたので,新しい方へ移動します。
- モジュール
- Hot Module Replacement (v1)
- Tapable (v1)
- Tree Shaking & Dead Code Elimination (v2)
- Scope Hoisting (v3)
- SplitChunksPlugin (v4)
モジュール
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
のデフォルト
- ESM のみをサポートし、
- javascript/dynamic
- CJS と AMD のみをサポート
- json
- son をサポート
- webassembly/experimental
- WebAssembly モジュールのサポート
もし、指定したい場合は、以下のように書きます。
{ test: /\.mjs$/, include: /node_modules/, type: 'javascript/auto' }
また、現在進行中の作業として、css
, url
, html
があります。
実行の仕組み
webpackでは、__webpack_require__
という関数を用いて、依存の走査を行います。
少し語弊がありますが、図は以下のような感じになります。
長いので部分的に省略していますが、コードにすると以下のような感じです。
(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で配信された新しいアセットを取得しにいきます。
ここのwebsocketでは、次にRuntimeが取りに行くhashIDを送ります。
このhashIDをファイル名につけて、webpack-dev-serverへfetchを行います。
配信されるJavaScriptとjsonの中身は以下のようになります。
// "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を持ち、多彩に様々なことを表現することができます。
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でしょう。
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を使っています。
この構造の問題点があります。
lazy
,c
,d
,cjs
はexampleと別チャンクにする必要があるshared
は2つの異なるスコープから参照されるcjs
はCommonJS moduleである
これらを単純にチャンク分解すると以下のようになります。
しかし、ESM同士は静的解析するためビルド時にモジュール解決を行うことができます。
なので、さらにチャンク内でESMの同レイヤーのスコープ同士をくっつけることが可能です。
つまり、上記のように同一スコープをまとめることが可能です。
example
+a
+b
shared
+shared2
- この2種類は、
lazy
にも使われるため、example
と一緒にはできない
- この2種類は、
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内で行われているため、無駄な走査を省くことができます。
SplitChunksPlugin (v4)
CommonsChunkPlugin
が廃止され、新しく追加されたプラグインです。
廃止された理由は以下のようになります。
- 表現力が低く、非同期チャンクにそのときに不必要な無駄なものが入り、必要以上のダウンロードが発生する可能性がある
- 制御構文が難しい(e.g.
minChunks
)
例えば、node_modules
をbundle.jsとして生成するがそのページで必要なものは実際その全てではないということが今までで経験したことと思います。
SplitChunksPluginでは、モジュールの重複回数とモジュールのカテゴリー(e.g. node_modules)により、 自動的にチャンクとして分割するべきモジュールを識別し、分割します。
以下の点がCommonsChunkPlugin
との大きな違いです。
- 不要なモジュールをダウンロードしないため、非同期チャンクでも効率的
- 扱いが簡単で自動的
- チャンクグラフを弄らなくて良い
以下の例を見てもらうとわかりやすいです。
左側が生成されたチャンクで、右側がSplitChunksPluginを実行した結果です。
各チャンクすべての共通箇所をまとめて、最小単位の共通チャンクに再分解しているのがわかります。
デフォルトでは、ファイル名は~
でつながり、中身で使われているコードの元ファイル名が連結されます。
このように分けることにより、必要なときに必要なファイルをダウンロードすることができます。
これは、パフォーマンスチューニングにおいて大切な要素です。
また、まとめられた各チャンクの最大・最小ファイルサイズも指定することができるようになりました。
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も出ているので、そちらも楽しんでみてください!
もしなにかありましたら、twitterまでどうぞ!