執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

HTMLRewriter

使用 Bun 的 HTMLRewriter 通過 CSS 選擇器轉換 HTML 文件

HTMLRewriter 使用 CSS 選擇器轉換 HTML 文件。它支援 Response、string 和 ArrayBuffer 輸入。Bun 的實現基於 Cloudflare 的 lol-html。


用法

一個常見的用例是在 HTML 內容中重寫 URL:

TypeScript
// 將所有圖片替換為 rickroll
const rewriter = new HTMLRewriter().on("img", {
  element(img) {
    // 著名的 rickroll 影片縮圖
    img.setAttribute("src", "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg");

    // 在圖片外圍套一個指向影片的連結
    img.before('<a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ" target="_blank">', {
      html: true,
    });
    img.after("</a>", { html: true });

    // 新增一些有趣的 alt 文本
    img.setAttribute("alt", "絕對不是一個 rickroll");
  },
});

// 範例 HTML 文件
const html = `
<html>
<body>
  <img src="/cat.jpg">
  <img src="dog.png">
  <img src="https://example.com/bird.webp">
</body>
</html>
`;

const result = rewriter.transform(html);
console.log(result);

重寫器會將每張圖片替換為 Rick Astley 的縮圖,並將每個 <img> 包裹在連結中,生成如下差異:

HTML
<html>
  <body>
    <img src="/cat.jpg" /> <!-- [!code --] -->
    <img src="dog.png" /> <!-- [!code --] -->
    <img src="https://example.com/bird.webp" /> <!-- [!code --] -->
    <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ" target="_blank"> <!-- [!code ++] -->
      <img src="https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" alt="絕對不是一個 rickroll" /> <!-- [!code ++] -->
    </a> <!-- [!code ++] -->
    <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ" target="_blank"> <!-- [!code ++] -->
      <img src="https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" alt="絕對不是一個 rickroll" /> <!-- [!code ++] -->
    </a> <!-- [!code ++] -->
    <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ" target="_blank"> <!-- [!code ++] -->
      <img src="https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" alt="絕對不是一個 rickroll" /> <!-- [!code ++] -->
    </a> <!-- [!code ++] -->
  </body>
</html>

現在點選任意圖片都會跳轉到一段非常著名的影片。

輸入型別

HTMLRewriter 可以轉換多種輸入型別的 HTML:

TypeScript
// 從 Response
rewriter.transform(new Response("<div>內容</div>"));

// 從字串
rewriter.transform("<div>內容</div>");

// 從 ArrayBuffer
rewriter.transform(new TextEncoder().encode("<div>內容</div>").buffer);

// 從 Blob(包裝在 Response 中)
rewriter.transform(new Response(new Blob(["<div>content</div>"])));

// 從 File(包裝在 Response 中)
rewriter.transform(new Response(Bun.file("index.html")));

Cloudflare Workers 對 HTMLRewriter 的實現僅支援 Response 物件。

元素處理器

on(selector, handlers) 方法會為匹配 CSS 選擇器的 HTML 元素註冊處理器。解析過程中,每個匹配的元素都會執行這些處理器:

TypeScript
rewriter.on("div.content", {
  // 處理元素
  element(element) {
    element.setAttribute("class", "new-content");
    element.append("<p>新內容</p>", { html: true });
  },
  // 處理文本節點
  text(text) {
    text.replace("新文本");
  },
  // 處理註釋
  comments(comment) {
    comment.remove();
  },
});

處理器可以是非同步的並傳回一個 Promise。轉換會在該元素處暫停,直到 Promise 處理完成,因此處理器仍會按照文件順序逐個執行:

TypeScript
rewriter.on("div", {
  async element(element) {
    const fragment = await fetch("https://example.com/fragment").then(r => r.text());
    element.setInnerContent(fragment, { html: true });
  },
});

transform(response) 會立即傳回。重寫會在後臺繼續進行,你可以從傳回的 Response 中讀取結果。讀取操作會控制重寫的速度:流式輸入(檔案、fetch() 響應、ReadableStream)只會以與傳回 body 消耗速度相同的速度被讀取,因此讀取較慢時,不會將整個文件都累積在記憶體中。如果沒有讀取 body,重寫仍會執行所有處理器直到文件末尾,並緩衝輸出,直到有人讀取。由於重寫的生命週期會超過 transform(),非同步處理器丟擲的錯誤(或其傳回的被拒絕的 Promise)會導致響應 body 被拒絕,而不是從 transform() 丟擲:

TypeScript
try {
  const output = await rewriter.transform(new Response(html)).text();
} catch (error) {
  console.error("a handler failed:", error);
}

對 string 或 ArrayBuffer 呼叫 transform() 時,必須同步傳回結果,因此無法等待需要事件迴圈執行的處理器(例如計時器、I/O 或 fetch)。此類處理器會使 transform() 丟擲 TypeError,並且重寫會失敗,不再執行任何後續處理器:

TypeScript
new HTMLRewriter()
  .on("div", {
    async element(element) {
      await Bun.sleep(1000); // needs a timer
    },
  })
  .transform("<div></div>");
// TypeError: HTMLRewriter.transform() cannot synchronously return a string
// because a content handler returned a Promise that did not resolve within a
// microtask. Pass a Response instead and await its body

如果處理器傳回的 Promise 在一個微任務檢查點內完成,那麼它仍可與 transform(string) 配合使用。任何不需要事件迴圈的操作都符合此條件,包括 process.nextTick 和已解決的 Promise。如果處理器可能需要等待實際工作,請傳入 Response。

CSS 選擇器支援

on() 方法支援豐富的 CSS 選擇器:

TypeScript
// 標籤選擇器
rewriter.on("p", handler);

// 類選擇器
rewriter.on("p.red", handler);

// ID 選擇器
rewriter.on("h1#header", handler);

// 屬性選擇器
rewriter.on("p[data-test]", handler); // 含有該屬性
rewriter.on('p[data-test="one"]', handler); // 精確匹配
rewriter.on('p[data-test="one" i]', handler); // 不區分大小寫
rewriter.on('p[data-test="one" s]', handler); // 區分大小寫
rewriter.on('p[data-test~="two"]', handler); // 單詞匹配
rewriter.on('p[data-test^="a"]', handler); // 以...開頭
rewriter.on('p[data-test$="1"]', handler); // 以...結尾
rewriter.on('p[data-test*="b"]', handler); // 包含...
rewriter.on('p[data-test|="a"]', handler); // 借字號分隔

// 組合選擇器
rewriter.on("div span", handler); // 後代
rewriter.on("div > span", handler); // 直接子元素

// 偽類選擇器
rewriter.on("p:nth-child(2)", handler);
rewriter.on("p:first-child", handler);
rewriter.on("p:nth-of-type(2)", handler);
rewriter.on("p:first-of-type", handler);
rewriter.on("p:not(:first-child)", handler);

// 萬用字元選擇器
rewriter.on("*", handler);

元素操作

所有元素修改方法都會傳回元素例項,因此你可以鏈式呼叫:

TypeScript
rewriter.on("div", {
  element(el) {
    // 屬性操作
    el.setAttribute("class", "new-class").setAttribute("data-id", "123");

    const classAttr = el.getAttribute("class"); // "new-class"
    const hasId = el.hasAttribute("id"); // 布林值
    el.removeAttribute("class");

    // 內容操作
    el.setInnerContent("新內容"); // 預設會轉義 HTML
    el.setInnerContent("<p>HTML 內容</p>", { html: true }); // 解析 HTML
    el.setInnerContent(""); // 清空內容

    // 位置操作
    el.before("前面的內容").after("後面的內容").prepend("第一個子節點").append("最後一個子節點");

    // 插入 HTML 內容
    el.before("<span>before</span>", { html: true })
      .after("<span>after</span>", { html: true })
      .prepend("<span>first</span>", { html: true })
      .append("<span>last</span>", { html: true });

    // 移除
    el.remove(); // 移除元素及其內容
    el.removeAndKeepContent(); // 僅移除元素標籤,保留內容

    // 屬性
    console.log(el.tagName); // 標籤名小寫
    console.log(el.namespaceURI); // 元素的名稱空間 URI
    console.log(el.selfClosing); // 是否自閉合標籤(如 <div />)
    console.log(el.canHaveContent); // 是否可以包含內容(void 元素如 <br> 為 false)
    console.log(el.removed); // 是否被移除

    // 遍歷屬性
    for (const [name, value] of el.attributes) {
      console.log(name, value);
    }

    // 結束標籤處理
    el.onEndTag(endTag => {
      endTag.before("結束標籤之前");
      endTag.after("結束標籤之後");
      endTag.remove(); // 移除結束標籤
      console.log(endTag.name); // 標籤名小寫
    });
  },
});

文本操作

文本塊表示文本內容的一部分,並報告其在文本節點中的位置:

TypeScript
rewriter.on("p", {
  text(text) {
    // 內容
    console.log(text.text); // 文本內容
    console.log(text.lastInTextNode); // 是否文本節點中的最後一段
    console.log(text.removed); // 是否已被移除

    // 操作
    text.before("文本之前").after("文本之後").replace("新文本").remove();

    // 插入 HTML 內容
    text
      .before("<span>before</span>", { html: true })
      .after("<span>after</span>", { html: true })
      .replace("<span>replace</span>", { html: true });
  },
});

註釋操作

註釋支援與文本節點類似的方法:

TypeScript
rewriter.on("*", {
  comments(comment) {
    // 內容
    console.log(comment.text); // 註釋文本
    comment.text = "新的註釋文本"; // 設定註釋文本
    console.log(comment.removed); // 是否已被移除

    // 操作
    comment.before("註釋之前").after("註釋之後").replace("新註釋").remove();

    // 插入 HTML 內容
    comment
      .before("<span>before</span>", { html: true })
      .after("<span>after</span>", { html: true })
      .replace("<span>replace</span>", { html: true });
  },
});

文件級處理器

onDocument(handlers) 方法會為文件級別的事件註冊處理器,而不是針對特定元素內部的事件:

TypeScript
rewriter.onDocument({
  // 處理文件型別宣告
  doctype(doctype) {
    console.log(doctype.name); // "html"
    console.log(doctype.publicId); // 如果有則為公有識別符號
    console.log(doctype.systemId); // 如果有則為系統識別符號
  },
  // 處理文本節點
  text(text) {
    console.log(text.text);
  },
  // 處理註釋
  comments(comment) {
    console.log(comment.text);
  },
  // 處理文件結尾
  end(end) {
    end.append("<!-- 頁尾 -->", { html: true });
  },
});

Response 處理

轉換 Response 時,HTMLRewriter 會:

  • 保留狀態碼、headers 和其他響應屬性
  • 轉換 body,同時保留流式處理能力
  • 自動處理 content-encoding(例如 gzip)
  • 轉換後將原始響應 body 標記為已使用
  • 將 headers 克隆到新響應中

錯誤處理

呼叫的過載決定錯誤通過哪種通道傳遞,而不是由時機決定。transform() 本身會在以下情況下丟擲錯誤:

  • on() 方法中的選擇器語法無效
  • 輸入型別無效(例如傳入 Symbol)
  • body 已被使用的錯誤,以及輸入 body 已失敗或中止的情況
  • string / ArrayBuffer 輸入時內容處理器引發的任何錯誤,因為必須在 transform() 傳回前生成結果。需要事件迴圈的處理器也是如此(參見元素處理器)
TypeScript
try {
  const result = rewriter.transform("<div></div>");
} catch (error) {
  console.error("HTMLRewriter 錯誤:", error);
}

對於 Response 輸入,transform() 會在重寫完成前傳回,因此重寫過程中發現的所有錯誤都會通過輸出 body 傳遞:

  • 內容處理器丟擲的錯誤,或其傳回的被拒絕的 Promise
  • 格式錯誤或被截斷的輸入
  • 讀取輸入 body 時發生的流錯誤
  • 記憶體分配失敗
TypeScript
try {
  const output = await rewriter.transform(new Response(html)).text();
} catch (error) {
  console.error("the rewrite failed:", error);
}

如果處理器建立了 Promise,卻既不傳回也不等待它,那麼該 Promise 的拒絕不會通過任何一種通道傳遞。與任何脫離處理流程的拒絕一樣,它會進入行程全域性的 unhandledRejection 路徑。Bun 的早期版本可能會從 transform() 本身丟擲此錯誤。


參考連結

你也可以閱讀 Cloudflare 文件,該 API 旨在與之相容。