技術探し

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

module bundlerの作り方(ECMAScript Modules編)

前回の準備編では、module bundlerがどのように動いているかを説明しました。

blog.hiroppy.me

今回は、dynamic import以外の最低限の実装を入れていきます。

リポジトリ

github.com

変更されたコード一覧はこちら

ECMAScript Modules(ESM)について

さて、多くの人がすでに使っている以下のような構文がESMと呼ばれるものです。

import { version } from 'module';
export const a = 1;

仕様等のドキュメント

tc39.es

whatwg.github.io

exploringjs.com

また、Node.jsでのESMの解決方法はCJSとの互換性を保つために別途異なるのでここでは扱いません。

blog.hiroppy.me

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との互換をあまり考えないため、__esModuleexportsアサインはしません。

ほかのパターンを見る場合はこちら

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の場合、10に呼び出されてその時にusedModules[1].exportsaddが登録され、0で使われます。 しかし、0からすると本当に1addがあるのかわからないため、実行時に落ちる可能性があります。

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を付与しなければいけないためです。

developer.mozilla.org

IIFE内で共通で使うために以下を定義します。

require.__defineExports = (exports, exporters) => {
  Object.entries(exporters).forEach(([key, value]) => {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: value,
    });
  });
};

そして、モジュールが初めて呼び出された時に以下のrequire.__defineExportsを実行し、usedModules[1].exportsObject.defineProperty経由でプロパティを追加します。

1: function (module, exports, require) {
  function add(n) {
    return 10 + n;
  }

  require.__defineExports(exports, {
    add: () => add, // このオブジェクトにこのファイル内のexportされるものが追加されている
  });

コードを書き換える

ASTを弄りたい人はastexplorerを使うと便利です。

astexplorer.net

CJSではコードの書き換えはrequire('module') ->require(1)のようにmoduleIdのみの書き換えでしたがESMでは呼び出し元等を編集する必要があります。

ESMもCJSとモジュールのマップ作成の処理は共通なので、コードの走査処理だけがESM時に新しく追加されます。コード箇所

  • CJS: modulesのリストを作る -> requireの中身をmoduleIdに変更する
  • ESM: modulesのリストを作る -> ソースコードをトラバースする -> requireの中身をmoduleIdに変更する
    • 二段階目でrequireに変換し、CJSと同じ形になるため三段階目はCJSと共通で動く

それでは、babelを使いASTを走査し以下のことを達成していきます。

  • importrequireへ変換
    • (e.g. import a from 'module' -> const a = require('module'))
  • 外部モジュールから使っている変数、関数等をすべて置換する
    • (e.g. a(1); -> __BUNDLER__1["a"](1))
  • exportをすべてマッピングし、不必要なシンタックスを消す
    • (e.g. export const a = 1 -> const a = 1)

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はExportDefaultDeclarationExportNamedDeclarationとなります。

// 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を見るとわかります。

github.com

本当はここでtree shakingもやろうと思ったんだけど、予想以上に量が多くなってしまったので次回やります。
もし何か聞きたいことあったら、Twitterまで。