Bun

Bun 中的 JavaScript 巨集


Jarred Sumner · 2023 年 5 月 31 日

兩週前,我們在 Bun 捆綁器 v0.6.0 中推出了新的 JavaScript 捆綁器。今天,我們發布一項新功能,突顯 Bun 的捆綁器與執行階段之間的緊密整合:Bun 巨集。

巨集是一種在捆綁時執行 JavaScript 函式的機制。從這些函式傳回的值會直接內嵌到您的捆綁包中。

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

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

在我們的原始碼中,我們可以使用 import attribute 語法,將此函式作為巨集匯入。如果您之前沒有見過這種語法,這是 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 commit 雜湊值

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 回應,並傳回包含標題和 meta 標籤的物件 — 全部都在捆綁時完成。

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 attribute 註釋的 import 陳述式。

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

Import attribute 是 ECMAScript 的第三階段提案,這表示它們極有可能作為 JavaScript 語言的官方部分新增。

Bun 也支援 import assertion 語法。Import assertion 是 import attribute 的早期形式,現在已被棄用(但 已受到 許多瀏覽器和執行階段支援)。

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

當 Bun 的轉譯器看到這些特殊 import 時,它會在轉譯器內部使用 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" } 匯入,才能在捆綁時執行。如果未呼叫這些 import,則它們不會產生任何作用,這與可能具有副作用的常規 JavaScript import 不同。

您可以完全停用巨集,方法是將 --no-macros 旗標傳遞給 Bun。它會產生如下所示的建置錯誤

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

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

  • TypedArray:解析為 base64 編碼的字串。
  • Response:在相關情況下,Bun 會讀取 Content-Type 並據此進行序列化;例如,類型為 application/jsonResponse 會自動解析為物件,而 text/plain 會內嵌為字串。類型為未知或未定義的 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");
}

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