blog.takurinton.dev

バンドルツール作る

2021-06-05

こんにちは

どうも、僕です。
最近バンドルツールを作った(というか作ってる途中)なのでその様子を記事にします。

まだ作ってる途中なのと、あまりきれいな構成ではないので多目に見てください。
ではやっていきます。

技術選定

プログラムを実行するためのものと、バンドラーを作成するための補助として使うもののそれぞれを別に悩むことなく以下のように選定しました。

deno

プログラムの実行に関してですが、今回は Deno を選択してみました。
存在は知っていたけど触れてなかったやつです、これを機に触れてみよ〜ってなりました。 Deno とは Node.js の欠点を補うために開発されたと言われています。ESM  周りの依存関係や Promise の挙動、TS のデフォルト使用などでしょうか(あまり知らない、ここら辺について後日記事にしたい)

あとは TL で マグロのアイコンした人 がしょっちゅうツイートしていたので気になって使ってみたというのもあります。いつも貴重な情報ありがとうございます

使ってみた感じでは、npm でのライブラリ管理ではなく deno.land/x での管理なので package.json が不要で楽(まああったほうがいいんだけど)、TS がデフォルトで使える(JS で書いてるんだけど)あたりが良いなと感じました。

babel

JS のコードをいじるのは babel を使用しました。 某で作る某タプリタ という書籍でも、字句解析、構文解析、ASTの生成を自力実装するのはおすすめしないぜベイベーと書かれていました。
ということでコードの parse は babel に頼り切ることにしました。

実際に babel プラグインを作るようなイメージでこれらのライブラリを使用しました。

babel での AST の弄り方

これは外部サイトとかを参考にしてもらった方がいいと思います(適当)
ぐぐりラビティが試されますね(適当)

と言いつつ、これはメモ帳なので一応メモ程度に書きます!

上と少し被りますが、babel の以下のプラグインを使用すれば簡単にソースコードをいじることができます。
まず最初に string のソースコードを AST に変換し、AST をいじり、また string のソースコードに吐き出すといった流れです。

コードベースで考えます。

ソースコードから AST を生成する

まずはソースコードを parse して AST を生成してみます。
例えば

const name = 'takurinton';

というコードがあるとして、それを AST にするためのコードは以下になります。

import perser from "https://dev.jspm.io/@babel/parser";

const ast = perser.parse(`
    const name = 'takurinton';
`);

console.log(JSON.stringify(ast, null, 2));

そして、ここから生成される AST は以下のようになります。

{
    "type": "File",
    "start": 0,
    "end": 28,
    "loc": {
      "start": {
        "line": 1,
        "column": 0
      },
      "end": {
        "line": 3,
        "column": 0
      }
    },
    "errors": [],
    "program": {
      "type": "Program",
      "start": 0,
      "end": 28,
      "loc": {
        "start": {
          "line": 1,
          "column": 0
        },
        "end": {
          "line": 3,
          "column": 0
        }
      },
      "sourceType": "script",
      "interpreter": null,
      "body": [
        {
          "type": "VariableDeclaration",
          "start": 1,
          "end": 27,
          "loc": {
            "start": {
              "line": 2,
              "column": 0
            },
            "end": {
              "line": 2,
              "column": 26
            }
          },
          "declarations": [
            {
              "type": "VariableDeclarator",
              "start": 7,
              "end": 26,
              "loc": {
                "start": {
                  "line": 2,
                  "column": 6
                },
                "end": {
                  "line": 2,
                  "column": 25
                }
              },
              "id": {
                "type": "Identifier",
                "start": 7,
                "end": 11,
                "loc": {
                  "start": {
                    "line": 2,
                    "column": 6
                  },
                  "end": {
                    "line": 2,
                    "column": 10
                  },
                  "identifierName": "name"
                },
                "name": "name"
              },
              "init": {
                "type": "StringLiteral",
                "start": 14,
                "end": 26,
                "loc": {
                  "start": {
                    "line": 2,
                    "column": 13
                  },
                  "end": {
                    "line": 2,
                    "column": 25
                  }
                },
                "extra": {
                  "rawValue": "takurinton",
                  "raw": "'takurinton'"
                },
                "value": "takurinton"
              }
            }
          ],
          "kind": "const"
        }
      ],
      "directives": []
    },
    "comments": []
} 

ナゲ〜〜〜、なげえですね。でもプログラミング言語って中身でこんなことが起こってるんですね。あ、全人類最低一度はプログラミング言語の作成経験があるからここら辺については皆さんご存知ですね。教養教養。
各ノードにある type ってのが大事ですね。
これはそのブロックが何を示しているかを表しています。AST をいじる時はここら辺をいじっていきます。大事。

AST を操作してみる

プラグインなどを作るときの醍醐味はここですよね!
AST を操作していきたいと思います!
AST の操作には traverse を使用します。
今回はすごい簡単に、先ほどのソースコードの name という変数を kimutaku に置き換えてみましょう。
また、その書き換えたコードを generate を使用し吐き出します。

import traverse from "https://dev.jspm.io/@babel/traverse";
import generator from "https://dev.jspm.io/@babel/generator";

const ast = traverse.default(ast, {
    VariableDeclaration(path) { 
      path.node.declarations.id.name = 'kimutaku';
    },
});

// 引数の ast は先ほど精製した AST を渡す
console.log(generator.default(ast));

これを出力すると以下のようになります。

const kimutaku = 'takurinton';

はい、これで木村拓哉さんもたくりんとんになったのでたくりんとんも木村拓哉さんになりました。つまり美人姉妹は僕のものですね。参照渡し〜のバカ〜(๑˃̵ᴗ˂̵)

みたいな感じでソースコードを直接操作することが可能です。めでたしめでたし。
めでたくないです、本題はここからです。

構成

構成は以下のようにしました。こだわりとかはなかったので、雑にこんな感じかなと決めてしまいました。
ちょっとだけ plugin.js が重くなったかなというくらいで、あとはそんなもんかなと思います。

  • packages/
    • cli.js(CLIの実装、 #2 で実装している)
    • parse/
      • parse.js(ES のコードを読み込んで AST に変換する)
    • traverse/
      • plugin.js(ESM を CommonJS に変換する)
      • traverse.js(AST を変換してコードとして出力する)
      • traverse.test.js
  • index.js(コードを実行する)

書いていく

AST については上で簡単にまとめたので、ここでは plugin.js についてのみをまとめます。

ではやっていきます。

まず全体のコード

まずは全体のコードを示し、後から部分的に説明します。

import helper from 'https://dev.jspm.io/@babel/helper-plugin-utils';
import HelperTransforms from 'https://dev.jspm.io/@babel/helper-module-transforms';
import HelperSimpleAccess from 'https://dev.jspm.io/@babel/helper-simple-access';
import t from 'https://dev.jspm.io/@babel/types';
import template from 'https://dev.jspm.io/@babel/template';

export default helper.declare((api, options) => {
  api.assertVersion(7);

  const {
    loose,
    // mjs じゃない時は true を使って .default を使用する
    strictNamespace = false,

    // true だったら mjs を使っているということで .default は持たない
    mjsStrictNamespace = true,
    allowTopLevelThis,
    strict,
    strictMode,
    noInterop,
    lazy = false,
    allowCommonJSExports = true,
  } = options;

  // options のバリデーション
  // array.every 全部 string かどうかを調べてる (https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/every)
  if (typeof lazy !== 'boolean' && typeof lazy !== 'function' && (!Array.isArray(lazy) || !lazy.every(item => typeof item === 'string'))) throw new Error(`.lazy must be a boolean, array of strings, or a function`);
  if (typeof strictNamespace !== 'boolean') throw new Error(`.strictNamespace must be a boolean, or undefined`);
  if (typeof mjsStrictNamespace !== 'boolean') throw new Error(`.mjsStrictNamespace must be a boolean, or undefined`);

  // ast を生成してる、エラー用
  const getAssertion = localName => template.expression.ast`
    (function(){
      throw new Error(
        "The CommonJS '" + "${localName}" + "' variable is not available in ES6 modules." +
        "Consider setting setting sourceType:script or sourceType:unambiguous in your " +
        "Babel config for this file.");
    })()
  `;

  // module の exports を操作する
  // ここの使い方についてはこの Qiita の記事がわかりやすかった
  // https://qiita.com/shuhei/items/96a852f7e0995fd42981
  const moduleExportsVisitor = {
    // 識別子の処理
    ReferenceIndentifier(path) {
      // ast の name を取得
      const localName = path.node.name;
      // module か exports だけを判定
      if (localName !== "module" && localName !== "exports") return;

      // 自分のスコープと親スコープのそれぞれの name を取得してる
      const localBinding = path.scope.getBinding(localName);
      const rootBinding = this.scope.getBinding(localName);

      if (
        // 再宣言をしてる
        // 形式が正しかったら戻す
        rootBinding !== localBinding ||
        (path.parentPath.isObjectProperty({ value: path.node }) && path.parentPath.parentPath.isObjectPattern()) ||
        path.parentPath.isAssignmentExpression({ left: path.node }) ||
        path.isAssignmentExpression({ left: path.node })
      ) return;

      path.replaceWith(getAssertion(localName));
    }, 

    // 代入式の処理
    AssignmentExpression(path) {
      const left = path.get("left");
      
      // もし左辺が識別子だったらというか識別子って言い方あまり気に入らないから変数名とか定数名とかにしたいな
      if (left.isIdentifier()) {
        const localName = path.node.name;
        if (localName !== 'module' && localName !== 'exports') return; // 上と同じ

        const localBinding = path.scope.getBinding(localName);
        const rootBinding = this.scope.getBinding(localName);

        if (rootBinding !== localBinding) return;

        // さっきは識別子だけだったけど今回は Expression なので値が入る
        const right = path.get("right");
        right.replaceWith(
          t.sequenceExpression([right.node, getAssertion(localName)]),
        );
      } else if (left.isPattern()) {
        // 
        const ids = left.getOuterBindingItentifiers();
        const localName = Object.keys(ids).filter(localName => {
          if (localName !== 'module' && localName !== 'exports') return false;
          return (
            path.scope.getBinding(localName) === this.scope.getBinding(localName)
          );
        })[0];

        if (localName) {
          const right = path.get('right');
          right.replaceWith(
            t.sequenceExpression([right.node, getAssertion(localName)]) // getAssertion はここで呼んでいる
          );
        }
      }
    },
  };

  return {
    name: 'rapida', 

    // pre() {
    //   this.file.set("rapida-*", "commonjs");
    // }

    // ファイル
    visitor: {
      CallExpression(path) {
        let { scope } = path;
        do {
          scope.rename("require");
        } while (scope = scope.parent);

        // ここで import to require をしているが正直これはナンセンスなのでちょっと考える
        // transformImportCall(this, path.get("callee"));
      },

      Program: {
        exit(path, state) {
          if (!HelperTransforms.isModule(path)) return;

          // rename してる
          path.scope.rename("exports");
          path.scope.rename("module");
          path.scope.rename("require");
          path.scope.rename("__filename");
          path.scope.rename("__dirname");

          // allowCommonJSExports は上で設定してる、デフォルトでここの処理を通ることはないけど何かがあったら通る
          if (!allowCommonJSExports) {
            HelperSimpleAccess.default(path, new Set(["module", "exports"]));
            path.traverse(moduleExportsVisitor, {
              scope: path.scope,
            });
          }

          // もし moduleName があったら string にする
          let moduleName = HelperTransforms.getModuleName(this.file.opts, options);
          if (moduleName) moduleName = t.stringLiteral(moduleName);

          // ここでモジュールを書き換えてる
          // ここがミソなのにライブラリに任せてしまっている
          // これが人生
          // あとで書き換えるよ
          const { meta, headers } = HelperTransforms.rewriteModuleStatementsAndPrepareHeader(path,
            {
              exportName: "exports",
              loose,
              strict,
              strictMode,
              allowTopLevelThis,
              noInterop,
              lazy,
              esNamespaceOnly:
                typeof state.filename === "string" &&
                /\.mjs$/.test(state.filename) ? mjsStrictNamespace : strictNamespace,
            },
          );

          // 依存関係の解決
          for (const [source, metadata] of meta.source) {
            // require に変換する
            const loadExpr = t.callExpression(t.identifier("require"), [
              t.stringLiteral(source),
            ]);

            let header;
            // isSideEffectImport は名前なし import ではないかどうかを確認する
            // https://github.com/babel/babel/blob/b2d9156cc62d37f4c522c9505a00f50b99a1eb74/packages/babel-helper-module-transforms/src/normalize-and-load-metadata.ts#L63-L74
            // export function isSideEffectImport(source: SourceModuleMetadata) {
            //   return (
            //     source.imports.size === 0 &&
            //     source.importsNamespace.size === 0 &&
            //     source.reexports.size === 0 &&
            //     source.reexportNamespace.size === 0 &&
            //     !source.reexportAll
            //   );
            // }
            if (HelperTransforms.isSideEffectImport(metadata)) {
              // 名前なし import の時上で生成した meta データの lazy が false だったらエラー
              if (metadata.lazy) throw new Error("Assertion failure");
              header = t.expressionStatement(loadExpr);
            } else {
              /**
               * Given an expression for a standard import object, like "require('foo')",
               * wrap it in a call to the interop helpers based on the type.
               * wrapInterop、ドキュメントないけどコードはここ
               * https://github.com/babel/babel/blob/b2d9156cc62d37f4c522c9505a00f50b99a1eb74/packages/babel-helper-module-transforms/src/index.ts#L126-L154
               * 戻り値は t.CallExpression
               */
              // export function wrapInterop(
              //   programPath: NodePath,
              //   expr: t.Expression,
              //   type: InteropType,
              // )
              const init = HelperTransforms.wrapInterop(path, loadExpr, metadata.interop) || loadExpr;
              if (metadata.lazy) {
                header = template.default.ast`
                  function ${metadata.name}() {
                    const data = ${init};
                    ${metadata.name} = function(){ return data; };
                    return data;
                  }
                `;
              } else {
                // 全部あてはまらないときは
                // var _fuga = _interopRequireDefault(require("fuga"));
                // のような形で require で読み込む
                header = template.default.ast`
                  var ${metadata.name} = ${init};
                `;
              }
            }

            // metadata.loc はソースコードの場所(start position, end position とか)を示している
            // file path もここにあるから cli 作るときはここをいじる
            header.loc = metadata.loc;
            
            headers.push(header);
            headers.push(
              // buildNamespaceInitStatements
              // https://github.com/babel/babel/blob/b2d9156cc62d37f4c522c9505a00f50b99a1eb74/packages/babel-helper-module-transforms/src/index.ts#L156-L217
              // ランタイムで statement を作成する
              // import/export を初期化する
              ...HelperTransforms.buildNamespaceInitStatements(meta, metadata, loose),
            );
          }

          // ensureStatementsHoisted
          // https://github.com/babel/babel/blob/b2d9156cc62d37f4c522c9505a00f50b99a1eb74/packages/babel-helper-module-transforms/src/index.ts#L115-L120
          // モジュールの初期化を行う。
          // headers が全てのコードなのでこれを初期化する
          HelperTransforms.ensureStatementsHoisted(headers);
          path.unshiftContainer("body", headers);
        }
      }
    },
  }
});

こんな感じです、長い。
コメントで書いてあるので説明は端折ります。

まとめ

自分でもびっくりするくらいクソ記事になってしまいましたがメモ帳なんてこんなもんだし、ソースコードに丁寧にコメント書いてあるのでそれ読んで思い出してね自分って感じです。
じゃ。