首页 / Bun 入门教程 / Bun 宏(Macros)

Bun 入门教程

Bun 宏(Macros)

本教程共 34 篇 · 第 8 篇 · 更新于 2026-08-06

BunMacros编译期执行打包器import attributes死代码消除

本节目标:

  • 理解 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}`);

请注意两件事:

  1. random 函数的源码在产物中完全消失了——它没有被打包进去。
  2. 调用点 random() 被替换成了这次调用的返回值

这就是宏的全部核心机制:函数在打包时被 Bun 自己的 JavaScript 运行时调用一次,返回值被转换成 AST 节点内联到调用处,源码则被彻底擦除。

因为宏的源码永远不会进入最终产物,它可以安全地做一些”特权操作”——读本地文件、查数据库、调用只在构建机上可用的凭据——这些代码和敏感逻辑都不会泄漏到浏览器端产物里。

Note

宏由 Bun 的转译器在打包阶段调用,运行环境就是 Bun 自身的 JavaScript 运行时。所以宏函数体里可以直接用 Bun.spawnSyncfetchHTMLRewriter 这些 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 编码
BlobResponse,依据 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.jsonexports 中声明 "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。