module bundlerの作り方(ECMAScript Modules編)
前回の準備編では、module bundlerがどのように動いているかを説明しました。
今回は、dynamic import以外の最低限の実装を入れていきます。
変更されたコード一覧はこちら
ECMAScript Modules(ESM)について
さて、多くの人がすでに使っている以下のような構文がESMと呼ばれるものです。
import { version } from 'module'; export const a = 1;
仕様等のドキュメント
また、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を見るとわかります。
本当はここでtree shakingもやろうと思ったんだけど、予想以上に量が多くなってしまったので次回やります。
もし何か聞きたいことあったら、Twitterまで。