宏是在构建时运行的 JavaScript 函数。宏返回的值将内联到捆绑包中,替换原始函数调用。这允许您在不使用自定义插件的情况下生成常量、代码甚至额外的资源。

使用导入属性导入宏,以指示它们应在构建时运行,而不是被打包到输出中。您可以导入任何 JavaScript 或 TypeScript 模块作为宏,包括内置的 Node 模块和 npm 包。

note:出于安全原因,不能从 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() // 错误:无法静态求值宏参数
});

常量

#

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'; // 错误:无法静态求值宏参数

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 模块的依赖项。例如,宏可以生成 CSS,这些 CSS 将被静态提取到 CSS 捆绑包中,就像它是从 JS 文件导入的一样。

在宏函数中,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 {
/** 每当给定文件发生变化时使宏调用失效。 */
invalidateOnFileChange(filePath: string): void;
/** 当创建匹配给定模式的文件时使宏调用失效。 */
invalidateOnFileCreate(options: FileCreateInvalidation): void;
/** 每当给定的环境变量发生变化时使宏失效。 */
invalidateOnEnvChange(env: string): void;
/** 每当 Parcel 重新启动时使宏失效。 */
invalidateOnStartup(): void;
/** 在每次构建时使宏失效。 */
invalidateOnBuild(): void;
}

type FileCreateInvalidation =
| FileInvalidation
| GlobInvalidation
| FileAboveInvalidation;

/** 当创建匹配 glob 的文件时使其失效。 */
interface GlobInvalidation {
glob: string;
}

/** 当创建特定文件时使其失效。 */
interface FileInvalidation {
filePath: string;
}

/** 当在层次结构中某个目录上方创建特定名称的文件时使其失效。 */
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

#

从 5.3 版本开始,TypeScript 原生支持导入属性,并且宏的自动完成和类型与常规函数一样工作。

Babel

#

@babel/plugin-syntax-import-attributes 插件使 Babel 能够解析导入属性。如果您使用 @babel/preset-env,启用 shippedProposals 选项也会启用导入属性的解析。

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

ESLint

#

当使用支持导入属性的解析器(如 Babel 或 TypeScript)时,ESLint 支持导入属性。

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

单元测试

#

单元测试宏就像测试任何其他 JavaScript 函数一样。一个注意事项是如果您的宏使用了上面部分描述的 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 的区别

#

通过导入属性的宏最初是在 Bun 中实现的。Parcel 的实现在很大程度上与 Bun 的宏 API 兼容,但存在一些差异: