マクロ

マクロは、ビルド時に実行される JavaScript 関数です。マクロによって返される値は、元の関数呼び出しの代わりにバンドルにインライン化されます。これにより、カスタムプラグインなしで、定数、コード、さらには追加のアセットを生成できます。

マクロは、出力にバンドルされるのではなくビルド時に実行されることを示すために、import 属性を使用してインポートされます。組み込みの Node モジュールや npm のパッケージなど、任意の JavaScript または TypeScript モジュールをマクロとしてインポートできます。

: セキュリティ上の理由から、node_modules 内からマクロを呼び出すことはできません。

この例では、regexgen ライブラリを使用して、ビルド時に文字列のセットから最適化された正規表現を生成しています。

import regexgen from 'regexgen' with {type: 'macro'};

const regex = regexgen(['foobar', 'foobaz', 'foozap', 'fooza']);
console.log(regex);

これは、以下のバンドルにコンパイルされます。

console.log(/foo(?:zap?|ba[rz])/);

ご覧のとおり、regexgen ライブラリは完全にコンパイルされ、静的な正規表現が残っています。

引数

#

マクロの引数は静的に評価されます。つまり、それらの値はビルド時に既知である必要があります。文字列、数値、ブール値、オブジェクトなど、任意の JavaScript リテラル値を渡すことができます。文字列連結、算術、比較演算子などの簡単な式もサポートされています。

import {myMacro} from './macro.ts' with {type: 'macro'};

const result = myMacro({
name: 'Devon'
});

ただし、非定数変数を参照したり、マクロ以外の関数を呼び出したりする値はサポートされていません。

import {myMacro} from './macro.ts' with {type: 'macro'};

const result = myMacro({
name: getName() // Error: Cannot statically evaluate macro argument
});

定数

#

Parcel は、const キーワードで宣言された定数も評価します。これらは、マクロ引数で参照できます。

import {myMacro} from './macro.ts' with {type: 'macro'};

const name = 'Devon';
const result = myMacro({name});

あるマクロの結果を別のマクロに渡すこともできます。

import {myMacro} from './macro.ts' with {type: 'macro'};
import {getName} from './name.ts' with {type: 'macro'};

const name = getName();
const result = myMacro({name});

ただし、定数の値を変更しようとすると、エラーが発生します。

import {myMacro} from './macro.ts' with {type: 'macro'};

const arg = {name: 'Devon'};
arg.name = 'Peter'; // Error: Cannot statically evaluate macro argument

const result = myMacro({name});

戻り値

#

マクロは、オブジェクト、文字列、ブール値、数値、さらには関数など、任意の JavaScript 値を返すことができます。これらは AST に変換され、コード内の元の関数呼び出しを置き換えます。

index.ts
import {getRandomNumber} from './macro.ts' with {type: 'macro'};

console.log(getRandomNumber());
macro.ts
export function getRandomNumber() {
return Math.random();
}

この例のバンドルされた出力は次のようになります。

console.log(0.006024956627355804);

非同期マクロ

#

マクロは、サポートされている任意の値に解決される Promise を返すこともできます。たとえば、ビルド時に HTTP リクエストを行って URL の内容を取得し、その結果を文字列としてバンドルにインライン化できます。

index.ts
import {fetchText} from './macro.ts' with {type: 'macro'};

console.log(fetchText('http://example.com'));
macro.ts
export async function fetchText(url: string) {
let res = await fetch(url);
return res.text();
}

関数の生成

#

マクロは関数を返すことができるため、ビルド時にコードを生成できます。文字列から動的に関数を生成するには、new Function コンストラクターを使用します。

この例では、micromatch ライブラリを使用して、ビルド時に glob マッチング関数をコンパイルしています。

index.ts
import {compileGlob} from './glob.ts' with {type: 'macro'};

const isMatch = compileGlob('foo/**/bar.js');
glob.ts
import micromatch from 'micromatch';

export function compileGlob(glob) {
let regex = micromatch.makeRe(glob);
return new Function('string', `return ${regex}.test(string)`);
}

この例のバンドルされた出力は次のようになります。

const isMatch = function(string) {
return /^(?:foo(?:\/(?!\.)(?:(?:(?!(?:^|\/)\.).)*?)\/|\/|$)bar\.js)$/.test(string);
};

アセットの生成

#

マクロは、それを呼び出した JavaScript モジュールの依存関係になる追加のアセットを生成できます。たとえば、マクロは、JS ファイルからインポートされたかのように、静的に CSS バンドルに抽出される CSS を生成できます。

マクロ関数内では、this は Parcel が提供するメソッドを持つオブジェクトです。アセットを作成するには、this.addAsset を呼び出して、タイプとコンテンツを指定します。

この例では、CSS の文字列を受け取り、生成されたクラス名を返します。CSS はアセットとして追加され、CSS ファイルにバンドルされ、JavaScript バンドルには生成されたクラス名が静的な文字列としてのみ含まれます。

index.ts
import {css} from './css.ts' with {type: 'macro'};

<div className={css('color: red; &:hover { color: green }')}>
Hello!
</div>
css.ts
import type {MacroContext} from '@parcel/macros';

export async function css(this: MacroContext | void, code: string) {
let className = hash(code);
code = `.${className} { ${code} }`;

this?.addAsset({
type: 'css',
content: code
});

return className;
}

上記の例のバンドルされた出力は次のようになります。

index.js
<div className="ax63jk4">
Hello!
</div>
index.css
.ax63jk4 {
color: red;
&:hover {
color: green;
}
}

キャッシュ

#

デフォルトでは、Parcel はマクロの結果を、それを呼び出すファイルが変更されるまでキャッシュします。ただし、場合によっては、マクロにキャッシュを無効にする必要のある他の入力がある場合があります。たとえば、ファイルを読み取ったり、環境変数にアクセスしたりする場合があります。マクロ関数内の this コンテキストには、キャッシュ動作を制御するメソッドが含まれています。

interface MacroContext {
/** Invalidate the macro call whenever the given file changes. */
invalidateOnFileChange(filePath: string): void,
/** Invalidate the macro call when a file matching the given pattern is created. */
invalidateOnFileCreate(options: FileCreateInvalidation): void,
/** Invalidate the macro whenever the given environment variable changes. */
invalidateOnEnvChange(env: string): void,
/** Invalidate the macro whenever Parcel restarts. */
invalidateOnStartup(): void,
/** Invalidate the macro on every build. */
invalidateOnBuild(): void,
}

type FileCreateInvalidation = FileInvalidation | GlobInvalidation | FileAboveInvalidation;

/** Invalidate when a file matching a glob is created. */
interface GlobInvalidation {
glob: string
}

/** Invalidate when a specific file is created. */
interface FileInvalidation {
filePath: string
}

/** Invalidate when a file of a specific name is created above a certain directory in the hierarchy. */
interface FileAboveInvalidation {
fileName: string,
aboveFilePath: string
}

たとえば、マクロでファイルを読み取る場合は、ファイルパスを無効化として追加して、そのファイルが変更されるたびに呼び出し元のコードが再コンパイルされるようにします。この例では、message.txt が編集されるたびに、index.ts が再コンパイルされ、readFile マクロが再度呼び出されます。

index.ts
import {readFile} from './macro.ts' with {type: 'macro'};

console.log(readFile('message.txt'))
macro.ts
import type {MacroContext} from '@parcel/macros';
import fs from 'fs';

export async function readFile(this: MacroContext | void, filePath: string) {
this?.invalidateOnFileChange(filePath);
return fs.readFileSync(filePath, 'utf8');
}
message.txt
hello world!

他のツールとの使用

#

マクロは通常の JavaScript 関数であるため、他のツールと簡単に統合できます。

TypeScript

#

TypeScript は、バージョン 5.3 から import 属性を標準でサポートしており、マクロのオートコンプリートと型は通常の関数と同じように機能します。

Babel

#

@babel/plugin-syntax-import-attributes プラグインを使用すると、Babel で import 属性を解析できます。@babel/preset-env を使用している場合は、shippedProposals オプションを有効にすると、import 属性の解析も有効になります。

babel.config.json
{
"presets": [
[
"@babel/preset-env",
{
"shippedProposals": true
}
]
]
}

ESLint

#

ESLint は、Babel や TypeScript などのサポートするパーサーを使用すると、import 属性をサポートします。

.eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser'
};

ユニットテスト

#

マクロのユニットテストは、他の JavaScript 関数をテストするのと同様です。注意すべき点の 1 つは、マクロが上記のセクションで説明した this コンテキストを使用する場合です。マクロ自体をテストしている場合は、this 引数をモックして、期待どおりに呼び出されていることを確認できます。

css.test.ts
import {css} from '../src/css.ts';

it('should generate css', () => {
let addAsset = jest.fn();
let className = css.call({
addAsset,
// ...
}, 'color: red');

expect(addAsset).toHaveBeenCalledWith({
type: 'css',
content: '.ax63jk4 { color: red }'
});
expect(className).toBe('ax63jk4');
});

マクロを間接的に使用するコードをテストする場合、マクロ関数は、コンパイル時に Parcel によってではなく、実行時に通常の関数として呼び出されます。この場合、通常 Parcel によって提供されるマクロコンテキストは利用できません。そのため、上記の例では this 引数が MacroContext | void として型指定されており、this が存在するかどうかを実行時にチェックしています。コンテキストが利用できない場合、this?.addAsset などのコンテキストを使用するコードは実行されませんが、関数は通常どおり値を返す必要があります。

Bun との違い

#

import 属性によるマクロは、もともと Bun で実装されました。Parcel の実装は、ほとんどの場合、Bun のマクロ API と互換性がありますが、いくつかの違いがあります。