module bundlerの作り方(準備編)

2020 / 05 / 19

Edit
🚨 This article hasn't been updated in over a year
💁‍♀️ This post was copied from Hatena Blog

今回は中身がどう動いているかを解説したいと思います。 最初のこの記事では、最低限の実装を説明していくことにします。

webpack のアルゴリズムの仕組みはこちらを読んでください。

webpackの仕組みを簡潔に説明する - 技術探し webpackの仕組みを説明します。

必要なステップ

必要なステップは以下の 3 つです。

  • エントリーポイントからのすべてのモジュールを走査し、requireを解決後にユニーク id を付与していく
  • コード内のモジュールパス(requireの引数(e.g. ./module.js))を id へ置換する
  • runtime のコードテンプレートの作成
    • IIFE(即時関数)箇所とそれに付随する引数の module 群

この実装されあれば、動くコードはできます。(2 つめは optional でもいいけど後からつらくなる)

モジュール解決

今回は説明しやすいように関数を 2 つに分けています。

  • すべてのモジュールの把握と ID 作成
  • コード内の require 部分を ID を置換

1. 使われるモジュールをすべて列挙し、ユニーク ID を添付させる

ここでのゴールは、モジュールのコードをすべて AST にしそれぞれのモジュールにユニークな ID を付与していくことです。

今回は、webpack でも使う JS のパーサーである acorn を使っても良いのですが、codegen がほしいので babel を使って行います。(もちろん、acornescodegenを使っても良い)

@babel/parserでコードを AST に落とし、@babel/traverse でトラバースをし、@babel/generatorで AST からコードを生成します。

以下の処理を再帰させることにより、使用されるモジュールを列挙します。

ファイル名を取得する

require の declarations type は CallExpressioncalleeの type は Identifier となります。

const parser = require("@babel/parser");
const { default: traverse } = require("@babel/traverse");
const { default: generate } = require("@babel/generator");

const basePath = dirname(entryPath);
const ast = parser.parse(await promises.readFile(entryPath, "utf8"));

traverse(ast, {
  CallExpression({ node: { callee, arguments: args } }) {
    if (callee.type === "Identifier" && callee.name === "require") {
      const filePath = getScriptFilePath(basePath, args[0].value);
    }
  },
});

つまり、このように構文解析を行えば、呼び出されるモジュールファイルがわかります。 そして、CJS では拡張子が省略可能であり、index.js.で省略可能なので、以下の処理が必要です。

function getFilename(filename) {
  // index.js === .
  if (filename === ".") {
    return "index.js";
  }

  // omit .js
  if (extname(filename) === "") {
    return `${filename}.js`;
  }

  return filename;
}

node_modules のファイルを読み込む

注意点は以下の通りです。

  • node_modules もxxx/yyyの形式でも動く
  • main フィールドがない場合は、'./index.js'を参照する
  • node_modules 内のモジュールの子供は./等でアクセスするため少し処理が複雑になる
    • 再帰処理を行う時にxxxが来たらベースのパスをリセットし root の値にする
    • そうでない場合は前回のベースのパスを引き継ぐために引数に渡す

また、この処理は以下の点がめんどくさいので一旦不完全として進めて行きたいと思います。

  • package.json のmodule, browser, exportsフィールドには対応しない
  • node_modules のディレクトリ昇格処理は行わない
function getScriptFilePath(basePath, filename) {
  if (isNodeModule(filename)) {
    return join(basePath, getFilename(filename));
  }

  // node_modules
  const moduleBasePath = join(basePath, "node_modules", filename);

  // e.g. require('a/b')
  // need to split by /
  if (filename.includes("/")) {
    const dir = dirname(moduleBasePath);
    const name = basename(moduleBasePath);

    return join(dir, getFilename(name));
  }

  // TODO: add module, browser, exports
  const { main } = require(join(moduleBasePath, "package.json"));

  // when main field is undefined, index.js will be an entry point
  return join(moduleBasePath, getFilename(main));
}

使われるモジュールのマップを作成する

これでバンドラーからモジュールへのファイルアクセスが行えたので、そのモジュールの中身を取得し AST に変換し、保持します。ここでは以下の情報を整理します。

  • id => module に振られたユニーク id
  • path => 絶対パス、require('./module1')./module1での検索は困難で書き方が多く一意でないため絶対パスが検索キー
  • ast => 後から再度使うので AST の形で保持しておく
const modulesMap = new Set();

// エントリーポイントは0番
modulesMap.add({
  id: 0,
  path: entryPath,
  ast: entryCodeAst,
});

ここで最初にモジュールをすべて把握するのかというと、あとからすべてのコードのrequireにモジュールの ID を振っていくため一度すべてのモジュールの対応表を作らなければなりません。

filename を id にしない理由
  • メリット
    • どのファイルが読まれているかわかりやすい
    • requireの引数を置換しなくてもいいので、実装が楽
      • 最終的にrequireを実行して走査するので、id の対応付けが必要でファイル名を id にすると単純なコードでは動く
  • デメリット
    • bundle サイズが大きくなる
    • ファイル階層、ファイル名の省略、拡張子の省略で一意な値にならない
      • require('xxxx) の xxxx とキー名を一緒にするのはコード内ですべて統一されている保証がないので危ない
      • 絶対パスに変換すればこの問題は解決するが、結局ソースコード内の変更を行っているし bundle サイズが大きくなるので無駄

なので、モジュールそれぞれに数値 id をふることにより上記の問題を解決するのが一般的です。

モジュールをキャッシュする

これを行うことにより、すでに読み込まれたモジュールの追加を防ぎ、Circular Dependency 防ぎます。 以下の場合の対応を行わないと再帰が終わらずに無限にループします。

Circular Dependency の例

// entry.js
module.exports = "from entry";

const a = require("./module1");

console.log("main:", a);
// module1.js
const a = require("./entry");

console.log("module1:", a);

module.exports = "from module1";

以下のように現在走査しているファイルの絶対パスを使って確認を行います。

const hasAlreadyModule = Array.from(modulesMap).some(
  ({ path }) => path === filePath,
);

無ければ、追加し、そのモジュールはキャッシュされていないのでそのモジュールの依存を辿るために再帰を行います。

if (!hasAlreadyModule) {
  try {
    // node_modulesだったら現在のベースパスをリセット
    const nextDir = isNodeModule(args[0].value) ? entryDir : dirname(filePath);
    const code = readFileSync(filePath, "utf-8");
    const ast = parser.parse(code);

    modulesMap.add({
      id: modulesMap.size, // これで自動的にIDがインクリメントされていく
      ast,
      path: filePath,
    });
    walkDeps(ast, nextDir); // まだ見てないモジュールの中身を見に行く
  } catch (e) {
    console.warn("could not find the module:", e.message);
  }
}

全体コード

async function buildModulesMap(entryDir, entryFilename) {
  const modulesMap = new Set();
  const entryPath = getScriptFilePath(entryDir, `./${entryFilename}`);
  const entryCodeAst = parser.parse(await promises.readFile(entryPath, "utf8"));

  // add an entry point
  modulesMap.add({
    id: 0,
    path: entryPath, // an absolute path
    ast: entryCodeAst,
  });

  // start from the entry-point to check all deps
  walkDeps(entryCodeAst, entryDir);

  function walkDeps(ast, currentDir) {
    traverse(ast, {
      CallExpression({ node: { callee, arguments: args } }) {
        if (callee.type === "Identifier" && callee.name === "require") {
          const filePath = getScriptFilePath(currentDir, args[0].value);
          const hasAlreadyModule = Array.from(modulesMap).some(
            ({ path }) => path === filePath,
          );

          if (!hasAlreadyModule) {
            try {
              // reset the current directory when node_modules
              // ./ has 2 types which are local of the first party and local of the third party module
              const nextDir = isNodeModule(args[0].value)
                ? entryDir
                : dirname(filePath);
              const ast = parser.parse(readFileSync(filePath, "utf-8"));

              modulesMap.add({
                id: modulesMap.size,
                ast,
                path: filePath,
              });

              walkDeps(ast, nextDir);
            } catch (e) {
              console.warn("could not find the module:", e.message);
            }
          }
        }
      },
    });
  }

  return modulesMap;
}

2. すべてのコードのrequireを id に置換する

ここでのゴールは、先程生成したmodulesMapのコードのrequireの中身をすべて id を書き換えることです。

先程作成した以下の情報を使っていきます。

const modulesMap = new Set();

modulesMap.add({
  id: 0,
  path: entryPath, // 絶対パス
  ast: entryCodeAst,
});

それをすべて回しつつ、AST がすでにあるので再度トラバースを行い自身(modulesMap)の中に入っている他のモジュールを探しその ID をrequire部分を上書きします。

e.g. ./module.js === 1(id) => require('./module') ===>require(1)

for (const { id, ast, path } of modulesMap.values()) {
  traverse(ast, {
    CallExpression({ node: { callee, arguments: args } }) {
      if (callee.type === "Identifier" && callee.name === "require") {
        const filePath = getScriptFilePath(
          // node_modulesのときはプロジェクトのベースパスではなく、そのモジュールのpathをベースにする
          isNodeModule(args[0].value) ? dirname(path) : basePath,
          args[0].value,
        );
        const {
          id: moduleID,
        } = // ここでrequireの中身のファイルIDを手に入れる
          Array.from(modulesMap.values()).find(
            ({ path }) => path === filePath,
          ) || {};

        // requireの引数の中身を変更する
        args[0].value = moduleID; // './xxxx' => 0 等の数字(moduleID)へ置換する
      }
    },
  });
}

最終的な展開式は、各モジュールのidcodeだけあればいいので、以下のように管理します。 pathはなくてもいいですが、bundle されたファイルにコメントでファイル名書いてあげるとわかりやすいので入れておいたほうがいいです。

const modules = new Map();

modules.set(id, {
  path,
  code: moduleTemplate(generate(ast).code),
});

実行で読み込まれるファイルのすべての依存を解決し、一意なモジュールの id に置換が行えました。

全体コード

function convertToModuleId(basePath, modulesMap) {
  const modules = new Map();

  for (const { id, ast, path } of modulesMap.values()) {
    traverse(ast, {
      CallExpression({ node: { callee, arguments: args } }) {
        if (callee.type === "Identifier" && callee.name === "require") {
          const filePath = getScriptFilePath(
            // don't reset the path when node_modules
            // because the path during searching in node_modules is the base path of modulesMap
            isNodeModule(args[0].value) ? dirname(path) : basePath,
            args[0].value,
          );
          const { id: moduleId } =
            Array.from(modulesMap.values()).find(
              ({ path }) => path === filePath,
            ) || {};

          args[0].value = moduleId;
        }
      },
    });

    modules.set(id, {
      path,
      code: moduleTemplate(generate(ast).code),
    });
  }

  return modules;
}

ランタイムのコード作成

最後に二種類の実行コードの作成を行います。

  • モジュールテンプレート
  • 本体テンプレート(IIFE)

モジュールテンプレート

モジュールは以下のように展開されます。

// before
function m(txt) {
  console.log("module", txt);
}

module.exports = m;
// after
({
  [id]: function (module, exports, require) {
    function m(txt) {
      console.log("module", txt);
    }

    module.exports = m;
  },
});

このように引数のmodule, exports, requireを持った関数に囲います。 これは後ほど、本体の引数として使われます。

本体テンプレート

上記で作成したモジュールテンプレートを value として保持し、key をそのモジュールの id としたオブジェクトを IIFE の引数に渡します。

((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;
  }

  return require(0); // この0はエントリーポイントの0
})({
  // ここからは上のテンプレート
  0: function (module, exports, require) {
    const m = require(1);

    m("from entry.js");
  },
  1: function (module, exports, require) {
    function m(txt) {
      console.log("module", txt);
    }

    module.exports = m;
  },
  // ここまで
});

これでモジュール解決も行えて、1 ファイルで実行できる形となりました。 これが最低限のベースコードとなります。

処理フロー

1 引数に設定しているモジュール群が本体コードに渡りすべての実行が始まる

((modules) => {

2 最初に return require(0); が実行される 0(entry point の moduleId)はすでにこのコードを生成する時にセットしておく

((modules) => {
  ...

  return require(0);
})({...});

3 require(0)を実行する

ここの処理は再帰的に行われるため以下共通

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;
}
  • 最初にキャッシュ用の変数(usedModules)を確認する(今回は最初なので空)
    • キャッシュ変数のキーは moduleID なので、もしあればその中のexportsを返して終わり

--- ここからはキャッシュが無い時の処理 ----

  • 次にキャッシュ変数に今の引数の moduleId の値を入れ初期化(次回以降のキャッシュのため)
    • 出口のexportsだけあれば全部のモジュールがつながるのでそれだけ初期化する
const module = (usedModules[moduleId] = {
  exports: {},
});
  • 次に以下の処理を実行する
// nodeには、exportsとmodule.exportsがあるため、第1引数と第2引数を使いmodule.exportsに格納する
// requireは走査用ラッパー
modules[moduleId](module, module.exports, require);
  • ここで第 3 引数にrequireを渡しているため再帰的に走査を行いキャッシュ変数に使ったモジュールを貯めつつ実行をしていきます
({
  0: function (module, exports, require) {
    // 引数経由で来たIIFE内のrequire functionがここで実行され、1の走査が始まる
    // 1の中にrequireがあれば更にそれが呼ばれる を繰り返しコードを組み立てる
    // require functionの戻り値はキャッシュ変数に格納されたexports
    const m = require(1);

    m("from entry.js");
  },
});

このようにエントリーポイントからスタートし、上から順に再帰的にモジュールからモジュールへ呼び出しを行い実行していきます。

bundle されたコード例

"use strict";

const { version } = require("react");

console.log(version);

これの変換後は以下のようになります。

https://github.com/hiroppy/the-sample-of-module-bundler/blob/master/tests/output/cjs/node-modules/main.min.js

これを見ればわかりますが、tree shaking/dead code elimination の必要性が出てきます。

リポジトリ

すべてのコードはこのリポジトリにあります。

GitHub - hiroppy/the-sample-of-module-bundler: You will know how to make a javascript bundler You will know how to make a javascript bundler. Contribute to hiroppy/the-sample-of-module-bundler d...

さいごに

次回は、tree shaking + ESM について書こうと思います。