宏是一种在打包时运行 JavaScript 函数的机制。这些函数返回的值会被直接内联到你的打包文件中。
作为一个示例,考虑这个返回随机数的简单函数。

random.ts
export function random() {
return Math.random();
}
这只是一个普通文件中的常规函数,但我们可以这样将它用作宏:

cli.tsx
import { random } from "./random.ts" with { type: "macro" };
console.log(`你的随机数是 ${random()}`);
宏通过导入属性语法来标识。如果你之前没见过这种语法,它是 Stage 3 TC39 提案,
允许你给导入语句附加额外的元数据。
现在我们用 bun build 来打包这个文件。打包后的文件将打印到标准输出。
console.log(`你的随机数是 ${0.6805550949689833}`);
如你所见,random 函数的源码并没有出现在打包文件中。相反,它在打包时执行,函数调用(random())被替换为函数的返回结果。由于源码永远不会被包含在打包文件里,宏可以安全地执行诸如读取数据库之类的特权操作。
何时使用宏
如果你有多个为小任务写的一次性构建脚本,打包时执行代码会更易维护。宏与其它代码共存,和构建过程同步运行,自动并行化,如果失败,构建也会失败。
不过,如果你发现自己在打包时运行大量代码,考虑改为运行服务器。
导入属性
Bun 宏的导入语句使用以下方式注释:
with { type: 'macro' } — 一种导入属性,Stage 3 ECMA Script 提案
assert { type: 'macro' } — 一种导入断言,导入属性的早期形式,现已废弃(但已被多个浏览器和运行时支持)
安全注意事项
宏必须显式用 { type: "macro" } 导入,才会在打包时执行。如果不调用这些宏导入,它们不会有任何效果,而普通的 JavaScript 导入可能会有副作用。
可以通过向 Bun 传递 --no-macros 参数来完全禁用宏,这会产生如下构建错误:
error: Macros are disabled
foo();
^
./hello.js:3:1 53
为减少恶意包的潜在攻击面,宏不能从 node_modules/**/* 内部调用。如果包试图调用宏,会看到如下错误:
error: For security reasons, macros cannot be run from node_modules.
beEvil();
^
node_modules/evil/index.js:3:1 50
你的应用代码仍然可以从 node_modules 引入宏并调用它们。

cli.tsx
import { macro } from "some-package" with { type: "macro" };
macro();
导出条件 “macro”
当向 npm 或其它包注册中心发布包含宏的库时,使用 "macro" 导出条件为宏环境专门提供一个版本。
{
"name": "my-package",
"exports": {
"import": "./index.js",
"require": "./index.js",
"default": "./index.js",
"macro": "./index.macro.js"
}
}
这个配置允许用户使用相同的导入标识符在运行时或打包时消费你的包:

index.ts
import pkg from "my-package"; // 运行时导入
import { macro } from "my-package" with { type: "macro" }; // 宏导入
第一个导入会解析到 ./node_modules/my-package/index.js,第二个导入会被 Bun 的打包器解析为 ./node_modules/my-package/index.macro.js。
当 Bun 的转译器遇到宏导入时,会使用 Bun 的 JavaScript 运行时在转译器内调用该函数,并将 JavaScript 返回值转换为 AST 节点。这些函数是在打包时调用,而非运行时。
宏在转译器的访问阶段同步执行——插件之前,生成 AST 之前。它们按导入顺序执行。转译器会等待宏执行完才继续。如果宏返回 Promise,转译器也会等待它完成。
Bun 的打包器是多线程的,因此宏会在多个 JavaScript “工作线程”中并行执行。
死代码消除
宏执行和内联后,打包器会执行死代码消除。举例,下面的宏:

returnFalse.ts
export function returnFalse() {
return false;
}
打包如下代码(启用语法压缩选项)会得到一个空包:

index.ts
import { returnFalse } from "./returnFalse.ts" with { type: "macro" };
if (returnFalse()) {
console.log("这段代码会被消除");
}
可序列化性
Bun 的转译器需要能序列化宏的返回值,以便将其内联到 AST 中。所有兼容 JSON 的数据结构都支持:

macro.ts
export function getObject() {
return {
foo: "bar",
baz: 123,
array: [1, 2, { nested: "value" }],
};
}
宏可以是异步的,或返回 Promise 实例。Bun 的转译器会自动等待 Promise 并内联结果。

macro.ts
export async function getText() {
return "异步值";
}
转译器为常见数据格式如 Response、Blob、TypedArray 实现了特殊序列化逻辑。
- TypedArray:解析为 base64 编码字符串。
- Response:Bun 会读取
Content-Type 并据此序列化;比如 application/json 类型会自动解析为对象,text/plain 则内联为字符串。未知或未定义类型的 Response 会转为 base64 编码。
- Blob:和 Response 一样,序列化依赖其
type 属性。
fetch 返回的是 Promise<Response>,可以直接返回。

macro.ts
export function getObject() {
return fetch("https://bun.com");
}
函数和大多数类的实例(除上述类)不可序列化。

macro.ts
export function getText(url: string) {
// 这行不行!
return () => {};
}
宏可以接受输入,但仅限于有限情况。参数值必须是静态已知的。例如,以下用法不被允许:

index.ts
import { getText } from "./getText.ts" with { type: "macro" };
export function howLong() {
// `foo` 的值不能被静态获知
const foo = Math.random() ? "foo" : "bar";
const text = getText(`https://example.com/${foo}`);
console.log("页面长度为 ", text.length, " 字符");
}
如果 foo 的值在打包时已知(比如是常量或另一个宏的结果),则允许如下用法:

index.ts
import { getText } from "./getText.ts" with { type: "macro" };
import { getFoo } from "./getFoo.ts" with { type: "macro" };
export function howLong() {
// 这里有效,因为 getFoo() 是静态已知的
const foo = getFoo();
const text = getText(`https://example.com/${foo}`);
console.log("页面长度为", text.length, "字符");
}
其输出为:
function howLong() {
console.log("页面长度为", 1322, "字符");
}
export { howLong };
嵌入最新 git 提交哈希

getGitCommitHash.ts
export function getGitCommitHash() {
const { stdout } = Bun.spawnSync({
cmd: ["git", "rev-parse", "HEAD"],
stdout: "pipe",
});
return stdout.toString();
}
构建时,getGitCommitHash 被调用结果替换:
import { getGitCommitHash } from "./getGitCommitHash.ts" with { type: "macro" };
console.log(`当前 Git 提交哈希是 ${getGitCommitHash()}`);
你可能在想 “为什么不直接用 process.env.GIT_COMMIT_HASH?”可以用,但你能用环境变量做下面这个吗?
在打包时执行 fetch() 请求
这个示例中,我们使用 fetch() 进行 HTTP 请求,用 HTMLRewriter 解析 HTML 响应,并返回包含标题和 meta 标签的对象——这一切都在打包时完成。

meta.ts
export async function extractMetaTags(url: string) {
const response = await fetch(url);
const meta = {
title: "",
};
new HTMLRewriter()
.on("title", {
text(element) {
meta.title += element.text;
},
})
.on("meta", {
element(element) {
const name =
element.getAttribute("name") || element.getAttribute("property") || element.getAttribute("itemprop");
if (name) meta[name] = element.getAttribute("content");
},
})
.transform(response);
return meta;
}
extractMetaTags 函数在打包时被函数调用结果替换。这意味着 fetch 请求发生在打包时,结果被内嵌到包里。由于不可达,抛出错误的分支也被消除。
import { extractMetaTags } from "./meta.ts" with { type: "macro" };
export const Head = () => {
const headTags = extractMetaTags("https://example.com");
if (headTags.title !== "Example Domain") {
throw new Error("预期标题应为 'Example Domain'");
}
return (
<head>
<title>{headTags.title}</title>
<meta name="viewport" content={headTags.viewport} />
</head>
);
};