Bun

巨集

巨集是一種在打包時執行 JavaScript 函數的機制。從這些函數返回的值會直接內聯到您的 bundle 中。

作為一個簡單的範例,請考慮這個返回隨機數的簡單函數。

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

這只是一個常規檔案中的常規函數,但我們可以像這樣將其用作巨集

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

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

注意 — 巨集使用匯入屬性語法表示。如果您以前沒有見過這種語法,它是一個 Stage 3 TC39 提案,允許您將額外的元數據附加到 import 語句。

現在我們將使用 bun build 打包這個檔案。打包後的檔案將列印到標準輸出。

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

如您所見,random 函數的原始碼在 bundle 中任何地方都沒有出現。相反,它是在打包期間執行的,並且函數呼叫 (random()) 被替換為函數的結果。由於原始碼永遠不會包含在 bundle 中,因此巨集可以安全地執行特權操作,例如從資料庫讀取。

何時使用巨集

如果您有幾個用於小型任務的建置腳本,在這些情況下您可能需要一次性建置腳本,則 bundle-time 程式碼執行可能更易於維護。它與您的其餘程式碼一起存在,與其餘建置一起執行,自動並行化,並且如果失敗,建置也會失敗。

但是,如果您發現自己在 bundle-time 執行大量程式碼,請考慮改為執行伺服器。

匯入屬性

Bun 巨集是使用以下任一項註解的 import 語句

安全考量

巨集必須使用 { type: "macro" } 明確匯入才能在 bundle-time 執行。這些匯入如果未被呼叫則無效,這與可能具有副作用的常規 JavaScript 匯入不同。

您可以通過將 --no-macros 標誌傳遞給 Bun 來完全禁用巨集。它會產生如下建置錯誤

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

通過此配置,用戶可以在執行時或在 bundle-time 使用相同的 import specifier 來使用您的套件

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

第一個 import 將解析為 ./node_modules/my-package/index.js,而第二個將由 Bun 的 bundler 解析為 ./node_modules/my-package/index.macro.js

執行

當 Bun 的 transpiler 看到巨集匯入時,它會使用 Bun 的 JavaScript 執行時環境在 transpiler 內部呼叫該函數,並將 JavaScript 的返回值轉換為 AST 節點。這些 JavaScript 函數在 bundle-time 而不是執行時呼叫。

巨集在訪問階段(在外掛程式之前和 transpiler 生成 AST 之前)在 transpiler 中同步執行。它們按照匯入的順序執行。transpiler 將等待巨集完成執行後再繼續。transpiler 也將 await 巨集返回的任何 Promise

Bun 的 bundler 是多執行緒的。因此,巨集在多個產生的 JavaScript "workers" 內部並行執行。

無效程式碼消除

bundler 在執行和內聯巨集之後執行無效程式碼消除。因此,給定以下巨集

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

...然後打包以下檔案將產生一個空的 bundle,前提是啟用 minify syntax 選項。

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

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

可序列化

Bun 的 transpiler 需要能夠序列化巨集的結果,以便可以將其內聯到 AST 中。支援所有 JSON 兼容的資料結構

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

巨集可以是異步的,或者返回 Promise 實例。Bun 的 transpiler 將自動 await Promise 並內聯結果。

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

transpiler 實現了特殊的邏輯來序列化常見的資料格式,例如 ResponseBlobTypedArray

  • TypedArray:解析為 base64 編碼的字串。
  • Response:Bun 將讀取 Content-Type 並相應地進行序列化;例如,類型為 application/jsonResponse 將自動解析為物件,而 text/plain 將作為字串內聯。具有無法識別或 undefined type 的 Response 將以 base-64 編碼。
  • Blob:與 Response 一樣,序列化取決於 type 屬性。

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

macro.ts
export function getObject() {
  return fetch("https://bun.dev.org.tw")
}

函數和大多數類別的實例(上面提到的那些除外)是不可序列化的。

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

但是,如果在 bundle-time 已知 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?」 嗯,您也可以這樣做。但是您可以使用環境變數來做到這一點嗎?

在 bundle-time 發出 fetch() 請求

在本範例中,我們使用 fetch() 發出外發 HTTP 請求,使用 HTMLRewriter 解析 HTML 回應,並返回一個包含標題和 meta 標籤的物件——所有這些都在 bundle-time 完成。

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 函數在 bundle-time 被擦除,並替換為函數呼叫的結果。這意味著 fetch 請求在 bundle-time 發生,並且結果嵌入在 bundle 中。此外,拋出錯誤的分支被消除,因為它是無法到達的。

輸入
輸出
輸入
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 };