运行时包管理器打包器测试运行器代码检查格式化指南参考博客反馈

宏

使用 Bun 宏在打包时运行 JavaScript 函数

宏是打包时运行的 JavaScript 函数。Bun 会将它们的返回值直接内联到你的 bundle 中。

作为一个简单示例,考虑下面这个返回随机数的函数。

random.ts
export function random() {
  return Math.random();
}

这是一个普通文件中的普通函数,但你可以将它用作宏:

cli.tsx
import { random } from "./random.ts" with { type: "macro" };

console.log(`你的随机数是 ${random()}`);

使用 bun build 打包文件。Bun 会将打包后的文件打印到 stdout。

terminal
bun build ./cli.tsx
JavaScript
console.log(`你的随机数是 ${0.6805550949689833}`);

random 函数的源代码不会出现在 bundle 中。相反,该函数会在打包期间运行,Bun 会将调用(random())替换为其结果。由于源代码不会包含在 bundle 中,宏可以安全地执行读取数据库等特权操作。

何时使用宏

对于一些原本需要编写一次性构建脚本才能完成的小任务,在打包时执行代码会更易于维护。它与其余代码放在一起,也会随其余构建流程一起运行。Bun 会自动并行执行宏;如果宏执行失败,构建也会失败。

不过,如果你发现自己在打包时运行大量代码,考虑改为运行服务器。

导入属性

宏是带有以下注解之一的导入语句:

  • with { type: 'macro' } — 导入属性,是一项 Stage 4 ECMAScript 提案
  • assert { type: 'macro' } — 导入断言,是导入属性的早期形式,现已被弃用(但一些浏览器和运行时已经支持它)

安全注意事项

必须使用 { type: "macro" } 显式导入宏,宏才会在打包时运行。这些导入只有在调用时才会生效,不同于可能产生副作用的普通 JavaScript 导入。

你可以使用 --no-macros 标志完全禁用宏。它会产生如下构建错误:

Code
error: Macros are disabled

foo();
^
./hello.js:3:1 53

为减少恶意包可能造成的攻击面,Bun 不允许 node_modules/**/* 中的代码调用宏。如果某个包尝试调用宏,你会看到类似这样的错误:

Code
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" 导出条件,为宏环境提供专用的包版本。

package.json
{
  "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 运行时调用该函数,并将返回值转换为 AST 节点。

宏会在转译器的访问阶段同步运行,此时转译器已经将文件解析为 AST。它们按照调用在文件中出现的顺序运行;转译器只有在遇到对某个宏模块导出的函数的调用时,才会加载或运行该宏模块。转译器会等待每个宏完成后再继续,并会等待宏返回的任何 Promise。

Bun 的打包器是多线程的,因此宏会在多个生成的 JavaScript“工作线程”中并行执行。

死代码消除

打包器会在运行并内联宏之后执行死代码消除。给定以下宏:

returnFalse.ts
export function returnFalse() {
  return false;
}

……在启用 minify 语法选项的情况下,打包以下文件会生成一个空的 bundle。

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 等常见数据格式。

  • Response:Bun 会读取 Content-Type 并据此进行序列化。例如,它会将类型为 application/json 的 Response 解析为对象,并将 text/plain 内联为字符串。对于类型无法识别或未定义的 Response,Bun 会使用 base64 编码。
  • Blob:与 Response 一样,序列化方式取决于 type 属性。

fetch 的结果是 Promise<Response>,因此宏可以直接返回它。

macro.ts
export function getObject() {
  return fetch("https://bun.sh");
}

函数和大多数类的实例(之前列出的实例除外)都无法序列化。

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, "字符");
}

其输出为:

JavaScript
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();
}

打包时,Bun 会将 getGitCommitHash 调用替换为调用该函数的结果:

input
import { getGitCommitHash } from "./getGitCommitHash.ts" with { type: "macro" };

console.log(`当前 Git 提交哈希是 ${getGitCommitHash()}`);
output
console.log(`当前 Git 提交哈希是 3ee3259104e4507cf62c160f0ff5357ec4c7a7f8`);

在打包时执行 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;
}

Bun 会在打包时擦除 extractMetaTags 函数,并将其替换为函数调用的结果。fetch 请求会在打包时发生,Bun 会将结果嵌入 bundle 中。启用 minify 语法选项后,由于抛出错误的分支不可达,Bun 也会将其消除。

input
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>
  );
};
output
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>
  );
};