Bun

宏是一种在捆绑时运行 JavaScript 函数的机制。这些函数返回的值会直接内联到你的捆绑包中。

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

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

这只是一个普通文件中的普通函数,但我们可以像这样用作宏

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

console.log(`Your random number is ${random()}`);

注意 — 宏使用import attribute语法表示。如果你以前没有见过这个语法,这是一个第 3 阶段 TC39 提议,它允许你向import语句附加额外的元数据。

现在,我们将使用bun build捆绑此文件。捆绑的文件将打印到 stdout。

bun build ./cli.tsx
console.log(`Your random number is ${0.6805550949689833}`);

如你所见,random函数的源代码在捆绑包中无处可见。相反,它在捆绑期间执行,函数调用 (random()) 被函数的结果替换。由于源代码永远不会包含在捆绑包中,因此宏可以安全地执行特权操作,例如从数据库中读取数据。

何时使用宏

如果你有几个用于小事情的构建脚本,而你原本会使用一次性构建脚本,那么在打包时执行代码会更容易维护。它与你的其他代码一起存在,它与构建的其余部分一起运行,它会自动并行化,如果它失败,构建也会失败。

如果你发现自己在打包时运行了很多代码,请考虑运行服务器。

导入属性

Bun 宏是使用以下方式注释的导入语句

  • with { type: 'macro' }导入属性,第 3 阶段 ECMA Scrd
  • 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 导入宏并调用它们。

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"
  }
}

使用此配置,用户可以在运行时或打包时使用相同的导入说明符使用你的软件包

import pkg from "my-package";                            // runtime import
import {macro} from "my-package" with { type: "macro" }; // macro import

第一个导入将解析为 ./node_modules/my-package/index.js,而第二个导入将由 Bun 的打包器解析为 ./node_modules/my-package/index.macro.js

执行

当 Bun 的转换器看到宏导入时,它使用 Bun 的 JavaScript 运行时调用转换器中的函数,并将 JavaScript 中的返回值转换为 AST 节点。这些 JavaScript 函数是在捆绑时执行的,而不是在运行时执行。

宏在转换器中以同步方式在访问阶段执行,在插件之前和转换器生成 AST 之前。它们按照导入的顺序执行。转换器将等待宏执行完毕后再继续执行。转换器还将“await”宏返回的任何“Promise”。

Bun 的捆绑器是多线程的。因此,宏在多个派生的 JavaScript“工作线程”中并行执行。

死代码消除

捆绑器在运行和内联宏之后执行死代码消除。因此,给定以下宏

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

...那么,只要启用了最小化语法选项,捆绑以下文件将生成一个空捆绑。

import {returnFalse} from './returnFalse.ts' with { type: 'macro' };

if (returnFalse()) {
  console.log("This code is eliminated");
}

可序列化性

Bun 的转换器需要能够序列化宏的结果,以便将其内联到 AST 中。所有与 JSON 兼容的数据结构都受支持

macro.ts
export function getObject() {
  return {
    foo: "bar",
    baz: 123,
    array: [ 1, 2, { nested: "value" }],
  };
}

宏可以是异步的,或返回“Promise”实例。Bun 的转换器将自动“await”该“Promise”并内联结果。

macro.ts
export async function getText() {
  return "async value";
}

转换器实现了针对常见数据格式(如“Response”、“Blob”、“TypedArray”)的特殊序列化逻辑。

  • “TypedArray”:解析为 base64 编码的字符串。
  • “Response”:Bun 将读取“Content-Type”并相应地序列化;例如,类型为“application/json”的“Response”将自动解析为对象,而“text/plain”将内联为字符串。具有无法识别或“undefined”类型的“Response”将以 base-64 编码。
  • “Blob”:与“Response”一样,序列化取决于“type”属性。

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

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

大多数类的函数和实例(上述类除外)不可序列化。

export function getText(url: string) {
  // this doesn't work!
  return () => {};
}

参数

宏可以接受输入,但仅限于某些情况。该值必须是静态已知的。例如,以下内容不被允许

import {getText} from './getText.ts' with { type: 'macro' };

export function howLong() {
  // the value of `foo` cannot be statically known
  const foo = Math.random() ? "foo" : "bar";

  const text = getText(`https://example.com/${foo}`);
  console.log("The page is ", text.length, " characters long");
}

但是,如果在捆绑时已知 foo 的值(例如,如果它是一个常量或另一个宏的结果),则允许

import {getText} from './getText.ts' with { type: 'macro' };
import {getFoo} from './getFoo.ts' with { type: 'macro' };

export function howLong() {
  // this works because getFoo() is statically known
  const foo = getFoo();
  const text = getText(`https://example.com/${foo}`);
  console.log("The page is", text.length, "characters long");
}

这会输出

function howLong() {
  console.log("The page is", 1322, "characters long");
}
export { howLong };

示例

嵌入最新的 git commit 哈希

getGitCommitHash.ts
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(`The current Git commit hash is ${getGitCommitHash()}`);
输出
console.log(`The current Git commit hash is 3ee3259104f`);

你可能在想“为什么不直接使用 process.env.GIT_COMMIT_HASH?”好吧,你也可以这样做。但是,你能使用环境变量来实现此功能吗?

在捆绑时进行 fetch() 请求

在此示例中,我们使用 fetch() 发出传出 HTTP 请求,使用 HTMLRewriter 解析 HTML 响应,并在捆绑时返回包含标题和元标记的对象。

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("Expected title to be 'Example Domain'");
  }

  return <head>
    <title>{headTags.title}</title>
    <meta name="viewport" content={headTags.viewport} />
  </head>;
};
输出
import { jsx, jsxs } from "react/jsx-runtime";
export const Head = () => {
  jsxs("head", {
    children: [
      jsx("title", {
        children: "Example Domain",
      }),
      jsx("meta", {
        name: "viewport",
        content: "width=device-width, initial-scale=1",
      }),
    ],
  });
};

export { Head };