Bun

Bun 中的 JavaScript 宏


Jarred Sumner · 2023 年 5 月 31 日

两周前,我们在 Bun v0.6.0 中推出了新的 JavaScript 打包器。今天,我们发布了一项新功能,它突出了 Bun 打包器和运行时之间的紧密集成:Bun 宏。

宏是一种在**打包时**运行 JavaScript 函数的机制。这些函数返回的值会直接内联到您的打包文件中。

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

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

在我们的源代码中,我们可以使用导入属性语法将此函数作为宏导入。如果您以前没有见过这种语法,它是一个 Stage 3 TC39 提案,允许您将额外的元数据附加到 `import` 语句。

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

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

现在我们将使用 `bun build` 打包此文件。打包后的文件将打印到 stdout。

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

如您所见,`random` 函数的源代码在打包文件中根本没有出现。相反,它在**打包期间**执行,函数调用 (`random()`) 被替换为函数的结果。由于源代码永远不会包含在打包文件中,因此宏可以安全地执行特权操作,例如从数据库读取数据。

何时使用宏

对于那些您本可以编写一次性构建脚本的小任务,打包时代码执行可以更易于维护。它与您的其余代码一起存在,与其余构建一起运行,它会自动并行化,如果它失败,构建也会失败。

但是,如果您发现自己在打包时运行了大量代码,请考虑运行服务器。

让我们看看一些宏可能很有用的场景。

嵌入最新的 Git 提交哈希

in-the-browser.ts
getGitCommitHash.ts
in-the-browser.ts
import { getGitCommitHash } from './getGitCommitHash.ts' with { type: 'macro' };

console.log(`The current Git commit hash is ${getGitCommitHash()}`);
getGitCommitHash.ts
export function getGitCommitHash() {
  const {stdout} = Bun.spawnSync({
    cmd: ["git", "rev-parse", "HEAD"],
    stdout: "pipe",
  });

  return stdout.toString();
}

当我们构建它时,`getGitCommitHash` 被替换为调用函数的结果

output.js
CLI
output.js
console.log(`The current Git commit hash is 3ee3259104f`);
CLI
bun build --target=browser ./in-the-browser.ts

您可能会想“为什么不直接使用 `process.env.GIT_COMMIT_HASH` 呢?” 嗯,您也可以这样做。但是您可以使用环境变量来做这个吗?

在打包时发出 `fetch()` 请求

在此示例中,我们使用 `fetch()` 发出出站 HTTP 请求,使用 `HTMLRewriter` 解析 HTML 响应,并返回一个包含标题和元标签的对象——所有这些都在打包时完成。

in-the-browser.tsx
meta.ts
in-the-browser.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>;
};
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` 请求发生在打包时,结果嵌入到打包文件中。此外,抛出错误的条件分支由于无法访问而被消除。

output.js
CLI
output.js
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 };
CLI
bun build --target=browser --minify-syntax ./in-the-browser.ts

工作原理

Bun 宏是使用 `{type: 'macro'}` 导入属性注释的导入语句。

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

导入属性是 ECMAScript 的 Stage 3 提案,这意味着它们极有可能作为 JavaScript 语言的官方部分添加。

Bun 还支持**导入断言**语法。导入断言是导入属性的早期版本,现已被废弃(但已被许多浏览器和运行时支持)。

import { myMacro } from "./macro.ts" assert { type: "macro" };

当 Bun 的转译器看到这些特殊导入之一时,它会使用 Bun 的 JavaScript 运行时在转译器内部调用该函数,并将 JavaScript 的返回值转换为 AST 节点。这些 JavaScript 函数在打包时调用,而不是在运行时。

执行顺序

Bun 宏在转译器的访问阶段同步执行——在插件之前和转译器生成 AST 之前。它们按照调用的顺序执行。转译器将等待宏执行完成,然后继续。转译器还将 `await` 宏返回的任何 `Promise`。

Bun 的打包器是多线程的。因此,宏在多个生成的 JavaScript“worker”内部并行执行。

死代码消除

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

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

...然后打包以下文件将生成一个空包。

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

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

安全注意事项

宏必须使用 `{ type: "macro" }` 显式导入才能在打包时执行。与可能具有副作用的常规 JavaScript 导入不同,如果这些导入未被调用,则它们不起作用。

您可以通过向 Bun 传递 `--no-macros` 标志来完全禁用宏。它会产生如下所示的构建错误

error: Macros are disabled

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

**`node_modules` 中禁用宏**

为了减少恶意包的潜在攻击面,宏不能从 `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();

限制

一些需要知道的事情。

**宏的结果必须是可序列化的!**

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` 将内联为字符串。类型未知或未定义的响应将被 base-64 编码。
  • `Blob`:与 `Response` 一样,序列化取决于 `type` 属性。

`fetch` 的结果是 `Promise`,因此可以直接返回。

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