babel-plugin-macrosというものの存在を知って、実際にマクロを書いてみました。
babel-plugin-macrosとは
すでにQiitaへ記事を書いている方がいるので軽く触れる程度にしますが、BabelでJavaScriptを変換する際に、importした名前を使った特定の場所だけ特別な変換を入れられるようにする、という仕組みです。
ふつうにBabelのプラグインにするのと比べて、以下のようなメリット・デメリットがあります。
- メリット
-
importした名前と紐づくので、不意にあちこちで変換が適用されてしまうことがない -
babel.config.jsにbabel-plugin-macrosを入れておけば、あとはimportで適用できるので、特殊な変換を自作する場合にも、そのたびごとにBabelの設定を変更する必要がない - デメリット
- ASTレベルでの変換なので、JavaScriptの文法から外れた入出力を行うことはできない
-
importして使う必要があるので、全コードへ一律に適用したい変換には向かない - 依存関係の制御ができない(他のファイルや時間など、外部データに依存した変換を行う場合、外部データが変化してもキャッシュが残り続けてしまう)
マクロはどのように作るのか
実際にマクロを書き始める前に、マクロ内部での処理はどのように進むのかをまとめておきます。
まず、Babel内部では、JavaScriptコードはASTという形式で管理されていて、ちょうどHTMLをDOMにしたのと同じように、各コード片(ノード)がJavaScriptのオブジェクトとなっていて、ツリーをたどったり、操作したりといったことができます。AST Explorerのような、JavaScriptコードがどのようなASTになるかを調べられるツールもあります(ツールによって微妙にノードの種類などが違いますので、Babelの参考にする場合は@babel/parserを選んでおきましょう)。
import { foo } from 'some.macro'のようにマクロを読み込んだソースコードでfooを書くと、マクロコードにはfooのノードが渡されます。そこからASTをたどって改変を行う、という流れです(ソースコード全体のASTにアクセスできますので、その必要があればfooと離れた箇所に手を入れることも可能です)。
簡単なものを書いてみる
例として、import test from 'test.macro'のように読み込んで、test('a')とすれば、コンパイル後のコードではaの文字コードである97になっている、というようなマクロを書くことにします。
基本的なルール
マクロとして動かすファイルは、以下のようなルールで作る必要があります。
-
.macroあるいは/macroで終わる名前で参照できるようにする -
requireとmodule.exportsを使うCommon.jsで書く - マクロ処理は同期的に実行する
ということで、基本の枠は以下のようになります。
const { createMacro } = require('babel-plugin-macros');
module.exports = createMacro((params) => {});
マクロ関数の引数
createMacroの引数となった関数には、以下のようなキーを含むオブジェクト1つが引数として渡されます。
-
babel…Babelの各種ライブラリ。babel.type.***が適切な型のASTノードを作るのに必要となります。 -
references…これ自体もオブジェクトとなっていて、キーはこのマクロをimportした名前(デフォルトインポートならdefault)、値はソースに出現したノードの配列です。 -
state…Babelの処理状態を反映したオブジェクト。細かい位置まで正確なエラーを出したい場合は、stateを利用することになります(詳細略)。 -
config…設定オブジェクト(詳細略)
referencesに来る配列をforEachで回しつつ、適切なノードに置き換えていく、というのが基本的な流れとなります。
実装例
今回はdefault importなので、references.default.forEachでループを回していきます。そして、関数名からparentPathで1階層上がれば、それはCallExpressionのはずなのでそれを確認してから、引数を回収して処理を加え、書き出しておきます。
const { createMacro, MacroError } = require('babel-plugin-macros');
module.exports = createMacro((params) => {
const {babel, references} = params;
references.default.forEach(node => {
const call = node.parentPath;
if(call.type !== 'CallExpression') throw new MacroError('エラーメッセージ');
const rawValue = call.node.arguments[0].value;
call.replaceWith(babel.type.numericLiteral(rawValue.charCodeAt(0)));
});
});
エラー処理
上の例でも関数呼び出し以外で来たときはエラーを出していますが、積極的に詳細なエラーを出すことをおすすめします。具体的には、
-
importした名前が適当でなかった場合 - 想定した形以外のノードとしてASTに現れた場合
- 関数形で、引数が想定したものでなかった場合
などが考えられます。特に、関数の引数がリテラルでなかった場合には、「あくまでコンパイル時専用の関数として、リテラル以外を投げた場合はエラーにする」方法や、逆に「リテラルのときはコンパイル時に置き換えるけど、そうでない式であれば、実行時処理のコードを書き出しておく」など、いくつかの方策が考えられます。
簡便にはMacroErrorを投げる方法がありますが、stateをたどって詳細な位置まで含めたエラーを出すことも可能です。