babelプラグインの作り方・活用方法

Pocket

さくらインターネット Advent Calendar 2017の13日目の記事です。

さくらインターネットでは、sakura.ioのコントロールパネルを始めとした、様々な管理画面が存在しています。状況に合わせた最適解を選んでいるので、様々な設計やフレームワークの利用などが行われています。その中で、最近社内でもよく使われるようになった babel について、掘り下げて調べてみました。

この記事はbabel7を参照しています。記事を書いている時点では、betaなので正式リリースの際には変更される点が有るかもしれません。

目次

  • babelの簡単なおさらい
    • パッケージ構成
    • 処理のフローの理解
  • babelプラグインの作成
    • 簡単なプラグインを作成
    • テストを実行
  • babelプラグインを使ってコード自動生成を行う
    • s2sの紹介

パッケージ構成

Babelは、JavaScriptで書かれたJavaScriptのコンパイラーです。
非常にコード量の多いプロダクトになっており、以下のようにたくさんの小さいパッケージで構成されています。

インタフェース

  • @babel/core: .babelrc 等の設定を読み込み、ソースコードに対してプラグインの適用を順次行う
  • @babel/polyfill: ブラウザなどのランタイムにて、 core-js regenerator による標準ライブラリ等の補完を行う
  • @babel/register: require をフックし、コンパイルされたファイルを返す
  • @babel/cli: コマンドラインからコンパイルするためのコマンド

@babel/plugin-*

  • Transform Plugins
    • コード→コード (AST→AST) の変換を行う
    • ex: @babel/plugin-transform-arrow-functions
  • Syntax Plugins
    • babylon内に実装された、構文解析に使用するプラグインを設定するパッケージ
    • 現時点で構文解析を拡張することは出来ない
    • ex: @babel/plugin-syntax-jsx

※AST: コンパイラが扱う構文木 Abstract Syntax Tree

@babel/preset-*

各種構文をサポートするためのプラグインを一括適用するパッケージです。
1つのパッケージを適用するだけで、複数のパッケージが導入されます。

  • @babel/preset-es2015
    • @babel/plugin-transform-arrow-functions: () => {}function () {}
    • @babel/plugin-transform-spread: [...a, 'foo'];[].concat(a, [ 'foo' ]);
    • etc…
  • @babel/preset-react
    • @babel/plugin-syntax-jsx: <JSX /> のようなコードをパースして、Babelが扱えるようにする
    • @babel/plugin-transform-react-jsx: <span>hoge</span>React.createElement('span', null, 'hoge')
  • @babel/preset-env: ターゲットブラウザ・ランタイムに対応したコードを生成するためのプラグインを自動選択

内部パッケージ

今まで紹介を行ったパッケージの内部で呼ばれるものです。
プラグインを自作する際にも使用します。

  • @babel/babylon
    • 構文解析を行う
    • デフォルトでES2017構文が有効
    • JSX, Flow, Typescriptにも対応
  • @babel/types
    • ASTのジェネレーター・バリデーター
    • プラグインなどでASTを組み立てる時に使う
  • @babel/template
    • ASTのテンプレートを作成可能
    • 文字列で書いたコード内のプレスホルダにASTを差し込める
  • @babel/traverse
    • ASTを辿り、ノードの置換・追加・削除を行う
  • @babel/generator
    • ASTをコードに変換する
  • @babel/code-frame
    • エラーをソースの場所とともに表示する

いままでのあらすじ

ここまでで紹介したパッケージの流れをざっくり図にすると、このようになっています。

ちなみに現時点でまだbetaですが、babel 7は以下のような変更が行われています。

  • Scoped Package化 babel-core@babel/core
  • TypeScriptの標準対応が入る
  • flowで書き直しているっぽい
  • 新しい構文への対応も一緒に追加される予定
    • Optional Chaining: a?.b = 42;
    • BigInt: 50000n + 60n;

Babelプラグインを作る

ここではBabelのTransform Pluginsを作成しようと思います。
処理としては、babylonで解析されたASTを編集し返すみです。
babelプラグインを作るための手順は以下のとおりです。

  1. IN/OUTの形式をコードを定義する
  2. IN/OUTのASTを読む
  3. ASTを読み込んで、OUTの形式のASTを構築するコードを書く

順に追って説明します。

今回紹介する内容は、 https://github.com/kamijin-fanta/babel-example-2017 でコードを公開しています。

IN/OUTの形式をコードを定義する

ここでは、 hoge という識別子を fuga に置き換えるプラグインを作ります。
期待されるコードは以下のとおりです。

IN:

console.log(hoge)

OUT:

console.log(fuga)

ASTを読む

上で定義したASTをそれぞれ読んでみましょう。
ASTを読むには https://astexplorer.net/ が便利です。
ここでは、言語にJavaScriptを選択し、パーサーにbabylon7を選択します。

IN側のコードを入力しました。
console.log(hoge)CallExpression として認識され、呼び出す関数は callee 配列は arguments として格納されています。

callee 側の console.log は、 MemberExpression として定義されます。 object に格納された Identifierproperty に格納された Identifier でコードを表しています。

引数である (hoge)arguments 配列の中に Identifier として定義されています。

プラグインを書く

まず、何もしない最小のプラグインを作ってみます。

$ npm i @babel/core @babel/babel-cli
$ mkdir src
$ touch src/index.js
$ cat '{ "plugins": ["./index.js"] }' > .babelrc

index.jsを編集し、以下の内容にします。

// index.js
module.exports = function({ types: t }) {
  return {
    name: 'babel-example-plugin',
    visitor: {
      Identifier(path) {
        console.log('path: ', path.node)
      }
    }
  };
};

babelを実行し、標準出力に出力される結果を読んでみましょう。

$ node_modules/.bin/babel input.js
path:  Node {
  type: 'Identifier',
  start: 0,
  end: 7,
  loc: ~~~略~~~,
  name: 'console' }
path:  Node {
  type: 'Identifier',
  start: 8,
  end: 11,
  loc: ~~~略~~~,
  name: 'log' }
path:  Node {
  type: 'Identifier',
  start: 12,
  end: 16,
  loc: ~~~略~~~,
  name: 'hoge' }

プラグインで定義している visitor オブジェクトに対して、ノードのTypeに対してのマッチングを書いていきます。再帰的に探索を行い、マッチしたvisitorを呼び出します。Type名のメソッドを定義すると、親ノード・自ノードの情報等が含まれるパスが引数に渡されます。

上の例では、 console log hoge という3つのIdentifierがマッチしました。

次に、ノードの一部を書き換えてみましょう。
path オブジェクトの node プロパティが、現在のASTを表しています。下の例では、Identifierタイプでマッチしたノードのパスがpath引数に渡されます。Identifierは、属性としてnameを持ちます。その属性を書き換えてみましょう。

// index.js
module.exports = function({ types: t }) {
  return {
    name: 'babel-example-plugin',
    visitor: {
      Identifier(path) {
        if (path.node.name === 'hoge') {
          path.node.name = 'fuga';
        }
      }
    }
  };
};

このコードの出力結果はこうなります。

console.log(fuga);

テストを行う

テストは、 jestbabel-plugin-tester を使うのが使いやすそうなので、試してみます。

単純なテスト

先程はCLIから簡単にプラグインを扱うため、 .babelrc に設定しましたが、babel-plugin-testerを使うことで不要になるので、一緒に設定を書き換えます。

$ npm i jset babel-jest babel-plugin-tester @babel/preset-env
// .babelrc
{
  "presets": [
    ["@babel/preset-env", {
      "targets": {
        "node": "6.10"
      }
    }]
  ]
}

次にテストコードを書きます。codeが入力コードで、outputが出力コードです。

// src/index.test.js

import plugin from './index';
import pluginTester from 'babel-plugin-tester';

pluginTester({
  plugin,
  tests: [
    {
      title: 'example',
      code: `console.log(hoge);`,
      output: `console.log(fuga);`,
    },
  ],
});
$ node_modules/.bin/jest

 PASS  src\index.test.js
  babel-example-plugin
    √ example (6ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.603s, estimated 1s
Ran all test suites related to changed files.

スナップショットテスト

jestには、入出力の値の変化をテストするためのスナップショットテストの機能が有ります。 https://facebook.github.io/jest/docs/en/snapshot-testing.html

babel-plugin-testerはjestのスナップショットテストに対応しているため、非常に簡単にこの機能を使うことが出来ます。テストコードを書き換えて試します。 snapshot: true を設定し、outputを指定しないのがポイントです。

// src/index.test.js

import plugin from './index';
import pluginTester from 'babel-plugin-tester';

pluginTester({
  plugin,
  snapshot: true,
  tests: [
    {
      title: 'snapshot test',
      code: `console.log(hoge);`,
    },
  ],
});

テストを動かすと、 __snapshots__/index.test.js.snap ファイルが生成されます。次回このファイルと異なる値が出力された際は、エラーとなります。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`snapshot test 1`] = `
"
console.log(hoge);

      ↓ ↓ ↓ ↓ ↓ ↓

console.log(fuga);
"
`;

こうして、スナップショットをとっておくことで、プラグインの変更を手軽に検知できるようになります。

s2sの紹介

Babalプラグインを作ることで、Babelの機能などが理解できたかと思います。基本的に行っていることがコード→コードということがよく理解できたかなと思います。そのコードからコードを生成するBabelPluginの性質に着目したのが、s2sです。

https://github.com/akameco/s2s

s2sと適切なBabelPluginを組み合わせると、Redux等で大量に必要となるボイラープレートの削減を行うことができます。

基本的な動作は、このような流れです。エディタでファイルを保存すると、即時にコードが自動生成され、追記されたように見えます。

  1. ファイルが保存されたタイミングでハンドラ(babel-plugin)が起動
  2. babel-pluginはASTを受け取り、編集を行って返す
  3. 結果の文字列でファイルを上書きする

例えば、FlowでのReduxアプリケーションで使用する babel-plugin-s2s-action-types は、以下のような変換を行います。

In:

export type Action = Increment

Out:

// @flow
export const INCREMENT: "app/counter/INCREMENT" = "app/counter/INCREMENT";

export const Actions = {
  INCREMENT
};

export type Increment = {
  type: typeof INCREMENT
};

export type Action = Increment;

タイプ数が半分以下になっている事がお分かりいただけると思います。ただ、内部でやっていることは基本的に、ファイルのwatch・BabelPluginを使用したtransform・ファイルの書き込みの3点です。なので、ユーザが自由に拡張することが出来ます。

私も、babylon7がTypeScriptに対応していたので、上のプラグインのTS版のプラグインを作ってみました。非常に簡単に実装を行うことが出来ました。

https://github.com/kamijin-fanta/babel-plugins/tree/master/packages/babel-plugin-s2s-action-types-ts

まとめ

  • babel怖くない
    • babelのプラグインはASTを理解すると作れる
  • AST怖くない
    • astexplorer等、ASTの理解を助けるツールが存在する

冬休みはカスタムのBabelPluginを作ってみてはいかがでしょうか。

資料

  • https://github.com/thejameskyle/babel-handbook/blob/master/translations/en/plugin-handbook.md
    • もう少し掘り下げてプラグインを作る方法がかかれている
  • https://github.com/kamijin-fanta/babel-example-2017
    • 今回のサンプルプログラムを置いたリポジトリ
Pocket

Close Menu