Skip to content
返回博客
教程

XML 转 JSON:约定、陷阱与代码示例(2026 指南)

正确地将 XML 转换为 JSON:属性、数组、命名空间如何映射,为什么值保持字符串,附 JavaScript、Python 和浏览器代码示例。

13 分钟阅读

XML 转 JSON:约定、陷阱与代码示例

你从一个 SOAP 端点、一个 RSS 订阅源,或一份 sitemap.xml 里拉回一段响应,结果是 XML。而你的技术栈以 JSON 为原生格式:前端是 JavaScript,中间层是 REST,底层是文档数据库。于是你需要把 XML 转成 JSON,随手抓来一个解析器,以为一行代码就能搞定。

通常确实如此,直到输出反咬你一口。你以为会拿到数组,结果是一个对象。一个 id 属性凭空消失了。一个像 01234 这样的邮编变成了数字 1234。这些都不是解析器的 bug,而是两个对不齐的数据模型相互映射的必然结果。可靠地把 XML 转成 JSON,唯一的办法就是理解弥合这道鸿沟的那些约定。

本指南讲清这些约定为何存在、转换的四种方式(浏览器、JavaScript、Python、命令行)、所有主流库共享的 @_#text 规则、导致数据悄无声息丢失的五个陷阱,以及如何把 JSON 转回 XML 完成一次干净的往返。文中示例真实可运行,把它们粘进 Node、Python 或 shell,就能得到注释里写明的输出。

为什么 XML 转 JSON 需要约定(而不只是重新排版)

XML 和 JSON 表面看起来相似——都是有命名、可嵌套的数据构成的树——但它们底层的模型在关键之处分道扬镳。XML 元素可以携带属性、可以持有混合内容(文本与子元素交错排列),还可以归属于命名空间(namespace)。JSON 完全没有这些概念,它只有对象、数组和四种标量类型。把一者转成另一者不是重新排版,而是在两种语法之间做翻译,其中一种能说出另一种根本拼不出来的词。

在转换任何东西之前,先确认源数据确实有效是值得的。一个落单的、未转义的 &,或一对不匹配的标签,都会在解析器处被拒。先把输入丢进 XML 格式化工具 检查格式是否良构(well-formedness),能省去一轮莫名其妙的报错。

两个模型的分歧体现在这些地方:

维度XMLJSON
节点类型元素、属性、文本、混合内容对象、数组、字符串、数字、布尔、null
根约束必须恰好有一个根元素无根约束
属性有(id="P01"无(需要 @_ 约定)
重复元素同名兄弟节点合法对象键不能重复(需要数组约定)
类型系统文本无类型——一切皆为字符串原生类型
命名空间有(xmlns

正因为模型不匹配,每一次 XML 转 JSON 都是约定驱动的,而非无损的重新排版。好消息是这些约定并非随意拍脑袋:fast-xml-parser(Node.js)、xmltodict(Python)和 JAXB(Java)都收敛到了同样的两个标记——@_ 表示属性,#text 表示混合内容里的文本。学一次,跨运行时通用。同样的道理,数据形状不匹配的问题在其他转换里也会冒头,比如 CSV 与 JSON 转换指南 里讨论的类型推断问题。

如何把 XML 转成 JSON:4 种方法

按你的场景挑选方法:一次性的快速粘贴、一个 Node 服务、一条 Python 流水线,或 CI 里的一段 shell 脚本。

方法 1 —— 浏览器工具(零配置,隐私优先)

对于一次性的转换,或者你不太愿意粘进某个随机网站的 XML,浏览器内的转换器是最快的路径。把 XML 粘进 XML 转 JSON 工具,JSON 立刻出现——无需安装、无需账号、无需上传。一切都在你浏览器的 JavaScript 引擎里运行,数据永远不离开本机。

最后这一点比听起来更重要。SOAP 信封里带着 WS-Security 令牌,内部配置里带着连接字符串,导出文件里带着客户记录。因为什么都不会被传出去,这个工具对含有凭据或敏感载荷的 XML 是安全的。你可以亲自验证:打开网络面板,转换时看着零个请求发出。

方法 2 —— JavaScript / Node.js(fast-xml-parser)

在 Node 里,fast-xml-parser 是标准选择。不过它的默认行为会让你吃惊——属性被忽略,值被强制转换——所以下面这些选项才是你真正想要的、能做出忠实转换的配置:

// 在 Node.js 中用 fast-xml-parser 把 XML 转成 JSON
import { XMLParser } from 'fast-xml-parser';

const xml = `<catalog>
  <product id="P01">
    <name>Wireless Headphones</name>
    <price currency="USD">79.99</price>
  </product>
</catalog>`;

const parser = new XMLParser({
  ignoreAttributes: false,    // 保留属性(默认会丢弃它们!)
  attributeNamePrefix: '@_',  // 属性变成 @_ 前缀的键
  textNodeName: '#text',      // 混合内容里的文本放在 #text 下
  parseAttributeValue: false, // 不对属性做类型转换
  parseTagValue: false,       // 不对元素文本做类型转换
});

const result = parser.parse(xml);
console.log(JSON.stringify(result, null, 2));
// {
//   "catalog": {
//     "product": {
//       "@_id": "P01",
//       "name": "Wireless Headphones",
//       "price": {
//         "@_currency": "USD",
//         "#text": "79.99"
//       }
//     }
//   }
// }

人们最容易忘记的两个设置是 ignoreAttributes: falseparseTagValue: false。前者保住你的 idcurrency 属性;后者阻止解析器把 "79.99" 变成浮点数、把 "01234" 变成 1234。我们会在陷阱一节回头讲为什么保留字符串才是安全的默认做法。

如果你想在浏览器里实现零依赖,原生的 DOMParser 替你完成解析,然后你自己遍历 DOM:

// 在浏览器里用 DOMParser 实现零依赖的 XML 转 JSON
function xmlToJson(node) {
  // 纯文本元素 → 字符串值
  const children = Array.from(node.children);
  if (children.length === 0 && node.attributes.length === 0) {
    return node.textContent.trim();
  }

  const obj = {};
  // 属性 → @_ 前缀
  for (const attr of node.attributes) {
    obj['@_' + attr.name] = attr.value;
  }
  // 同时带属性和文本的元素 → #text
  if (children.length === 0) {
    obj['#text'] = node.textContent.trim();
    return obj;
  }
  // 递归进入子节点,把同名兄弟节点收集成数组
  for (const child of children) {
    const value = xmlToJson(child);
    if (obj[child.tagName] === undefined) {
      obj[child.tagName] = value;
    } else {
      if (!Array.isArray(obj[child.tagName])) obj[child.tagName] = [obj[child.tagName]];
      obj[child.tagName].push(value);
    }
  }
  return obj;
}

const doc = new DOMParser().parseFromString(
  '<catalog><product id="P01"><name>Wireless Headphones</name></product></catalog>',
  'text/xml'
);
const json = { [doc.documentElement.tagName]: xmlToJson(doc.documentElement) };
console.log(JSON.stringify(json, null, 2));
// { "catalog": { "product": { "@_id": "P01", "name": "Wireless Headphones" } } }

DOMParser 符合 XML 1.0 规范,能处理 CDATA 和实体引用,还能报告格式良构性错误——全都不需要安装任何包。代价是遍历逻辑要你自己承担,包括上面展示的数组收集规则。

方法 3 —— Python(xmltodict)

在 Python 里,xmltodict 把整个活儿压缩成一条短短的流水线。它默认用 @ 作为属性前缀,用 #text 表示混合内容:

# 在 Python 中用 xmltodict 把 XML 转成 JSON
import json
import xmltodict

xml = """<catalog>
  <product id="P01">
    <name>Wireless Headphones</name>
    <price currency="USD">79.99</price>
  </product>
</catalog>"""

data = xmltodict.parse(xml)
print(json.dumps(data, indent=2))
# {
#   "catalog": {
#     "product": {
#       "@id": "P01",
#       "name": "Wireless Headphones",
#       "price": {
#         "@currency": "USD",
#         "#text": "79.99"
#       }
#     }
#   }
# }

xmltodict 默认把每个值都保留为字符串,这正是你想要的行为。force_list 这个选项最好提前知道,它在「单个还是多个」的数组问题波及到你的代码之前就先把它解决:

# force_list 保证 <product> 永远是列表,即便只有一个
data = xmltodict.parse(xml, force_list={'product'})
products = data['catalog']['product']  # 现在永远是列表
for p in products:
    print(p['name'])

不用 force_list 时,一个 <product> 产出字典、两个则产出列表——于是你的循环在只有单项的情况下崩溃。这就是陷阱 #1,下文会讲到。

方法 4 —— 命令行(yq / Python 一行命令)

对于 shell 脚本和 CI 流水线,两条一行命令能覆盖大多数情况。Mike Farah 的 yq 能直接读 XML 并输出 JSON:

# 使用 yq(Mike Farah 的 Go 版本)
yq -p=xml -o=json '.' input.xml

# 从 stdin 管道输入
cat sitemap.xml | yq -p=xml -o=json '.'

如果 xmltodict 已经在你的环境里,这条 Python 一行命令无需额外的二进制文件:

python3 -c "import sys, xmltodict, json; print(json.dumps(xmltodict.parse(sys.stdin.read()), indent=2))" < input.xml

两者都从 stdin 流式读取,所以能直接嵌进流水线——无论是在脚本中途转换一段 API 响应,还是在构建步骤里批量规整一组文件,都很有用。

详解 @_ 属性约定与 #text 约定

大多数转换器页面跳过了真正要紧的部分:那些古怪的 @_#text 键到底是什么意思、为什么存在。把这两点弄清楚,输出就不再显得莫名其妙。

属性映射为 @_ 前缀的键。 属性在 JSON 里没有对应物——对象里并不存在一个专门用来放「关于这个对象的元数据」、又区别于子节点的位置。约定是给属性一个以 @_ 为前缀的键:

<user id="42" role="admin"/>
→ { "user": { "@_id": "42", "@_role": "admin" } }

为什么偏偏是 @_?因为没有任何合法的 XML 元素名能以 @ 开头,这个前缀永远不会和真实的子元素键冲突。它是一个明摆着却隐藏起来的保留命名空间。(xmltodict 用裸 @fast-xml-parser 默认用 @_。原理完全一致。)

混合内容映射为 #text 当一个元素同时带有属性和文本值时,文本需要一个地方与属性键并存。那就是 #text

<price currency="USD">29.99</price>
→ { "price": { "@_currency": "USD", "#text": "29.99" } }

纯文本元素直接变成一个字符串值。 没有属性、没有子节点,只有文本——那就没必要用 #text 绕这一道。<name>Alice</name> 变成 "name": "Alice"。只有当属性迫使元素值必须是一个对象时,#text 键才会出现。

这种不对称正是某个隐蔽 bug 的根源。同一个元素名,在一份文档里可能产出纯字符串,在另一份里却产出 @_/#text 对象,取决于那个具体实例是否携带了属性。一个没有 currency 属性的 <price> 是字符串 "29.99";而带属性的同一个 <price currency="USD"> 则是 { "@_currency": "USD", "#text": "29.99" }。直接读 node.price 的代码对其中一种形状有效,却在另一种上悄无声息地坏掉。防御性的取值方式是检查类型:const amount = typeof node.price === 'object' ? node.price['#text'] : node.price;

CDATA 变成普通文本内容。 一段 <![CDATA[if (a < b) return;]]> 只是一种转义机制,所以定界符被剥掉,内部文本被保留:"if (a < b) return;"。没有任何特殊的东西会留存进 JSON。

拿到输出后,把它粘进 JSON 格式化工具 校验 JSON 输出,并在接进代码之前确认结构与你的消费方所期望的一致。

XML 转 JSON 的 5 个陷阱及避坑方法

这些是能溜过代码评审、然后在生产环境里冒头的失败。每一个都能追溯到本指南开头讲的模型不匹配。

1. 数组的歧义(一个 vs 多个)。 单个 <item> 变成对象;两个或更多则变成数组。JSON 的形状取决于那份具体文档里恰好有几个兄弟节点。像 result.items.item.forEach(...) 这样的消费方代码在测试时能跑——因为你的测试数据里有三个 item——却在生产环境某条记录恰好只有一个时抛出 TypeError: not a function

// 两个 <book> 兄弟节点 → 数组
// <library><book>A</book><book>B</book></library>
// → { "library": { "book": ["A", "B"] } }

// 一个 <book> → 对象,而非数组
// <library><book>A</book></library>
// → { "library": { "book": "A" } }

// 做归一化,让两种情况表现一致
const books = [].concat(result.library?.book ?? []);
books.forEach(b => console.log(b)); // 对 0、1 或多个都安全

[].concat(x ?? []) 这个惯用法值得记住:缺失的值变成 [],单个对象变成 [object],已有的数组原样通过。在 Python 里,给 xmltodict.parse()force_list={'book'},值就永远是列表,于是你完全跳过归一化这一步。

2. 属性被悄无声息地丢弃。 好几个库默认忽略属性——fast-xml-parser 在你设置 ignoreAttributes: false 之前就是这么干的。转换看起来成功了,JSON 解析也没问题,但你的 idcurrencystatus 值就这么没了。永远显式设置这个标志,别去信任默认值。

3. 命名空间被压平。 一个 xmlns 声明变成一个普通的 @_xmlns 键,而 <soap:Body> 里的前缀只作为字符串键 "soap:Body" 的一部分残存下来。其语义——两个前缀可能绑定到同一个 URI——丢失了。

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>...</soap:Body>
</soap:Envelope>
→ {
    "soap:Envelope": {
      "@_xmlns:soap": "http://schemas.xmlsoap.org/soap/envelope/",
      "soap:Body": "..."
    }
  }

前缀 soap: 现在只是某个键名里的一段文本,没有任何东西知道它是个命名空间。如果来自不同命名空间的两个元素共用一个本地名,它们就可能冲突。当精确的命名空间处理本身就是需求的一部分时,请把数据留在支持命名空间的解析器里,根本别把它压平成 JSON。

4. 不做类型转换——而这是对的。 <zip>01234</zip> 绝不能变成 1234。账号代码、邮政编码、补零的标识符,以及对精度敏感的小数,都会在隐式转换下出问题。一个好的转换器把一切都保留为字符串,让你有意地、自己决定何时转换:

// 别依赖隐式转换
if (config.timeout > 25) { /* 脆弱:碰巧 "30" > 25 能成立 */ }

// 显式转换,只在你确知类型的地方做
if (parseInt(config.timeout, 10) > 25) { /* 安全 */ }

5. 有损:注释、处理指令和混合内容的顺序。 XML 注释(<!-- ... -->)和处理指令(<?xml-stylesheet ?>)在 JSON 里无处安放,会被丢弃。文本与子元素交错排列的相对顺序可能无法往返还原。如果你需要保留每一个字节——为了原样重新输出源文档——那就根本别转换;用 XML 格式化工具 重新排版或压缩,不触碰数据模型。

把 JSON 转回 XML(往返)

反方向走有它自己的曲折,因为 JSON 没有根元素规则,而 XML 要求恰好有一个。配套的 JSON 转 XML 工具 把同样的 @_/#text 约定反向施用,于是一次 JSON → XML → JSON 的往返能保住属性、文本和结构。

有意思的部分是根规整。转换器用四条规则解决单根的要求:

  • 单键对象 → 那个键成为根:{ "config": {...} }<config>...</config>
  • 多键对象 → 包进 <root>{ "a": 1, "b": 2 }<root><a>1</a><b>2</b></root>
  • 顶层数组 → 包成 <root><item>...</item></root>,用 <item> 作为固定的兜底名。
  • 标量值<root>value</root>

其余一切都与正方向镜像对应。@_ 键变成属性,#text 变成文本内容,而某个键下的 JSON 数组产出重复的同名兄弟节点——键名被复用,绝不会去做单数化处理:

// 在 Node.js 中用 fast-xml-parser 把 JSON 转成 XML
import { XMLBuilder } from 'fast-xml-parser';

const data = {
  catalog: {
    product: {
      '@_id': 'P01',
      name: 'Wireless Headphones',
      price: { '@_currency': 'USD', '#text': '79.99' },
    },
  },
};

const builder = new XMLBuilder({
  attributeNamePrefix: '@_', // @_ 键变成属性
  textNodeName: '#text',     // #text 键变成文本内容
  ignoreAttributes: false,   // 处理 @_ 键
  format: true,              // 美化输出
});

console.log(builder.build(data));
// <catalog>
//   <product id="P01">
//     <name>Wireless Headphones</name>
//     <price currency="USD">79.99</price>
//   </product>
// </catalog>

有个细节构建器替你处理好了:文本和属性值里的特殊字符(<>&")会被转义成对应的实体引用,于是输出始终保持格式良构。

常见问题

XML 属性如何映射到 JSON?

属性变成以 @_ 为前缀的键,所以 id="42" 变成 "@_id": "42"。这是 fast-xml-parserxmltodict 共享的约定,而该前缀永远不会和元素名冲突,因为没有合法的元素名以 @ 开头。

为什么 XML 转 JSON 把数字保留为字符串?

因为转换器不做任何类型转换。把 01234 强行变成 1234 会丢掉邮编、账号和补零 ID 里那个有意义的前导零。把每个值都保留为字符串才是安全的默认做法;要转换就在下游、在你确知类型的地方有意地转。

XML 转 JSON 是无损的吗?

不是。注释和处理指令被丢弃,命名空间语义只被部分保留,混合内容的顺序可能无法往返还原。当你需要保留每一个字节时,请用 XML 格式化工具重新排版 XML,而不是把它转成 JSON。

重复的 XML 元素在 JSON 里如何处理?

单个同名子节点变成对象;两个或更多变成数组。因为形状取决于兄弟节点的数量,你的消费方代码应当始终归一化为数组,这样无论是单项还是多项的情况都能应对而不崩溃。

转换成 JSON 时 XML 命名空间会怎样?

一个 xmlns 声明变成一个普通的 @_xmlns 键,而前缀留在元素名字符串里,就像 "soap:Body" 那样。前缀绑定到 URI 的语义不会被解读,所以不同的命名空间可能被压平到一起。

我该如何把 JSON 转回 XML?

使用配套的 JSON 转 XML 工具。它把同样的 @_#text 约定反向施用,于是属性、文本内容和数组都对称地映射回去。正是这种对称性,让一次干净的 JSON → XML → JSON 往返成为可能。

我能转换带多个根元素的 XML 吗?

不能。多个顶层元素不是格式良构的 XML,所以解析器会拒绝该输入。先把这些片段包进单个根元素——把 <a/><b/> 变成 <root><a/><b/></root>——再转换。

结语

XML 转 JSON 是约定驱动的,不是重新排版。这些规则跨运行时保持一致:属性映射为 @_ 键,混合内容的文本映射为 #text,重复的兄弟节点映射为数组,而值保持字符串,于是前导零和精度都能存活。要记住的坑是「单个 vs 数组」的形状突变、被悄无声息丢弃的属性,以及注释和命名空间语义的丢失。它们都不是 bug,一旦你理解了背后的模型不匹配,全都可以预料。

当你需要一次快速、私密的转换时,把内容粘进 XML 转 JSON 工具——它完全在你的浏览器里运行。先用 XML 格式化工具 校验源数据,需要往返的 XML 时再用 JSON 转 XML 工具 走反方向。想进一步了解数据格式模型如何塑造转换行为,参见关于 YAML 与 JSON 差异 的笔记。