はじめに
WebpackやBrowserifyなどのモジュールバンドラーは最近のWeb開発ではなくてはならない存在ですが、基本的には設定ファイルを書いてコマンドをたたくだけでバンドルができてしまうので、それらがどのようにモジュールの依存関係を解決しバンドルを作成しているのかという裏側の仕組みまで知る機会はあまりないかと思います。
私自身もVue.jsアプリを作っていたりしますが、Webpack周りはvue-cliにお任せしてしまっているので、Webpackについてはあまり詳しくありません。
しかし先日Webpackによるビルドがうまくいかないことがあり、何が原因なのか調べるのにバンドルのソースを確認しようとしたのですが、どこに何が書いてあるのかよくわからず非常に苦労しました。
そんな時にTwitterでminipackというリポジトリを見つけました。
https://github.com/ronami/minipack
minipackはモジュールバンドラーの仕組みを説明するために作られた簡易な実装です。
ソースコード1行ごとに説明のコメントが添えられているので、モジュールバンドラーがどのように依存関係を解決しバンドルを作成しているのかを順を追って理解することができます。
ただ、説明を読んでいるだけでは理解が深まらなかったので、中間データをデバッグしたり、ライブラリが使われているところを深掘りして調べたりしました。
本記事は、私なりに調べて理解した内容をまとめたものになります。
本記事の目的
本記事の目的は以下の通りです。
- モジュールバンドラーの仕組みを大まかに理解してもらうこと
- minipackの実装をより理解しやすくすること
- モジュールバンドラーの仕組みを日本語で説明した情報を提供すること
バンドル化するアプリ
hello world!
を出力するだけのものですが、ESModules形式で書かれていて、3つのファイルに分かれています。
import message from './message.js';
console.log(message);
import {name} from './name.js';
export default `hello ${name}!`;
export const name = 'world';
HTMLのscriptタグやNode.jsでバンドルを実行すると以下のように出力されます。
$ node bundle.js
hello world!
バンドル作成のプロセス
バンドルを作成する大まかな流れは以下の通りです。
- エントリポイントとなるモジュールのコードを解析し、どのモジュールに依存しているかを洗い出す
- その依存モジュールのコードを解析し、どのモジュールに依存しているかを洗い出す(これを再帰的に繰り返し、依存関係グラフを作成する)
- 依存関係グラフを受け取ってそれを実行する即時関数を出力する(この即時関数をバンドルと呼んでいる?)
ここからはminipack.jsの実装を例に各プロセスについて説明していきます。
依存モジュールの洗い出し
あるモジュールがどのモジュールに依存しているかを知るにはどうすれば良いでしょうか。
具体的には以下の流れで解析していきます。
- モジュールファイルを読み込む
- JavaScriptパーサでAST(Abstract Syntax Tree: 抽象構文木)を生成する
- ASTを走査(traverse)し、インポート宣言文を探す
minipack.jsではこの処理をcreateAsset()
関数として定義しています。
モジュールファイルを読み込む
これは説明するまでもないですね。
普通にfsモジュールを使ってファイルを読み込みます。
const content = fs.readFileSync(filename, 'utf-8');
JavaScriptパーサでAST(Abstract Syntax Tree: 抽象構文木)を生成する
WikipediaによればASTとは、
抽象構文木(英: abstract syntax tree、AST)とは、通常の構文木(具象構文木あるいは解析木とも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報のみを取り出した(抽象した)木構造のデータ構造である。
ということだそうです。
自分なりの理解で説明すると、あるコードについて、ここからここまでが文または式であり、その式はどんなことを意味していて、値として何を返すか、みたいなのを木構造で表したものです。
例えばentry.jsをパーサにかけてASTに変換すると、下図のようになります。
AST explorerというツールを使うと簡単にコードをASTに変換できます。
この黄色の部分から、0文字目から35文字目まではインポート宣言文(ImportDeclaration)になっていて、'./message.js'がインポート対象として指定さているということが読み取れます。
minipack.jsではJavaScriptパーサとしてbabel-parserを利用しています(balylonというのは旧名です)。
sourceType: 'module'
というのは、コードをESModules形式で書かれたものとして読み込むための指定です。
const ast = babylon.parse(content, {
sourceType: 'module',
});
ASTを走査(traverse)し、インポート宣言文を探す
AST上にインポート宣言文が見つかったら、それを依存モジュールとして記憶しておきます。
ASTのtraverseにはbabel-traverseを使用しています。
以下のコードでは、インポート宣言文(ImportDeclaration)が見つかるたびにdependenciesという配列に依存モジュールのファイルパスを格納しています。
traverse(ast, {
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value);
},
});
ES6+からES5へのトランスパイル
ここまでで依存モジュールの洗い出しについては完了していますが、モジュールのコードはESModules形式で書かれており、またブラウザではまだ実装されていないAPIを使用している場合、そのままのコードでは動かすことができません。
そこで、モジュールのコードをBabelでトランスパイルしています。
const {code} = transformFromAst(ast, null, {
presets: ['env'],
});
依存関係グラフの作成
モジュールAはモジュールBとモジュールCに依存していて、モジュールBはさらにモジュールDとモジュールEに依存していて...というように、アプリ全体のモジュールの依存関係を表す依存関係グラフを作成します。
アプリのエントリポイントから前述のモジュール洗い出し処理を行い、それを再帰的に繰り返していくことで依存関係グラフが作成できます。
minipack.jsではこの処理をcreateGraph()
関数として定義しています。
createGraph()
関数は最終的に以下のような配列を出力します。
次のバンドル作成のプロセスで必要となるのは、id
、code
、mapping
の3つです。
id
は各モジュールに割り振られたIDです。
code
はモジュールのコードで、BabelによってES5、CommonJS形式のコードに変換されています。
mapping
はそのモジュールの依存モジュールのリストで、ファイルパスとモジュールIDで構成されています。
[
{
"id": 0,
"filename": "./example/entry.js",
"dependencies": [
"./message.js"
],
"code": "\"use strict\";\n\nvar _message = require(\"./message.js\");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);",
"mapping": {
"./message.js": 1
}
},
{
"id": 1,
"filename": "example/message.js",
"dependencies": [
"./name.js"
],
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n\nvar _name = require(\"./name.js\");\n\nexports.default = \"hello \" + _name.name + \"!\";",
"mapping": {
"./name.js": 2
}
},
{
"id": 2,
"filename": "example/name.js",
"dependencies": [],
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nvar name = exports.name = 'world';",
"mapping": {}
}
]
バンドルの作成
モジュールの依存関係グラフが作成できたら、あとはそれを受け取ってブラウザ上で実行する関数を作成します。
この関数をバンドルと呼んでいるようです。
minipack.jsが最終的に出力するバンドルを見てみましょう。
※見やすいように改行したりインデントを揃えたりしてあります。
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = {exports: {}};
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({
0: [
function (require, module, exports) {
'use strict';
var _message = require('./message.js');
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log(_message2.default);
},
{'./message.js':1},
],
1: [
function (require, module, exports) {
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true
});
var _name = require('./name.js');
exports.default = 'hello ' + _name.name + '!';
},
{'./name.js':2},
],
2: [
function (require, module, exports) {
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true
});
var name = exports.name = 'world';
},
{},
],
})
この関数は以下のような構造になっています。
// 全体が即時関数になっている
(function (modules) {
function require(id) {} // CommonJS形式のモジュールを実行する
require(0) // エントリポイントのモジュールを実行する
})({/*即時関数にモジュールコードと依存関係情報で構成されたオブジェクトを渡す*/})
即時関数に渡すオブジェクト
これはcreateGraph()
で作成した依存関係グラフ配列をオブジェクトに変換しただけのものです。
require関数
モジュールIDを受け取り、依存関係グラフオブジェクトからモジュールを取り出して実行します。
モジュールコードがブラウザ上ではそのままでは実行できないCommonJS形式になっているので、そこで使用するrequire
、module
、exports
といった関数やオブジェクトを定義してモジュールに渡しています。
モジュールコード内で使用するrequire関数(localRequire)は単純にモジュールを読み込んで実行するだけのシンプルな実装になっています。
実践的な実装であれば、インポート済みモジュールのキャッシュ、循環参照といったものへの対応が必要になりますが、ここでは省略されています。
Webpackで作ったバンドルと比較してみる
さて、ここまでminipackの実装を見てきましたが、同じアプリに対してWebpackでバンドルを作成したらどうなるでしょうか。
以下がWebpackで作成したバンドルです。
(function(modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
});
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);
module.l = true;
return module.exports;
}
__webpack_require__.m = modules;
__webpack_require__.c = installedModules;
__webpack_require__.d = function(exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter });
}
};
__webpack_require__.r = function(exports) {
if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
}
Object.defineProperty(exports, "__esModule", { value: true });
};
__webpack_require__.t = function(value, mode) {
if (mode & 1) value = __webpack_require__(value);
if (mode & 8) return value;
if (mode & 4 && typeof value === "object" && value && value.__esModule)
return value;
var ns = Object.create(null);
__webpack_require__.r(ns);
Object.defineProperty(ns, "default", { enumerable: true, value: value });
if (mode & 2 && typeof value != "string")
for (var key in value)
__webpack_require__.d(
ns,
key,
function(key) {
return value[key];
}.bind(null, key)
);
return ns;
};
__webpack_require__.n = function(module) {
var getter =
module && module.__esModule
? function getDefault() {
return module["default"];
}
: function getModuleExports() {
return module;
};
__webpack_require__.d(getter, "a", getter);
return getter;
};
__webpack_require__.o = function(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
};
__webpack_require__.p = "";
return __webpack_require__((__webpack_require__.s = "./entry.js"));
})({
"./entry.js": function(module, exports, __webpack_require__) {
"use strict";
eval(
'\n\nvar _message = __webpack_require__(/*! ./message.js */ "./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);\n\n//# sourceURL=webpack:///./entry.js?'
);
},
"./message.js": function(module, exports, __webpack_require__) {
"use strict";
eval(
"\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n\nvar _name = __webpack_require__(/*! ./name.js */ \"./name.js\");\n\nexports.default = 'hello ' + _name.name + '!';\n\n//# sourceURL=webpack:///./message.js?"
);
},
"./name.js": function(module, exports, __webpack_require__) {
"use strict";
eval(
"\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nvar name = exports.name = 'world';\n\n//# sourceURL=webpack:///./name.js?"
);
}
});
ぱっと見複雑ですが、構造だけ見るとminipack.jsで作成したものと基本的には同じのようです。
// 全体が即時関数になっている
(function(modules) {
function __webpack_require__(moduleId) {} // モジュールを実行する
... // __webpack_require__のプロパティに関数などを追加している
... // 今回はアプリがシンプルすぎるので、これらがどう使われるものなのかまではわからなかった
return __webpack_require__((__webpack_require__.s = "./entry.js")) // エントリポイントのモジュールを実行する
})({/*モジュールのパスとモジュールコードで構成されたオブジェクトを即時関数に渡す*/})
即時関数に渡すオブジェクト
モジュールのファイルパスとそのコードで構成されたオブジェクトになっています。
モジュールコードはCommonJS形式に変換されているようですが、requireの部分が__webpack_require__となっています。
__webpack_require__関数
モジュールIDを受け取り、即時関数に渡されたオブジェクトからモジュールを取り出して実行します。
minipackと同様に、CommonJS形式のモジュールコードが実行できるように、require
、module
、exports
といった関数やオブジェクトをモジュールコードに渡しています。
まとめ
バンドルが作られる仕組みを理解していると、バンドルされることを意識したコードの書き方ができるようになります。
例えば、依存モジュールのインポート文で変数が使われているとビルドがうまくいかないことがありますが、JavaScriptパーサがコードをパースしてインポート文を見つける仕組みを知っていると、インポート文はstaticに指定しなければならないことが理解できます。
また、作成されたバンドルの構造を理解していれば、デバッグもしやすくなります。
今回題材として使用したアプリは非常にシンプルであり、現場でビルドされるバンドルは比べ物にならないくらい複雑になるはずです。
しかし、モジュールバンドラーの基本的な仕組みを理解していることは、現場でも役に立つはずです。
本記事が少しでもみなさんの参考になれば嬉しいです。
Appendix
Webpackでビルドする際に使用したコンフィグを載せておきます。
ビルドには以下のモジュールが必要です。
- webpack
- webpack-cli
- uglifyjs-webpack-plugin
- babel-loader
- babel-preset-env
const path = require('path')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
module.exports = {
context: path.join(__dirname, '/example'),
entry: './entry',
output: {
path: path.join(__dirname, '/dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['env']
}
}
}
],
},
plugins: [
new UglifyJsPlugin({
uglifyOptions: {
compress: false,
mangle: false,
output: {
comments: false,
beautify: false,
preserve_line: true
}
}
}),
],
mode: 'development'
}