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

XML

通过运行时 API 和打包器集成使用 Bun 内置的 XML 支持

在 Bun 中,XML 与 JSON、TOML、YAML 和 JSON5 一样是一等公民。你可以:

  • 使用 Bun.XML.parse 和 Bun.XML.stringify 解析和序列化 XML
  • 在运行时将 XML 文件 import 和 require 为模块(包括热重载和 watch 模式支持)
  • 在使用 Bun 打包器的前端应用中 import 和 require XML 文件

运行时 API

Bun.XML.parse()

将 XML 文档解析为普通 JavaScript 对象。

TypeScript
import { XML } from "bun";

const data = XML.parse(`
  <order id="A1" currency="USD">
    <customer>Ada</customer>
    <item sku="tea" qty="2">Green tea</item>
    <item sku="mug" qty="1">Mug</item>
    <paid/>
  </order>
`);

console.log(data);
// {
//   order: {
//     "@id": "A1",
//     "@currency": "USD",
//     customer: "Ada",
//     item: [
//       { "@sku": "tea", "@qty": "2", "#text": "Green tea" },
//       { "@sku": "mug", "@qty": "1", "#text": "Mug" },
//     ],
//     paid: "",
//   },
// }

默认情况下,结果是一个以元素名称为键的紧凑对象——这是大多数 XML 转对象库采用的 @attr/#text 约定。其工作方式如下:

  • 结果只有一个键,即根元素的名称。
  • 没有属性和子元素的元素会变成其字符数据:一个字符串;如果为空,则为 ""。在 XML 中,<paid/> 和 <paid></paid> 是同一回事。
  • 其他元素都会变成对象。每个属性对应一个 "@name" 键,然后按照各个子元素名称首次出现的顺序,为每个不同的子元素名称添加一个键,并为元素自身的文本添加一个 "#text" 键。
  • 如果某个子元素名称在元素中出现多次,其键对应的值就是按文档顺序排列的数组;否则对应单个值。请参阅一个或多个。
  • compact 用于选择结构,不会改变值。Bun 会按原样返回文本:包括前导、尾随和内部空白字符,展开 CDATA 段和实体引用,并将行尾规范化为 \n——这与该元素对应的树结构所给出的文本相同。由于一个元素只有一个 "#text",Bun 会串联该元素的文本片段,并忽略位于子元素之间的纯空白片段(即文档的排版空白)。如果文档经过手动排版(<name>\n value\n</name>),请在读取时进行修剪。
  • 所有值都是字符串。不会将任何值转换为数字、布尔值或 null。
  • 名称保持原样,包括命名空间前缀("soap:Body");xmlns 声明是普通属性。
  • 注释、处理指令、<?xml …?> 声明和 <!DOCTYPE …> 都不会被表示。

@ 和 # 不能作为 XML 名称的开头,因此属性键和文本键不会与子元素键冲突。

紧凑结构用于 数据。它不会保留名称不同的兄弟元素之间的相对顺序,也不会保留文本相对于子元素的位置:

TypeScript
XML.parse(`<p>Hello <b>world</b>!</p>`);
// { p: { "#text": "Hello !", b: "world" } }

如果这些信息很重要——例如处理文档而非数据时——请传入 { compact: false },以获取根元素的树结构,并保留元素内容在文档中的顺序:

TypeScript
const p = XML.parse(`<p class="lead">Hello <b>world</b>!<!-- draft --></p>`, { compact: false });

console.log(p);
// {
//   name: "p",
//   attributes: { class: "lead" },
//   children: [
//     "Hello ",
//     { name: "b", attributes: {}, children: ["world"] },
//     "!",
//     { comment: " draft " },
//   ],
// }

每个元素都是 { name, attributes, children };即使为空,这两个键也都会存在。children 按顺序保存元素内容:文本以字符串形式保存(保持原样,包括纯空白片段,相邻文本会合并)、子元素、以 { comment } 表示的注释,以及以 { target, data } 表示的处理指令。可以根据对象所包含的键来区分对象子项。与紧凑结构一样,树结构不会表示声明、DOCTYPE,或根元素之前和之后的任何内容。

一个或多个

在紧凑结构中,一个元素的列表和两个元素的列表具有不同的类型(entry: {…} 与 entry: [{…}, {…}])。通常是字符串的元素,如果带有属性,也会变成对象(<title> 与 <title type="html">)。对于需要遍历或可能带有属性的值,请采取稳妥的处理方式:

TypeScript
const entries = [feed.entry ?? []].flat(); // an array either way
const title = typeof e.title === "string" ? e.title : (e.title["#text"] ?? "");

树结构没有这类歧义:children 是数组,每个元素都是一个对象。

输入类型和编码

XML.parse 接受字符串,或以 Buffer、TypedArray、DataView、ArrayBuffer 或 Blob 表示的字节数据。

字符串已经是解码后的文本,因此会检查其中 encoding 声明的语法,但除此之外会忽略它。字节数据会按照 XML 规则解码:字节顺序标记或 <?xml version="1.0" encoding="..."?> 中的 encoding 会指定使用 UTF-8(默认值)、UTF-16(任一字节序)或 ISO-8859-1。其他编码会抛出错误。

TypeScript
XML.parse(await Bun.file("feed.xml").bytes());

错误处理

当文档格式不正确时,Bun.XML.parse() 会抛出 SyntaxError(没有宽松模式);对于深度异常的嵌套,会抛出 RangeError:

TypeScript
try {
  XML.parse("<a><b></a>");
} catch (error) {
  console.error(error.message); // "XML Parse error: Expected closing tag </b> but found </a>"
}

Bun.XML.stringify()

将任一结构中的一个元素序列化为 XML。

TypeScript
import { XML } from "bun";

XML.stringify({
  order: {
    "@id": "A1",
    customer: "Ada",
    item: [{ "@sku": "tea", "#text": "Green tea" }, { "@sku": "mug" }],
    paid: null,
  },
});
// '<order id="A1"><customer>Ada</customer><item sku="tea">Green tea</item><item sku="mug"/><paid/></order>'

XML.stringify({
  name: "p",
  attributes: { class: "lead" },
  children: ["Hello ", { name: "b", children: ["world"] }, "!", { comment: " draft " }],
});
// '<p class="lead">Hello <b>world</b>!<!-- draft --></p>'

当一个值具有字符串类型的 name,并且具有 children 或 attributes 属性时,Bun 会将其作为树节点写入。在 children 中,包含 name 的对象是元素,包含 comment 的对象是注释,包含 target 的对象是处理指令。其他所有值都会被视为紧凑对象,其中一个键用于命名根元素。Bun 会按顺序写入键,并将 @ 键作为属性。字符串、数字、布尔值和 bigint 会通过 String() 转换为文本;Date 会转换为其 ISO 字符串。null 会变成空元素。与 JSON.stringify 一样,Bun 会跳过 undefined、函数和符号。数组会为每个项生成一个元素。

输出是格式正确的 XML,否则 stringify 会抛出错误。Bun 会转义 &、< 和 >。属性值中的 "、制表符和换行符,以及任意位置的回车符,都会以字符引用的形式写入,以便解析后保持不变。XML 无法容纳的值会导致错误,而不是生成损坏的文档:不是 XML 名称的元素和属性名称("first name"、"0")、XML 字符集之外的字符(U+0000 和其他控制字符、未配对的代理项——XML 1.0 没有可用于这些字符的转义形式)、注释中的 --、处理指令中的 ?>、根节点处的数组或其他数组中的数组,以及循环结构。

结果只有元素本身,不包含 <?xml …?> 声明和 DOCTYPE,因此你可以将结果串联到外层元素中。要写入文件,请自行添加序言:

TypeScript
await Bun.write(
  "Info.plist",
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
    `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
    XML.stringify(plist, null, "\t"),
);

美化打印

传入 space 参数(空格数或缩进字符串,与 JSON.stringify 一样),即可缩进仅包含元素的内容。对于包含文本的元素,Bun 会将其写在同一行,因此缩进不会改变字符数据:

TypeScript
console.log(XML.stringify(data, null, 2));
// <order id="A1" currency="USD">
//   <customer>Ada</customer>
//   <item sku="tea" qty="2">Green tea</item>
//   <item sku="mug" qty="1">Mug</item>
//   <paid/>
// </order>

第二个参数已保留,请传入 null 或 undefined。

对于 XML.parse 生成的值,无论采用哪种结构,XML.parse(XML.stringify(value)) 都会返回相等的值。


模块导入

ES Modules

你可以直接导入 XML 文件。Bun 会像处理传递给 XML.parse 的字节数据一样解码文件(根据字节顺序标记或声明使用 UTF-8、UTF-16 或 ISO-8859-1)。模块的值是上文所述的紧凑对象:

config.xml
<?xml version="1.0" encoding="UTF-8"?>
<config env="production">
  <database host="localhost" port="5432" name="myapp"/>
  <feature name="auth"/>
  <feature name="rateLimit"/>
</config>

默认导入

app.ts
import doc from "./config.xml";

console.log(doc.config["@env"]); // "production"
console.log(doc.config.database["@host"]); // "localhost"
console.log(doc.config.feature.map(f => f["@name"])); // ["auth", "rateLimit"]

命名导入

根元素也可以作为命名导入使用:

app.ts
import { config } from "./config.xml";

console.log(config.database["@port"]); // "5432"

CommonJS

app.ts
const { config } = require("./config.xml");
console.log(config.database["@name"]); // "myapp"

导入属性

使用 with { type: "xml" } 将其他扩展名的文件作为 XML 解析:

TypeScript
import feed from "./export.rss" with { type: "xml" };

XML 热重载

使用 bun --hot 运行应用程序时,Bun 会在 XML 文件发生变化时重新加载它们:

server.ts
import { config } from "./config.xml";

Bun.serve({
  port: 3000,
  fetch(req) {
    return new Response(`Running in ${config["@env"]} against ${config.database["@host"]}`);
  },
});
terminal
bun --hot server.ts

打包器集成

使用 Bun 打包时,打包器会在构建时解析导入的 XML 文件,并将它们内联为 JavaScript 对象:

terminal
bun build app.ts --outdir=dist

在构建时解析意味着:

  • 生产环境中零运行时 XML 解析开销
  • 更小的打包体积
  • 对未使用的属性进行 Tree Shaking

动态导入

你可以动态导入 XML 文件:

TypeScript
const { default: doc } = await import("./config.xml");

一致性

Bun 的 XML 解析器使用 Rust 编写,并作为非验证处理器实现了 XML 1.0(第五版),不会读取外部实体:

  • 整个文档(包括内部 DTD 子集)都必须格式正确,否则会抛出 SyntaxError。
  • 解析器会展开文档中声明的内部实体,并设置展开限制,因此“十亿笑声”攻击载荷会失败,而不是耗尽内存。它会规范化属性值,并应用内部子集中声明的属性默认值。
  • 解析器不会获取或读取外部 DTD 或外部实体,因此不存在 XXE 攻击面。对于没有 DTD 的文档,引用未声明的实体会导致错误。如果 DOCTYPE 指向外部子集(或使用参数实体),而该子集可能声明了此实体,解析器就会保留原样的引用(&nbsp; 仍为 &nbsp;),除非文档指定了 standalone="yes"。
  • 解析器不会根据 DTD 进行验证。它不会解析命名空间,并会原样保留带前缀的名称。

解析器使用 W3C XML 一致性测试套件进行测试。对于此类处理器有明确预期结果的 1,679 个测试用例,解析器均已通过:拒绝格式不正确的文档,并接受格式正确的文档。对于测试套件提供了规范输出的用例,格式正确文档的元素树(包括处理指令)与规范输出逐字节匹配。已转换的测试套件列出了所有用例,包括那些结果合理地取决于是否读取外部实体的用例。


性能

与 Bun 的 JSON 解析器一样,解析器分两个阶段工作。SIMD 扫描阶段(运行时分派 AVX2/AVX-512/NEON/SVE 内核)会查找可能影响解析的字节,因此解析器无需逐字节扫描字符数据、属性值、注释和 CDATA 段。元素名称和属性名称会像 JSON.parse 一样复用 JavaScriptCore 的原子字符串缓存。

bench/xml/xml.mjs 会在相同文档上比较 Bun.XML.parse 与常用的 npm 解析器(越低越好;Linux x64,单核):

文档Bun.XML.parsetxmlfast-xml-parser@xmldom/xmldomxml2js
S3 ListObjectsV2 响应,231 KB1.1 ms4.0 ms23 ms31 ms19 ms
Atom feed,193 KB1.1 ms3.7 ms19 ms23 ms16 ms
libphonenumber 元数据,960 KB5.3 ms9.6 ms56 ms53 ms—
Chromium enums.xml,1.4 MB16 ms41 ms150 ms103 ms—
freedesktop MIME 数据库,2.2 MB27 ms56 ms299 ms280 ms—