Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

作って理解する Babel プラグイン

babel plugin

エンジニアの伊藤です。 ようやく梅雨が開けたと思ったらすでに立秋。残暑は残るどころか日に日に増している感じさえあります… 🥵 。

この記事では、普段の開発で非常にお世話になっているツール Babel のプラグインを作成する方法と、Babel プラグインの例としての処理時間を計測するプロファイラを紹介します。Babel プラグインに踏み込んでいきたいのですが、理解を助けるためにまず Babel が何をするツールなのかから説明します。

Babelとは

フロントエンドの開発を行っている方にはお馴染みな Babel という JavaScript のコンパイラがあります。

Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments.

https://babeljs.io/docs/en/


主に以下のことを実現してくれます。

  • ECMAScript の最新文法をブラウザがサポートする記述に変換する
  • 実行環境に足りない機能の Polyfill を提供する
  • ソースコードの変換を行う

Babel の処理の流れ

Babel はいわゆる「コンパイラ」の一種であるため、その処理内容は小難しそうなイメージがつきまといます。ただ、その処理概要を眺めてみると大枠としてはシンプルなアーキテクチャになっています。

Babel 処理概要
Babel 処理概要

大まかに、コード解析 (@babel/parse) -> AST 変換 (Plugins) -> コード生成 (@babel/generator) という流れになっています。上の図でいうと、上側にある @babel/cli を起点として反時計回りに処理が進むイメージです。

@babel/cli がシェルとの窓口になっていて、ユーザーが指定したファイルを読み込み、処理を行った結果を返します。@babel/core は @babel/cli や Bundler などほかのプログラムが「解析〜変換〜生成」処理を呼び出すための API 群を提供しています。そしてこの @babel/core が @babel/parser, Plugins, @babel/generator と連携して一連の処理を行っています。@babel/types, @babel/traverse, @babel/template は各パッケージ間でやりとりされる AST というデータ構造を処理するためのユーティリティとなっていて、プラグインの中でもこれらを利用して処理を記述することになります。ほかにも @babel/code-frame と行ったコンパイル結果をわかりやすく表示するツールも存在しています。

AST (Abstract Syntax Tree)

日本語では抽象構文木と訳される概念です。詳細な説明は他サイトにゆずるとして、ここでは「プログラム中の意味のあるまとまりの集合を表現するためのデータ構造」程度に理解してもらえれば十分です。そしてこの「意味のあるまとまり」をノードと呼びます。JavaScript の場合、どのようなデータになっているかは AST Explorer で手軽に確認できます(神ツール!)。AST Explorer の左ペインに JavaScript コードを貼り付けて、メニューから「JavaScript」「@babel/parser」を選ぶと実際に AST がどのようなデータなのかを確認できます。右ペインの + を開いていくことでプログラム中の情報が解析されている様子がわかります。

Babel プラグイン

Babel が行う処理のうち、「AST 変換」をつかさどります。つまり AST を受け取り適切な変換処理を施します。 Babel ではこの部分にプラグイン構造を採用していて、ユーザーが必要としている変換処理を自由に選択できるようになっています。実際、プラグインが一切ない状態で Babel を使ってみるとみため上はほとんど何もしません。なお、Babel の設定に出てくる preset はプラグインを一定の単位(ECMAScript のバージョンなど)でまとめたものです。

Babel プラグイン プロジェクト最小構成

では Babel プラグインはどうやって作るの?ということで、Babel プラグインを作るためのプロジェクト構成をみていきます(ようやく本題)。

$ mkdir babel-plugin-hello-world
$ cd !:1
$ tree -a
.
├── .babelrc
├── index.js
└── package.json
$ cat package.json 
{
  "name": "babel-plugin-hello-world",
  "version": "0.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "devDependencies": {
    "@babel/cli": "^7.10.1"
  },
  "dependencies": {
    "@babel/core": "^7.10.2"
  }
}
$ cat .babelrc 
{ "plugins": ["./index.js"] }

yarn init した後に @babel/core, @babel/cli を yarn add しただけです。.babelrc は動作確認用に置いてあるだけで、プラグインとしては要求されません。必要なのは変換処理が記述された index.js と package.json になります。シンプルではありますが、このような構成にしておけばほかのパッケージからこのプラグインを利用できます。そのままパス指定もできますし、node_modules に追加しても指定できるようなります*1

Hello World

肝心の変換内容については、まずは Hello World! ということで入力コード中に出現するすべてのリテラルを文字列 Hello World! に置き換えてみます。index.js に以下の内容を記述します。

module.exports = function({ types: t }) {
  return {
    visitor: {
      Literal(path) {
        path.replaceWith(t.stringLiteral("Hello World!"))
        path.skip()
      }
    }
  }
}

Node 12 系を使っています。Babel プラグインは { visitor: { ... } } というオブジェクトを返す関数を default export する必要があります。この visitor の値となるオブジェクトの中に変換処理を書いていきます。いわゆる Visitor パターンが使われていて、プラグインに渡された AST の中のノードを Babel が横断 (traverse) していく中で現れたノードが visitor 中のパターンにマッチしたときに、そのパターンに対する記述した変換処理が呼ばれて全体の変換が進んでいきます。上記の例ではコード中に現れるリテラル値は Literal で識別でき、それを Hello World! という文字列リテラルで置き換えています。最後に path.skip() を呼んでいるのは、変換後の文字列リテラルが再度変換処理の対象にならないようにするためです。実行してみると以下のようになり見事に Hello World! だらけになります。

$ cat test.js 
const v = 'hoge'
  + `fu
ga`
  + JSON.stringify({ id: 1, 'some-key': 0xCAFEBABE })
$ yarn -s babel test.js 
const v = "Hello World!" + "Hello World!" + JSON.stringify({
  id: "Hello World!",
  "Hello World!": "Hello World!"
});

「プロファイラ」で何をするのか

Babel プラグインを作成するにあたり必要なファイルは 2 つ、上記の例程度であれば記述内容はいたってお手軽です。ただし、実際いろいろな変換処理を考えるには JavaScript の言語仕様・変換前後の AST がどのような構造になっているかを把握しなければならず一気に難易度が上がります。そこで、Babel プラグインを利用したプロファイラの作成を通してもう少し実用的な AST 変換の例を紹介します。*2

JavaScript の域は出ないので、ここでは「関数の処理時間」を計測する処理を Babel プラグインを用いて挿入することで処理時間のプロファイリングをできるようにします。処理時間を計測するにあたり、単純に思い付くのは関数の先頭で時間を取得、関数が終わる間際に再度時間を取得しこれらの差分を計算し記録する方法です。以下のようなイメージです。

function add(a, b) {
  return a + b
}

を以下のように変換します。

function add(a, b) {
  var obj = __BPTP.enter("test.js", "add")  // 処理開始ファイル名、関数名、処理開始時刻を記録したオブジェクト作成
  var ret = a + b
  __BPTP.exit(obj)                          // 処理終了時刻を取得し処理時間を記録する
  return ret
}

どこに時間がかかっているか特定できるようにファイル名、関数名、処理時間を逐次記録していきます。Babel プラグイン内では入力されたファイル名やその元コードを参照できるようになっており、それを変換で使うことができます。

return がある関数の場合、末尾に処理を挿入するだけではなく return の直前に処理を挿入しなければなりません。さらに return の右側には処理が続く可能性があるため、処理時間の記録を行う前に計算を済ませておき処理時間の記録が終わったあと return させる必要があります。加えて、return は関数中に複数回出現する可能性がありそのすべてでこの対応が必要です。

また、if の内側に return が存在する場合、たとえば以下のように単純に処理を挿入してしまうと元コードの処理が壊れてしまう可能性があります。

function hoge() {
  ...
  if (a >= 0)
    return false
  ...
}

function hoge() {
  var obj = __BPTP.enter("test.js", "hoge")
  ...
  if (a >= 0)
    __BPTP.exit(obj)
    return false
  ...
}

とすると return するタイミングが変わってしまいます。そこで、 return の親(if で分岐する処理全体を表す部分)がブロック({ ... })になっていない場合はブロックを追加の上、計測処理を挿入する必要があります。

function hoge() {
  var obj = BPTP.enter("test.js", "hoge")
  ...
  if (a >= 0) {  // ブロックを追加する
    BPTP.exit(obj)
    return false
  }  // ブロックを追加する
  ...
}

ここまで把握できたらあとはがんばって AST 変換処理を記述していくだけです。 実際に作成したものをこちらに置いておきます: github.com

まず、AST が traverse される際に関数が処理対象となるようにします。

module.exports = function(babel) {
  const lines = []
  return {
    visitor: {
      Program: {
        ...
      },
      Function(path, state) {
        if (!hasEnoughLines(path, state)) {
          return
        }
        insertTrace(path, state, lines)
      }
    }
  }
}

https://github.com/ihiroky/babel-plugin-time-profiler/blob/57c391500bd010a4b608df557d81fa5f0d596a0e/index.js#L304

Function を記述することで関数を処理対象にします。また、Program(AST が入ってきたときに一番始めにマッチする処理)では初期処理、計測データを処理するコードの追加を行っています。計測データを処理するコードまで AST で書き出すと非常に冗長になるため、 @babel/template を用いて AST を生成しています。 state という変数には元コードの情報や Babel プラグインのオプションが格納されているので随時参照してきます。

余談気味ですがどうせなら TypeScript でプラグインを作れるのかなとちょっと調べてみました。が、まともに index.d.ts があるのが @babel/types のみ、@types にそのほかの Babel パッケージに対する型定義はあるのですがひとつ整合性が取れないような気がして見送りました。それでも @babel/types の型定義があることで vscode 上で補完が効くようになる様子なのでだいぶやりやすいです。この補完を使えるようにするために、@babel/types を require して処理を記述します。@babel/types を require しない場合は export する関数の引数に渡されるオブジェクトのプロパティに @babel/types のオブジェクト(Hello World で出てきた { types: t })があるのでこれを利用します。

関数を AST として参照する準備ができたので計測処理を挿入する部分を記述していきます。

function insertTrace(path, state, lines) {
  // 関数名の特定
  const name =
    path.node.id ? path.node.id.name :
    path.node.key ? path.node.key.name :
    `anonymous_${path.node.loc.start.line}_${path.node.loc.start.column}`

  if (types.isBlockStatement(path.node.body)) {
    // 関数にブロックが存在する(アロー関数の短縮形でない)場合

    // 計測開始処理を挿入する
    const bptpObj = insertEnter(path, state, name, lines)

    // 関数内の return 文を探す
    path.traverse(returnVisitor, { bptpObj })

    // Insert exit funtion if no return statement in the tail.
    const bodyArray = path.node.body.body
    if (!types.isReturnStatement(bodyArray[bodyArray.length - 1])){
      insertExit(path, bptpObj)
    }
  } else {
    // 関数にブロックが存在しない(アロー関数の短縮形である)場合はブロックを挿入し元処理の前後に計測処理を追加
    insertTraceForArrowFunctionOmittingForm(path, state, name, lines)
  }
}

https://github.com/ihiroky/babel-plugin-time-profiler/blob/57c391500bd010a4b608df557d81fa5f0d596a0e/index.js#L254

始めに関数名を取得します。匿名関数の場合はそもそも関数名がないため、anonymous_行数_カラム数 という名前にしています。これだけではどの関数なのかわかりにくいので、結果出力時には該当コード部分を 1 行表示するようにして視認性を上げています。

そして関数にブロック(function hoge() { ... } の { ... } 部分)が存在する場合としない場合で処理を分けます。関数なのにブロックがない…?と思われるかもしれませんが、アロー関数の短縮形(ex. (a, b) => a + b)が該当します。ブロックがある場合は計測開始処理を挿入した後、関数中の return 文を探索し計測終了処理を挿入します。また、関数の最後が return 文でない場合は最後に計測終了処理を追加する必要があります。

なお、関数が途中で終わるケースに throw で終わるものがあります。ここでは無視することで記録しないこととします。

あとはひたすら AST を生成しては挿入する処理を繰り返していきます。肝心の AST 変換処理ではありますが記述が冗長、かつ見たままなので掲載は省略します。やっていることは上で例示したような var obj = BPTP.enter("test.js", "add")(実際には行・カラム数や元コードも参照)や BPTP.exit(obj) の挿入です。アロー関数の短縮形に対する処理では if (...) return ... のケースと同様にブロックの追加も行っています。興味がある方は Github上のコード をご覧ください。

このプロファイラは Babel プラグインのみで実現しているため、たとえば複数モジュールからなるアプリケーションに対して利用した場合プロファイラを管理するためのオブジェクト生成ロジックが随所に出現します。つまり、とても無駄が多いコードが生成されます。これを回避したい場合、 TypeScript に対する tslib と同じように計測データを管理するオブジェクトをモジュールとして切り出しアプリケーションに別途 import してもらうようにする必要があると考えています。

また、Bundler から Babel を呼び出す場合に path.node.loc に有効な値が設定されていないことがあります。*3 これに対応したい場合は path.node.loc の存在チェックを適宜追加する必要があります。

まとめ

この記事では Babel の概要・プラグインの作成方法から、処理時間を計測するプロファイラを題材に Babel プラグインを作成しその処理概要を説明しました。

Babel は ECMAScript の最新仕様をプロダクト開発に用いるため・適宜 Polyfill 使うために用いることが多いです。それだけではなく、プロダクトコード中に現れる定型的な処理が随所の現れる際に都度その処理を追記することなくビルド時に一括で処理を追加できます。テックタッチのプロダクト開発でもこのような定型的な処理を記述するために Babel を用いています。

Babel プラグイン開発に参考になったサイト

github.com

Babel 本家から複数リンクがはられている、Babel プラグインの説明を行っているサイトです。プラグイン開発に必要なことの大半はここに書いてあるぐらいに充実しています。

astexplorer.net

入力したコードに対応する AST を閲覧できるサイト。JavaScript の仕様を理解して AST 変換を考えるというよりも変換後の AST を眺めて足りない部分を変化させる処理を書くつもりでいたほうがサクサク開発できます。ということでプラグイン開発には必須なサイトです。

babeljs.io

AST の生成や判定を行う @babel/types の API 一覧。AST Explorer で洗い出した必要な処理を実際にコードに落とすために必要です。よくみると TypeScript に対する API も存在している…。

babeljs.io

Babel のドキュメント(↑の @babel/type も含んでいる)。とっつきづらい部分はありますが必要なことは書いてあるか、たどれるようになっています。

おまけ

こんな感じでプロファイリングできます: プロファイラを使っている様子

npm リポジトリにも公開しておきました。よかったら触って遊んでみてください: www.npmjs.com

*1:Plugin/Preset Paths

*2:プロファイラを作ってみようかと思ったのはこの記事がきっかけです:https://itchyny.hatenablog.com/entry/2015/07/01/120000

*3:bundler が追加したコードに対して発生している様子。rollup.jsで確認