JSON 与 YAML、XML 的互转边界
结论:JSON 是 YAML 的子集,这句话只保证了单向成立;而 XML 根本没有官方 JSON 映射,全靠约定。

结论:以 JSON 作为中间格式最稳妥:先把源格式解析成数据模型,再序列化为目标格式;转换前先确认注释、XML 属性与命名空间、以及数字和布尔类型是否需要保留。
一条转换链路能不能用,不取决于它转得多漂亮,而取决于它丢了多少东西你有没有意识到。
一、JSON 与 YAML:单向包含
YAML 1.2 规范明确写了:JSON 是 YAML 的一个子集。也就是说任何合法 JSON 都是合法 YAML:
{"name": "json", "tags": ["a", "b"], "count": 3}
这段既是 JSON,也是 YAML 的流式写法。反过来不成立。
| 特性 | JSON | YAML 1.2 |
|---|---|---|
| 注释 | ✗ | ✓ |
| 锚点与别名 | ✗ | ✓ |
多文档(---) |
✗ | ✓ |
| 非字符串键 | ✗ | ✓(1: a、true: b) |
| 复杂键 | ✗ | ✓(? [a, b]) |
| 缩进敏感 | ✗ | ✓(不接受 Tab 缩进) |
| 类型推断 | 显式 | 隐式(0o17、0x1F、.inf) |
| 单行可读性 | 好 | 差(长内容靠折叠语法) |
1.1 类型推断是最大的坑
YAML 会把一些看起来像字符串的值解析成别的类型:
zip: 01234 # 字符串还是八进制整数?YAML 1.2 core schema 视为字符串
version: 1.10 # 1.1(浮点),语义丢失
country: NO # YAML 1.2 是字符串;YAML 1.1 是布尔 false
date: 2026-10-30 # 部分实现会解析成日期对象
NO 这个例子害过很多人:使用 YAML 1.1 解析器(部分老库)时,country: NO 变成 false。
对策:所有「长得像数字或布尔」的标识符一律加引号,version: "1.10"。
1.2 锚点、别名与多文档
defaults: &defaults
retries: 3
timeout: 30
serviceA:
<<: *defaults
host: a.example.com
---
serviceB:
<<: *defaults
host: b.example.com
展开锚点后转成 JSON,通常会得到两份重复的配置对象,或者一个数组(多文档必须转成 [{...}, {...}])。
无论哪种,反向转换时锚点不会恢复——这是必然的有损。
1.3 保留注释的转换
多数库转换时直接丢注释。需要保留时用 yaml 包的 Document API:
import { parseDocument } from 'yaml';
const document = parseDocument(`
# 服务配置
port: 8080
`);
document.set('port', 3000);
console.log(document.toString());
// # 服务配置
// port: 3000
判断标准很简单:如果这份文件会被人反复编辑,就别用「解析 → 对象 → 序列化」的路径。
1.4 往返一致性怎么验
/**
* 校验 JSON → YAML → JSON 的往返一致性。
* @param sourceValue 原始 JSON 值
* @param stringify YAML 序列化函数
* @param parse YAML 解析函数
* @returns 是否完全一致
*/
function isRoundTripStable(
sourceValue: unknown,
stringify: (input: unknown) => string,
parse: (input: string) => unknown,
): boolean {
const yamlText = stringify(sourceValue);
const restored = parse(yamlText);
return JSON.stringify(restored) === JSON.stringify(sourceValue);
}
用真实数据跑一遍,比读十遍文档有用。重点构造这些样例:null、空字符串、空数组、"00"、"1e5"、
负零、超过 2^53 的整数、含换行的长字符串。
二、JSON 与 XML:没有标准的映射
XML 比 JSON 早得多,结构能力也更强(属性、命名空间、混合内容、DTD)。两者之间没有官方映射规范, 所有工具都在做同一件事:选一套约定,然后尽量别丢。
2.1 常见约定
| 约定 | 表示 | 示例 |
|---|---|---|
| 元素 → 对象键 | 同名元素变字段 | <a>1</a> → {"a": "1"} |
属性 → @ 前缀 |
@name |
<a id="1"/> → {"a": {"@id": "1"}} |
文本 → #text |
有属性又有文本时 | <a id="1">hi</a> → {"a": {"@id": "1", "#text": "hi"}} |
| 重复元素 → 数组 | 出现两次以上则是数组 | <i>1</i><i>2</i> → {"i": ["1","2"]} |
| 命名空间 → 前缀 | ns:tag |
<ns:tag/> → {"ns:tag": ...} |
<order id="A1001">
<item sku="X1" qty="2">笔记本</item>
<item sku="X2" qty="1">钢笔</item>
</order>
{
"order": {
"@id": "A1001",
"item": [
{ "@sku": "X1", "@qty": "2", "#text": "笔记本" },
{ "@sku": "X2", "@qty": "1", "#text": "钢笔" }
]
}
}
2.2 必然有损的五处
- 元素顺序:JSON 对象无序,XML 是有序的元素序列;
- 属性与子元素:在 XML 里语义不同,在 JSON 里都变成字段;
- 混合内容:
<p>你好 <b>世界</b> 再见</p>这类结构无法用普通对象表达; - 命名空间声明:
xmlns是作用域概念,转成扁平字段后作用域丢失; - CDATA 与实体:
<![CDATA[...]]>与&在 JSON 里都只是文本。
2.3 「一个元素还是数组」的两难
<list><item>1</item></list>
只有一个 item 时,xml2js 默认给对象而不是数组;出现两个才给数组。
这导致前端代码必须写 Array.isArray(x) ? x : [x]。
对策:解析时统一开启「始终数组」选项,或依据 schema 声明字段为数组类型。
import { parseStringPromise } from 'xml2js';
const parsed = await parseStringPromise(xmlText, {
explicitArray: true, // 单元素也返回数组
trim: true,
explicitRoot: false,
});
2.4 安全提醒
解析不可信 XML 前必须禁用外部实体,否则可能触发 XXE(读取本地文件、内网探测):
// Node 生态通常通过解析器选项关闭 DTD 与外部实体
const parserOptions = { doctype: false, externalEntities: false };
XML → JSON 只是转换的第一层,转换并不消除 XML 带来的攻击面。
三、三种格式的适用位置
| 场景 | 推荐格式 | 原因 |
|---|---|---|
| 对外 HTTP 接口 | JSON | 浏览器与所有语言原生支持 |
| 人工维护的配置 | YAML | 注释、缩进、可读性 |
| 需要强 schema 与命名空间的文档(SOAP、Office Open XML、SVG) | XML | 结构能力与既有生态 |
| 与运维工具交互(K8s、CI) | YAML | 生态默认 |
| 数据落盘、缓存、日志 | JSON / JSONL | 通用、零依赖 |
四、转换实现要点
import { stringify as toYaml } from 'yaml';
/**
* 把 JSON 值转成 YAML 文本,保留中文不转义。
* @param value 待转换的 JSON 值
* @returns YAML 文本
*/
function jsonToYaml(value: unknown): string {
return toYaml(value, {
indent: 2,
lineWidth: 0, // 不自动折行,避免长字符串被切
defaultStringType: 'QUOTE_DOUBLE',
});
}
几个实用参数:
lineWidth: 0关闭自动折行,否则长 URL 会被切成多行,人眼难读;- 统一双引号,可避免
NO、1.10这类值被误判; - 数字精度敏感的数据,先转成字符串再序列化。
XML 方向建议固定一份映射函数,把「属性前缀」「文本键名」「数组策略」写进函数签名,而不是散落在各处:
type XmlMappingOptions = {
attributePrefix: string;
textKey: string;
alwaysArray: boolean;
};
const defaultMapping: XmlMappingOptions = {
attributePrefix: '@',
textKey: '#text',
alwaysArray: false,
};
五、检查清单
- [ ] YAML 里所有「像数字/布尔」的标识符加了引号;
- [ ] 明确 JSON → YAML → JSON 的验证样例集,并纳入测试;
- [ ] 需要保留注释时走 Document API,不做对象往返;
- [ ] XML 映射约定写进文档,团队共用一套参数;
- [ ] 解析不可信 XML 时禁用 DTD 与外部实体;
- [ ] 转换前后的字节数、字段数做一次对照,异常即报警。
常见问题
JSON、YAML、XML 之间应该如何选择转换方向?
优先把 JSON 作为中间格式:YAML 与 XML 都先解析成数据模型,再从模型输出目标格式,避免直接做文本替换。需要保留注释时走 YAML Document API;需要保留 XML 属性与命名空间时,先固定一套映射约定。
为什么 XML 转 JSON 会丢失属性和命名空间?
JSON 没有属性与命名空间的概念,XML 的属性、前缀和命名空间必须被压进普通键值,常见做法是加 @ 前缀或 #text 节点。映射规则一旦不统一,同一份 XML 就会得到不同结构的 JSON,往返后无法还原。
是否可以把三种格式长期双向同步?
不建议。注释、锚点、XML 属性和命名空间在转换中不可逆,双向同步会不断累积差异。更稳妥的做法是选定一种源格式,把其他格式当作一次性导出或临时查看结果。