运行时包管理器打包器测试运行器代码检查格式化指南参考博客反馈

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 旨在与之兼容。