Bun 宏(Macros)
本教程共 34 篇 · 第 8 篇 · 更新于 2026-08-06
本节目标:
- 理解 Bun 宏的本质——在打包/转译阶段执行的普通 JavaScript 函数,其返回值被内联进产物。
- 掌握宏的声明语法
import ... with { type: "macro" },以及它与普通导入的区别。- 搞清楚宏的三条硬约束:返回值必须可序列化、参数必须静态可知、
node_modules内不能调用宏。- 会用宏解决实际问题:嵌入 Git 提交哈希、构建期抓取远程数据、生成常量表。
- 知道如何关闭宏(
--no-macros)以及发布带宏的库时怎么用"macro"导出条件。
8.1 宏是什么
在大多数 JavaScript 工程里,“构建期做点事情”通常意味着写一个一次性的 build 脚本:读文件、拼字符串、生成一个 generated.ts,再让打包器去读它。这类脚本零散、难维护,和主构建流程也脱节——脚本挂了,构建可能还在继续跑。
Bun 宏(Macros)给出的答案是:让这段”构建期代码”就写在你的源码里,用普通函数的形式存在,只不过它的调用发生在打包阶段,而不是运行阶段。
一个最小的例子。先写一个再普通不过的函数:
// random.ts
export function random() {
return Math.random();
}
这就是一个普通模块里的普通函数,没有任何特殊装饰。真正让它变成”宏”的,是导入它的方式:
// cli.tsx
import { random } from "./random.ts" with { type: "macro" };
console.log(`Your random number is ${random()}`);
用 bun build 打包这个文件(不指定输出目录时,产物直接打印到标准输出):
bun build ./cli.tsx
得到的产物是:
console.log(`Your random number is ${0.6805550949689833}`);
请注意两件事:
random函数的源码在产物中完全消失了——它没有被打包进去。- 调用点
random()被替换成了这次调用的返回值。
这就是宏的全部核心机制:函数在打包时被 Bun 自己的 JavaScript 运行时调用一次,返回值被转换成 AST 节点内联到调用处,源码则被彻底擦除。
因为宏的源码永远不会进入最终产物,它可以安全地做一些”特权操作”——读本地文件、查数据库、调用只在构建机上可用的凭据——这些代码和敏感逻辑都不会泄漏到浏览器端产物里。
Note宏由 Bun 的转译器在打包阶段调用,运行环境就是 Bun 自身的 JavaScript 运行时。所以宏函数体里可以直接用
Bun.spawnSync、fetch、HTMLRewriter这些 Bun/Web API,不需要额外配置。
什么时候该用宏
官方给出的判断标准很朴素:如果这件事你原本会为它单独写一个小的 build 脚本,那它适合做成宏。
宏相比一次性脚本有几个实打实的好处:它和业务代码放在一起、跟着构建一起跑、由打包器自动并行执行、失败时构建也会跟着失败(不会出现”脚本静默失败但构建成功”的情况)。
反过来,如果你发现自己在打包阶段跑了大量代码,那说明这件事可能不该由宏承担——考虑改成一个真正的服务端接口。
8.2 语法:import attributes
宏的声明依赖 import attributes(导入属性) 语法,这是 TC39 的 Stage 3 提案,用于给 import 语句附加额外元信息:
import { myMacro } from "./macro.ts" with { type: "macro" };
Bun 同时兼容更早的 import assertions(导入断言) 写法。这套语法后来被 TC39 放弃了,但不少浏览器和运行时已经实现,因此 Bun 保留了支持:
import { myMacro } from "./macro.ts" assert { type: "macro" };
Tip新代码统一用
with { type: "macro" }。assert形式仅在需要兼容旧工具链时使用。
一个关键点:同一个模块既可以被当作普通模块导入,也可以被当作宏导入,取决于导入语句上有没有 with { type: "macro" }。宏和普通函数在定义侧没有任何区别,“是不是宏”是由使用方决定的。
8.3 执行机制与顺序
理解宏的行为,需要知道它在编译管线的哪个位置:
- 宏在转译器的 visiting 阶段同步执行,早于插件,也早于转译器生成最终 AST。
- 宏按导入顺序依次执行。
- 转译器会等待每个宏执行完成再继续;如果宏返回
Promise,转译器会await它并使用兑现值。 - Bun 的打包器是多线程的,因此宏会在多个派生出来的 JavaScript “worker” 里并行执行。
也就是说,宏既可以是同步函数,也可以是异步函数:
// macro.ts
export async function getText() {
return "async value";
}
打包后,getText() 的调用点会直接变成字符串字面量 "async value"。
死代码消除
打包器的死代码消除(Dead Code Elimination)发生在宏执行并内联之后。这个顺序非常重要,它让宏可以充当”编译期开关”。
看这个宏:
// returnFalse.ts
export function returnFalse() {
return false;
}
以及使用它的文件:
// index.ts
import { returnFalse } from "./returnFalse.ts" with { type: "macro" };
if (returnFalse()) {
console.log("This code is eliminated");
}
在开启 minify syntax 选项的前提下打包,产物是空的——returnFalse() 先被内联成 false,随后整个 if 分支被判定为不可达并删除。
这是宏最有价值的用法之一:用编译期计算出的布尔值裁剪掉整段代码,产物里连痕迹都不留。相比运行期的 if (process.env.FEATURE),宏能做到的是真正的物理删除。
8.4 两条硬约束
宏很强,但有两条绕不开的限制,写之前必须先记住。
约束一:返回值必须可序列化
转译器必须能把宏的返回值序列化成 AST 节点才能内联。所有 JSON 兼容的数据结构都支持:
// macro.ts
export function getObject() {
return {
foo: "bar",
baz: 123,
array: [1, 2, { nested: "value" }],
};
}
除了 JSON 类型,转译器对几种常见数据格式实现了特殊的序列化逻辑:
| 返回值类型 | 序列化行为 |
|---|---|
Promise | 转译器 await 后按兑现值继续序列化 |
Response | 读取 Content-Type 分别处理:application/json 解析为对象、text/plain 内联为字符串;类型未知或未定义时做 base64 编码 |
Blob | 同 Response,依据 type 属性决定 |
由于 fetch() 的返回值正是 Promise<Response>,它可以被宏直接返回:
// macro.ts
export function getObject() {
return fetch("https://bun.com");
}
而函数、以及上面没列出的绝大多数类的实例,都是不可序列化的:
// macro.ts
export function getText(url: string) {
// 这样写不行:函数无法被内联进 AST
return () => {};
}
Warning宏不能返回函数、类实例、闭包这类”带行为”的东西。宏产出的只能是数据。如果你需要在产物里得到一段逻辑,那它就不该是宏,而应该是普通模块。
约束二:参数必须静态可知
宏可以接收参数,但参数值必须在打包时就能确定。下面这段是不允许的:
// 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("The page is ", text.length, " characters long");
}
但如果 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("The page is", text.length, "characters long");
}
产物如下,可以看到连 text.length 都被提前算成了数字:
function howLong() {
console.log("The page is", 1322, "characters long");
}
export { howLong };
8.5 典型用例
用例一:把 Git 提交哈希嵌进产物
这是宏最常被举出的例子。写一个宏,在打包时执行 git rev-parse HEAD:
// getGitCommitHash.ts
export function getGitCommitHash() {
const { stdout } = Bun.spawnSync({
cmd: ["git", "rev-parse", "HEAD"],
stdout: "pipe",
});
return stdout.toString();
}
在业务代码里当宏用:
// index.ts
import { getGitCommitHash } from "./getGitCommitHash.ts" with { type: "macro" };
console.log(`The current Git commit hash is ${getGitCommitHash()}`);
打包结果:
console.log(`The current Git commit hash is 3ee3259104e4507cf62c160f0ff5357ec4c7a7f8`);
有人会问:“这用 process.env.GIT_COMMIT_HASH 不也行吗?“确实行。但环境变量只能传字符串,而宏可以在构建机上执行任意逻辑——调子进程、读文件、访问数据库、发网络请求——这是环境变量做不到的。
用例二:构建期 fetch + HTMLRewriter 抓取元信息
下面这个宏在打包时发起一次 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;
}
使用侧:
// head.tsx
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("Expected title to be 'Example Domain'");
}
return (
<head>
<title>{headTags.title}</title>
<meta name="viewport" content={headTags.viewport} />
</head>
);
};
打包后:
export const Head = () => {
const headTags = {
title: "Example Domain",
viewport: "width=device-width, initial-scale=1",
};
return (
<head>
<title>{headTags.title}</title>
<meta name="viewport" content={headTags.viewport} />
</head>
);
};
extractMetaTags 被完全擦除,网络请求发生在打包时,结果被固化进产物;那段抛错的校验分支因为不可达也被消除了。这相当于把一次运行期请求”预支”到了构建期——运行时零开销,且没有网络失败的风险。
8.6 安全边界
在源码里执行任意代码显然是有风险的,Bun 对宏设了几道闸。
第一,宏必须显式声明。 只有带 { 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 里的包导入宏并调用它:
import { macro } from "some-package" with { type: "macro" };
macro();
Warning“依赖包不能调用宏”这条规则是安全底线,别试图绕过它。如果一个库要求你在自己的代码里调用它导出的宏,那是正常用法;如果它试图在自己内部悄悄执行宏,那就该警惕了。
8.7 发布带宏的库:"macro" 导出条件
如果你要把一个包含宏的库发布到 npm,通常希望”运行时版本”和”宏版本”是两份不同的实现。Bun 支持在 package.json 的 exports 中声明 "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。库作者因此可以把”重的、只在构建期需要的”依赖收进宏版本,不污染运行期产物体积。
8.8 小结与常见误区
宏的心智模型可以浓缩成一句话:它是一段在打包时跑一次、把结果写死进产物的普通函数。
回顾几个容易踩的坑:
- 误以为宏是运行时特性。 宏的调用发生在打包/转译阶段,产物里不存在宏函数。想在运行期动态计算的东西,不要交给宏。
- 想让宏返回函数或类实例。 不行,宏只能产出可序列化的数据。
- 给宏传运行期变量。 参数必须静态可知,否则构建报错。常量、字面量、其他宏的返回值都可以。
- 忘了死代码消除依赖 minify syntax。 上面
returnFalse那个例子要产出空 bundle,前提是启用了对应的压缩选项;否则内联后的if (false)可能仍以某种形式保留。 - 在库内部调用宏。
node_modules/**/*内禁止调用宏,这是硬性安全约束。
用一句话决定要不要上宏:如果这件事的结果在构建那一刻就已经确定、并且能表达成 JSON,那它就适合做成宏。 反之,交给运行时。
下一章我们把视角从编译期拉回运行期,系统性地过一遍 Bun.* 命名空间下都有哪些内置 API。