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 屬性 語法表示。如果您以前沒有見過此語法,它是一個第 3 階段 TC39 提議,讓您附加額外元資料到 import 陳述式。

現在我們將使用 bun build 捆綁此檔案。已捆綁的檔案將列印到標準輸出。

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 匯入不同,如果這些匯入未被呼叫,它們不會產生任何效果。

您可以透過將 --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"
  }
}

使用此設定,使用者可以在執行時或套件組建時使用相同的匯入指定符來使用您的套件

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

轉譯器實作了特殊邏輯來序列化常見的資料格式,例如 ResponseBlobTypedArray

  • TypedArray:解析為 base64 編碼字串。
  • Response:Bun 會讀取 Content-Type 並據此序列化;例如,類型為 application/jsonResponse 會自動解析成物件,而 text/plain 會內嵌為字串。類型為未識別或 undefined 的回應會以 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");
}

但是,如果 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 hash

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 回應,並傳回包含標題和 meta 標籤的物件,所有這些都在套件編譯時執行。

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