module bundlerの作り方(ECMAScript Modules編)
2020 / 06 / 01
Edit前回の準備編では、module bundler がどのように動いているかを説明しました。
今回は、dynamic import 以外の最低限の実装を入れていきます。
リポジトリ
変更されたコード一覧はこちら
ECMAScript Modules(ESM)について
さて、多くの人がすでに使っている以下のような構文が ESM と呼ばれるものです。
import { version } from "module";
export const a = 1;
仕様等のドキュメント
tc39: https://tc39.es/ecma262/#sec-modules
whatwg: https://whatwg.github.io/loader
https://exploringjs.com/impatient-js/ch_modules.html
また、Node.js での ESM の解決方法は CJS との互換性を保つために別途異なるのでここでは扱いません。
webpack では現在、Node.js の scope-package の対応中なので、もう少しお待ち下さい。
ECMAScript Modules と CommonJS Modules の違い
ESM の特徴は、事前に静的解析を行います。 そうすると以下のようなメリットが得られます。
- ランタイムでシンタックスエラーが発生することを避けれる
- 不必要なコードを消す(dead code elimination)ことが容易に行える
- CJS の tree shaking 対応は webpack で現在進行中
ESM はトップレベルで宣言しないといけないのはそのような理由があるからです。
また、CJS は同期ですが、dynamic import は非同期です。(require.ensure
除く)
出力されるランタイムコード
先に完成したコードを見ていきます。
// entry.js
import { add as addNumber } from "./module1.js";
console.log(addNumber(10));
// module1.js
export function add(n) {
return 10 + n;
}
上記のコードは以下のような出力になります。
((modules) => {
const usedModules = {};
function require(moduleId) {
if (usedModules[moduleId]) {
return usedModules[moduleId].exports;
}
const module = (usedModules[moduleId] = {
exports: {},
});
modules[moduleId](module, module.exports, require);
return module.exports;
}
require.__defineExports = (exports, exporters) => {
Object.entries(exporters).forEach(([key, value]) => {
Object.defineProperty(exports, key, {
enumerable: true,
get: value,
});
});
};
return require(0);
})({
0: function (module, exports, require) {
const __BUNDLER__1 = require(1);
console.log(__BUNDLER__1["add"](10));
},
1: function (module, exports, require) {
function add(n) {
return 10 + n;
}
require.__defineExports(exports, {
add: () => add,
});
},
});
今回は CJS との互換をあまり考えないため、__esModule
をexports
にアサインはしません。
ほかのパターンを見る場合はこちら
CJS と ESM の出力の違い
ESM も最終的には CJS に合わせた状態(require
)になりますが、大きな違いが 2 点あります。
ESM ではランタイムコード生成時に既に実行先が確定される
先程、話したとおり ESM では静的解析を行うことが前提となるため実行前にすべて確定されます。 このコードを CJS で書くと以下のような出力になります。
// CJS
({
0: function (module, exports, require) {
const { add: addNumber } = require(1);
console.log(addNumber(10));
},
1: function (module, exports, require) {
function add(n) {
return 10 + n;
}
module.exports = {
add,
};
},
});
CJS の場合、1
が0
に呼び出されてその時にusedModules[1].exports
にadd
が登録され、0
で使われます。 しかし、0
からすると本当に1
にadd
があるのかわからないため、実行時に落ちる可能性があります。
ESM は事前に実行するものを予約したコードに変換することによりこの問題を防ぎます。
// ESM
({
0: function (module, exports, require) {
const __BUNDLER__1 = require(1);
console.log(__BUNDLER__1["add"](10));
},
1: function (module, exports, require) {
function add(n) {
return 10 + n;
}
require.__defineExports(exports, {
add: () => add,
});
},
});
__BUNDLER__1['add'](10)
のように必ず実行できる形でコードが吐かれます。
もう少ししっかり書くなら、 (0, __BUNDLER__1['add'])(10);
と変換したほうが良いです。 こうしないとthis
のスコープを担保できないからです。更に、.add
に変えるとコード量も減ります。(たしか webpack@3 でそっちに移行した記憶)
つまり、呼び出す側が既に呼び出される側の内部を把握している状態となり実行時にエラーを起こすのを防げるということです。
属性の付与が必要となる
新しくrequire
に__defineExports
を追加しました。
これは、exports されるものに必ずenumerable
を付与しなければいけないためです。
IIFE 内で共通で使うために以下を定義します。
require.__defineExports = (exports, exporters) => {
Object.entries(exporters).forEach(([key, value]) => {
Object.defineProperty(exports, key, {
enumerable: true,
get: value,
});
});
};
そして、モジュールが初めて呼び出された時に以下のrequire.__defineExports
を実行し、usedModules[1].exports
にObject.defineProperty
経由でプロパティを追加します。
({
1: function (module, exports, require) {
function add(n) {
return 10 + n;
}
require.__defineExports(exports, {
add: () => add, // このオブジェクトにこのファイル内のexportされるものが追加されている
});
},
});
コードを書き換える
AST を弄りたい人は astexplorer を使うと便利です。
CJS ではコードの書き換えはrequire('module')
->require(1)
のように moduleId のみの書き換えでしたが ESM では呼び出し元等を編集する必要があります。
ESM も CJS とモジュールのマップ作成の処理は共通なので、コードの走査処理だけが ESM 時に新しく追加されます。
- CJS: modules のリストを作る ->
require
の中身を moduleId に変更する - ESM: modules のリストを作る -> ソースコードをトラバースする ->
require
の中身を moduleId に変更する- 二段階目で
require
に変換し、CJS と同じ形になるため三段階目は CJS と共通で動く
- 二段階目で
それでは、babel を使い AST を走査し以下のことを達成していきます。
import
をrequire
へ変換- (e.g.
import a from 'module'
->const a = require('module')
)
- (e.g.
- 外部モジュールから使っている変数、関数等をすべて置換する
- (e.g.
a(1);
->__BUNDLER__1["a"](1)
)
- (e.g.
export
をすべてマッピングし、不必要なシンタックスを消す- (e.g.
export const a = 1
->const a = 1
)
- (e.g.
import を変更する
親はImportDeclaration
となり、type はImportDefaultSpecifier
, ImportNamespaceSpecifier
, ImportSpecifier
となります。
// ImportDefaultSpecifier
import a from "module";
// ImportNamespaceSpecifier
import { a } from "module";
import * as a from "module";
// ImportSpecifier
import { a as b } from "module";
import { default as a } from "module"; // CJSとESMの互換ブリッジ
ゴールはrequire
に変更し、格納先の変数名に Id 込の名前を付与することです。
最初にモジュールのマッピングをし Id を発行しているのでそれを変数名へ紐付けていきます。
import { a } from 'module'
-> const __BUNDLE__1 = require('module')
import
は、どの type でもconst a = require('b')
となるためすべて共通化できます。
export
を変更する
export
はいろいろなケース(e.g. アグリゲート)があるので、最小限の実装にしています。
親はExportDeclaration
となり、type はExportDefaultDeclaration
とExportNamedDeclaration
となります。
// ExportDefaultDeclaration
// FunctionDeclaration
// export default function a() {}
// Identifier
// const a = 1;
// export default a;
// ArrowFunctionExpression 本当はclassも追加しないとダメ
// export default () => {}
// ExportNamedDeclaration
// export const a = 1;
// 未実装
// export { a, b };
// export a from 'module';
ゴールは、export されるもののマッピングで、名前と接続先を把握し以下のように展開します。
function add(n) {
return 10 + n;
}
require.__defineExports(exports, {
//名前 接続先
add: () => add,
});
名前を付与する
最初に export されるものの名前を取得しなければなりませんが、状況によって取り方が異なります。
// export default function a()
node.declaration.name;
// function a() {}
// export default a;
node.declaration.id && node.declaration.id.name;
// export const a = 1
node.declaration.declarations && node.declaration.declarations[0].id.name;
これで名前が取れないときは、export default () => {}
やクラスの可能性を考慮します。
この場合は、名前をこちらが付けてあげて接続先を作ります。(e.g. key: default
, name: __default__
)
export default () => {};
-> const __default = () => {};
と書き換えて名前を付けます。
不要なコードを消す
Object.defineProperty(exports, key)
で値を展開していくため、コード内に不要なコードがあります。
export default function a() {}
->function a() {}
export default a
-> この行事自体が不要export const a = 1
->const a = 1
export
に付随したコードはランタイムでは必要なくなります。
import されたものをコード内で紐付け置換する
コード内の import したものを使っている箇所をすべて書き換えます。
ReferencedIdentifier
に入ってきて、かつ親の type がImportDeclaration
がターゲットです。
ゴールは、import されたモジュールが呼び出されている場所を__BUNDLER__{id}[function/variable name]
に変えていくことです。
// before
import { foo as bar } from "module";
console.log(bar(10));
// after
const __BUNDLER__1 = require("module");
console.log(__BUNDLER_1["foo"](10));
最初に、scope hoisting を考えないといけないため以下の処理を行いスコープを固定します。 これを行うことにより import されたものとコード内で使われているものが正しく紐付けられます。
const localName = path.node.name;
const localBinding = path.scope.getBinding(localName);
const { parent } = localBinding.path; // 親を確定させる
import
は複数連続({a, b, ...}
)して入っているのでループし、確認していきます。
parent.specifiers.forEach(({ type, local, imported }) => {});
このループの中でそれぞれの export されたものとコード内で使用されるものを置換してきます。
それのlocal
はコード内の情報を示し、imported
はコード外の情報を意味します。
つまり、a as b
の場合、imported
as local
となりimported
は元の情報を辿る上で重要な役割を果たします。
ImportNamespaceSpecifier: import * as foo from 'module'
一番めんどくさいです。
というのも、default
を省略できるかどうかはプラットフォームに依存しているため互換性を考えないといけないからです。(Node.js, Babel, typescript, etc..)
例えば、出力先がmodule.exports = require('./foo')
とかが代表的な例です。
ここでの最終的な展開式は以下のようになります。
const __BUNDLER_1 = require(1);
console.log(__BUNDLER_1["default"]); // import先のdefault
console.log(__BUNDLER_1["func"]()); // import先のnamed exportされたfunc
console.log(__BUNDLER_1);
/**
* default: xxxx
* func: yyyy
*/
しかし、これは互換性を崩しているので実際はよくなくて、以下のほうが安全です。
const __BUNDLER_1 = require(1);
console.log(__BUNDLER_1["default"]["default"]); // import先のdefault
console.log(__BUNDLER_1["default"]["func"]()); // import先のnamed exportされたfunc
console.log(__BUNDLER_1);
/**
* default: {
* default: xxxx,
* func: yyyy
* },
* func: yyyy
*/
// esModuleの場合はgetDefaultを使うを判別する関数をIIFEに作成する
const getDefaultExport = function (module) {
const getter =
module && module.__esModule
? function getDefault() {
return module["default"];
}
: function getModuleExports() {
return module;
};
Object.defineProperty(getter, "esm", { enumerable: true, get: getter });
return getter;
};
このサンプルではdefault
を 1 階層挟まずにやっているため Object の key が直で名前となります。
ImportDefaultSpecifier: import foo from 'module'
default は必ず一つしか無いため、name はdefault
となります。
ImportSpecifier: import { foo } from 'module'
普通の named import であれば、name は import 先と同じです。
問題は、リネームされている場合です。この場合は、imported.name
を見る必要があります。
そして、ローカルのリネームされたものは全部置き換えます。
// import { foo as bar } from 'module';
const __BUNDLER__1 = require("module");
console.log(__BUNDLER_1["foo"](10)); // <--- barはもういらないのでfooに戻してあげる
置換を行う
それぞれの状態でも name を取れたので最後に置換を行います。
const assignment = t.identifier(
`${prefix}${moduleId}${name ? `[${JSON.stringify(name)}]` : ""}`,
);
// ImportNamespaceSpecifierは今回defaultを省いたので以下
// replace `['foo'].foo` with `['foo']`
path.parentPath.replaceWith(assignment);
// その他
path.replaceWith(assignment);
ここでの node の区間は以下の箇所です。
// parent node: | ここ |
// current node: | この区間 | ここはとなり |
foo()
__BUNDLER_1['foo']()
なぜImportNamespaceSpecifier
ではparentPath.replaceWith
を使っているかというと()
部分も修正したかったからです。
これでコード置換が行えたので、最初に出したランタイムコードが作成されます。
さいごに
以上で、module bundler で ESM の対応をする方法の紹介は終わりです。 すべてのコードはこの PR を見るとわかります。