JSON、CSV、XML 与 YAML 之间怎么互转
安全地在 JSON、CSV、XML 和 YAML 之间转换:表格扁平化、表头、类型、属性、重复元素、命名空间、YAML schema 与往返校验的实用规则。
CSV 和 XML 最后都会流进 JSON 管道,原因是一样的:源系统比消费它的代码更老、更"表格化",或者更"企业化"。供应商发来一份 CSV 导出,SOAP 服务返回 XML,RSS 订阅要被转成 API 响应。机械的那部分很简单,bug 都出在数据形状的取舍上。
一句话版本:
- CSV → JSON 通常变成一个对象数组,一行一个对象。
- XML → JSON 通常变成一个根对象,属性、文本节点和重复元素按约定映射。
- CSV 的值一开始都是文本。类型转换是可选的,而且应该保守。
- XML 有一些 JSON 没有的概念:属性、命名空间、CDATA、注释、混合内容、重名元素。
- 转换完之后,先校验 JSON、看清结构,再把它交给下一个服务。
- JSON 和 YAML 表达的树很相似,但 YAML 的标签、锚点、重复键和隐式标量类型推断,都需要明确的策略。
这篇两个方向都讲。别指望完美往返 —— 先把映射规则定下来,因为源格式里的东西并不总能在目标格式里表达出来。
映射速查表
| 源结构 | 自然的 JSON 结构 | 要留意 |
|---|---|---|
| CSV 表头行 | 对象的键 | 重复或空的表头 |
| CSV 数据行 | 数组里的一个对象 | 单元格缺失、多出 |
| CSV 单元格值 | 字符串,或谨慎转换后的标量 | 邮政编码、ID、大整数 |
| XML 根元素 | 唯一的顶层键 | 多个根元素不是正常的 XML 文档 |
| XML 属性 | @ 开头的键,比如 @id |
前缀约定必须写进文档 |
| 带属性的 XML 文本 | #text |
混合内容可能有损 |
| 重复的 XML 元素 | 数组 | 一个元素和多个元素会导致结构不同 |
| XML 命名空间前缀 | 键里保留前缀,比如 soap:Envelope |
去掉前缀可能造成键名冲突 |
如果只记一句话:CSV 是表,XML 是树。 JSON 两者都能装,但映射时要做的决定完全不同。
CSV 转 JSON:行映射
典型的 CSV 是一行表头加若干记录:
id,name,active,zip
1,Ada,true,02139
2,Bob,false,94105
通常的 JSON 输出是:
[
{ "id": 1, "name": "Ada", "active": true, "zip": "02139" },
{ "id": 2, "name": "Bob", "active": false, "zip": "94105" }
]
注意这里刻意做的类型选择:id 变成数字是安全的,active 可以变布尔,而 zip 必须留成字符串 —— 前导零是有意义的。
所以"只要长得像数字就转成数字"很危险。好的 CSV 转换器要么把所有值都留成字符串,要么只做保守的类型转换。
别用 split(',') 解析真实的 CSV
这种写法只对玩具级 CSV 有效:
const [header, ...rows] = csv.trim().split('\n');
const keys = header.split(',');
只要某个字段里出现逗号、引号或换行,它立刻就崩:
id,name,note
1,Ada,"Loves compilers, math, and notes"
2,Bob,"Line one
line two"
3,Carol,"He said ""ship it"""
Loves compilers, math, and notes 是一个单元格,不是四个。Bob 那条备注里的换行属于带引号的值,不是行结束。Carol 那条里成对的引号,表示一个字面量引号。
请用解析器。浏览器或 Node 项目里,PapaParse 是常规的生产选择:
import Papa from 'papaparse';
const result = Papa.parse(csvText, {
header: true,
skipEmptyLines: true,
});
console.log(result.data);
写一个本地小工具的话,自己搓个简单的状态机也不是不行 —— 关键是它必须记住"当前是不是在引号里面":
function parseCsvGrid(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuotes) {
if (char === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (char === '"') {
inQuotes = false;
} else {
field += char;
}
continue;
}
if (char === '"') inQuotes = true;
else if (char === ',') { row.push(field); field = ''; }
else if (char === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else if (char !== '\r') field += char;
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
这和 fixjson.org 的 CSV 转换器所用的零依赖方案很接近:识别引号的解析、去掉 BOM、把表头行变成对象的键,并且只在安全时做轻量的类型转换。
CSV 类型转换:保守一点
CSV 本身没有类型,所有单元格都是文本。JSON 则有字符串、数字、布尔、null、数组和对象。转换这一步,要决定是把文本原样留着,还是转换它。
一套安全的转换策略长这样:
| CSV 单元格 | JSON 值 | 为什么 |
|---|---|---|
true |
true |
完全匹配的布尔字面量 |
false |
false |
完全匹配的布尔字面量 |
null |
null |
完全匹配的 null 字面量 |
42 |
42 |
数字能安全往返 |
3.14 |
3.14 |
数字能安全往返 |
007 |
"007" |
前导零可能有意义 |
9007199254740993 |
"9007199254740993" |
超出 JavaScript 安全整数精度 |
| 空单元格 | "" |
空字符串比擅自猜成 null 更稳妥 |
JavaScript 实现:
function coerceCsvCell(value) {
if (value === '') return '';
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null') return null;
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) {
const number = Number(value);
if (Number.isFinite(number) && String(number) === value) {
return number;
}
}
return value;
}
那句 String(number) === value 检查虽小,却很关键。它让 007 保持字符串,也避免假装超出安全范围的大整数还能精确保留。
在 Python 里做 CSV 转 JSON
Python 标准库能正确处理 CSV 的引号规则:
import csv
import json
with open("customers.csv", newline="", encoding="utf-8-sig") as file:
rows = list(csv.DictReader(file))
print(json.dumps(rows, indent=2, ensure_ascii=False))
有两个细节在默默干活:
newline=""让csv模块自己正确处理行尾。encoding="utf-8-sig"会去掉 Excel 或其他 Windows 工具加上的 UTF-8 BOM。
csv.DictReader 返回的都是字符串。想要数字和布尔,就自己加一道逐字段的、有意为之的转换。
会搞砸导入的 CSV 边界情况
重复表头
这份 CSV 有歧义:
id,name,name
1,Ada,Lovelace
一个对象没法在同一个键下同时保住两个 name 值。你得决定:是拒绝重复表头、把它们改名(name、name_2),还是把重复项收进数组。默默覆盖是最糟的选择。
单元格缺失或多余
行数和表头不总是对得上:
id,name,active
1,Ada,true
2,Bob
3,Carol,false,extra
常见策略:
- 缺失的单元格变成
""。 - 多余的单元格直接拒绝。
- 多余的单元格存进一个保留键,比如
_extra。
选一种,并写进文档。要是每一行的形状都略有不同,问题会在后面的导入环节才爆出来。
分隔符不是逗号
"CSV" 常常只是"表格软件导出的文本"的代称。它可能是逗号分隔、制表符分隔,也可能是分号分隔:
- 美式 CSV:
id,name,active - TSV:
id\tname\tactive - 欧洲版 Excel 导出:
id;name;active
如果结果变成了孤零零的一大列,那就是分隔符选错了。要么让用户自己选分隔符,要么用能自动探测分隔符的解析器。
JSON 转 CSV:有意识地把树压平
CSV 期待的是一行表头加一批矩形记录,所以最干净的输入是一个"形状相似的对象"数组:
[
{ "id": "u_1", "name": "Ada", "active": true },
{ "id": "u_2", "name": "Grace", "active": false }
]
id,name,active
u_1,Ada,true
u_2,Grace,false
转换之前先定好列集合。"取第一行的键"很省事,但会漏掉后面才出现的字段;取所有键的并集能保住字段,但可能留下一堆空单元格。要一份稳定的导出约定,最好是显式给出列清单。
嵌套值需要另一套策略:
| JSON 值 | 可能的 CSV 表示 | 代价 |
|---|---|---|
| 嵌套对象 | 压平成 profile.email 这样的列 |
点号可能和字面量键名撞车 |
| 字符串数组 | 用一个约定好的分隔符连接 | 分隔符可能出现在值内部 |
| 任意对象或数组 | 把紧凑 JSON 放进一个带引号的单元格 | 保住了结构,但在表格软件里很别扭 |
| 缺失的属性 | 空单元格,或一个有文档说明的哨兵值 | 否则"空"、"缺失"和 null 会糊成一团 |
健壮的 CSV 写入器必须给含有逗号、引号、CR 或 LF 的字段加引号,并把内部引号写成两个。别用 values.join(',') 拼行。
function csvCell(value) {
const text = value == null ? '' : String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
表格公式注入是另一个输出侧的风险。如果不可信的单元格以 =、+、- 或 @ 开头,表格软件可能会把它当公式执行。请按导入目标的转义建议处理,别指望常规的 CSV 引号能阻止公式执行。
XML 转 JSON:树映射
XML 不是表,而是一棵带属性和文本的元素树:
<user id="1" active="true">
<name>Ada</name>
<role>admin</role>
<role>editor</role>
<note priority="high">Review access</note>
</user>
一种实用的 JSON 映射是:
{
"user": {
"@id": "1",
"@active": "true",
"name": "Ada",
"role": ["admin", "editor"],
"note": {
"@priority": "high",
"#text": "Review access"
}
}
}
很多 XML 转对象的工具用的都是同一套约定:
- 属性 →
@开头的键 - 带属性或子元素的元素文本 →
#text - 重复的子元素 → 数组
- 根元素 → 顶层 JSON 键
XML 转 JSON 并没有通用标准。约定之所以重要,是因为下游代码会依赖它。
XML 属性 vs 子元素
XML 有两种方式表达"id":
<user id="1">
<name>Ada</name>
</user>
以及:
<user>
<id>1</id>
<name>Ada</name>
</user>
这在 XML 里是两种不同的结构,在 JSON 里也要区分开:
{
"user": {
"@id": "1",
"name": "Ada"
}
}
对比:
{
"user": {
"id": "1",
"name": "Ada"
}
}
@ 前缀的作用,就是防止名为 id 的属性和名为 <id> 的子元素撞在一起。
单元素数组问题
这是 XML 转 JSON 在生产环境里最常见的坑:
<roles>
<role>admin</role>
</roles>
往往变成:
{ "roles": { "role": "admin" } }
而:
<roles>
<role>admin</role>
<role>editor</role>
</roles>
变成:
{ "roles": { "role": ["admin", "editor"] } }
同一个字段的类型,居然随数据而变。如果消费方期望 role 永远是数组,那就在解析之后归一化:
const roles = [].concat(doc.roles?.role ?? []);
如果这份数据源有 schema 支撑,请把 XML 解析器配置成:已知可重复的路径一律按数组处理。而对通用的浏览器转换器来说,最稳妥的默认行为是如实反映文档结构,并把约定讲清楚。
XML 文本、CDATA 与混合内容
对数据型 XML 来说,文本通常很简单:
<title>Effective TypeScript</title>
变成:
{ "title": "Effective TypeScript" }
如果元素还带属性,文本就需要一个键来放:
<price currency="USD">9.99</price>
变成:
{ "price": { "@currency": "USD", "#text": "9.99" } }
CDATA 也是文本:
<body><![CDATA[Use <strong>care</strong> here]]></body>
应该变成一个内容为 Use <strong>care</strong> here 的字符串。
混合内容就难办了:
<p>Hello <strong>Ada</strong>, welcome back.</p>
大多数数据型转换器要么把文本拼起来,要么丢掉零散空白,要么返回一份更啰嗦的节点列表。如果你的 XML 是文档型标记而不是数据型 XML,那就做好人工复核输出的准备。
XML 命名空间
命名空间在 JSON 里没有直接对应物:
<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: 去掉确实能让键名好看些,代价是可能把两个本地名相同、实际不同的元素合并到一起。
XML 解析器的安全问题
解析来自不可信来源的 XML 时,当心 DTD 和外部实体。服务端 XML 解析器在解析外部实体、或处理意料之外的 DTD 时,历史上出过不少 XXE 类漏洞。
浏览器端转换用的 DOMParser 不会像老式服务端解析器那样去拉取任意外部实体,但你仍然要把输出当作不可信数据对待。后端代码则要:
- 除非确有需要,否则禁用 DTD 和外部实体解析
- 解析超大文档之前先设好体积上限
- 如果你的转换器只支持数据型 XML,就拒绝文档型 XML
- 用一个仍在维护、默认配置安全的解析器
fixjson.org 的 XML 转换器有意做成零依赖、面向典型数据型 XML 的尽力而为方案:它跳过处理指令和 DTD 类声明,用 @ 映射属性,把混合文本映射到 #text,并解码常见的 XML 实体。
JSON 转 XML:定好元素名、属性和重复元素
JSON 没有必需的根元素,没有必需的属性名,数组元素也没有必需的元素名。这些全都得由转换器定约定。
把 @ 和 #text 这套约定反过来用:
{
"user": {
"@id": "u_1",
"name": "Ada",
"role": ["admin", "editor"],
"note": { "@priority": "high", "#text": "Review access" }
}
}
变成:
<user id="u_1">
<name>Ada</name>
<role>admin</role>
<role>editor</role>
<note priority="high">Review access</note>
</user>
序列化器必须转义文本里的 &、<、>,以及属性里的引号;对不是合法 XML 名称的对象键,要么拒绝、要么编码。根部是 JSON 数组时,还需要调用方提供外层包装名和元素名,比如 <users><user>...</user></users>。
null 在 XML 里没有通用表示。可选方案包括空元素、直接省略、字面量文本,或者 XML Schema 实例的 nil 属性。按接收系统要求的约定来选。布尔和数字同理:除非有 schema 或事先约定的映射,XML 文本保不住它们在 JSON 里的类型。
往返转换未必能得到相同的字节。即便数据一致,属性顺序、无意义空白、命名空间前缀、CDATA 与转义文本的选择、数字格式都可能不同。请比较解析后的数据模型,而不是原始 XML 字符串。
JSON 与 YAML:数据模型相似,安全规则不同
对普通的对象、数组、标量和 null 来说,JSON 是 YAML 1.2 数据模型的子集,所以合法 JSON 通常能被 YAML 1.2 解析器直接解析。YAML 额外提供了注释、块字符串、锚点、别名、标签、多文档,以及更精简的语法。
user:
name: Ada
active: true
roles:
- admin
- editor
很自然地转成:
{
"user": {
"name": "Ada",
"active": true,
"roles": ["admin", "editor"]
}
}
请用维护良好的安全加载器,别自己写 YAML 解析器,也别开启任意对象构造:
import YAML from 'yaml';
const value = YAML.parse(yamlText);
const jsonText = JSON.stringify(value, null, 2);
import json
import yaml
value = yaml.safe_load(yaml_text)
json_text = json.dumps(value, indent=2)
转换之前先过一遍这几种情况:
- 重复的映射键可能被拒绝,也可能某一个值悄悄胜出 —— 取决于库和选项。
- 锚点和别名会产生共享引用;JSON 序列化会把树展开,而且表示不了环。
- 自定义 YAML 标签在 JSON 里没有标准对应物。
- 注释会消失,因为 JSON 没有注释。
- 多份 YAML 文档需要拆成多个输出值、一个数组,或者一套 NDJSON 策略。
- YAML 1.1 和 1.2 的库对标量的推断可能不同;标识符、类日期字符串这类有歧义的值,请加引号。
如果数据本身就与 JSON 兼容,那把 JSON 转成 YAML 基本只是呈现方式的选择。但只要这个文件还得可预测地转回去,就别加锚点、别依赖隐式类型推断、别用自定义标签。格式化器能修好缩进,却判断不出某个标量本该是字符串还是数字。
校验转换出来的 JSON
转换完成后,在结果进入下一个系统之前,快速过一遍:
- 这份 JSON 能解析吗?
- 需要保留前导零的 ID 和邮政编码,还是字符串吗?
- 消费方期望是数组的地方,重复的 XML 元素真的变成数组了吗?
- 属性是不是落在了你的代码预期的那个前缀下?
- 空单元格变成了
""、null、缺失的键,还是别的什么? - 如果之后还要转回 XML,命名空间保住了吗?
一次性的浏览器操作,把转换结果粘进 JSON 校验器,或者在 JSON 查看器里看一眼。数据敏感的话,请用本地浏览器工具,别把客户导出的数据传到某个来路不明的在线格式化站点。
在浏览器里转换
fixjson.org 有两个相关的本地工具:
- JSON 转 CSV 转换器 —— JSON 转 CSV 以及 CSV 转回 JSON;CSV 解析会正确处理带引号的逗号、带引号的换行、成对引号、BOM 剥离,以及保守的类型转换。
- JSON 转 XML 转换器 —— 按上文的
@/#text/ 重复元素约定,在 JSON 和 XML 之间互转。
两个都在你的浏览器里运行。你的表格导出、API 载荷和 XML 数据源不会被上传到服务器。
常见问题
怎么把 CSV 转成 JSON?
用能识别引号的 CSV 解析器,把第一行当表头,然后把后面每一行映射成以这些表头为键的对象。安全时才做类型转换 —— ID、邮政编码和大整数通常需要保持字符串。
为什么我 CSV 转出来的值全是字符串?
因为 CSV 没有原生类型,每个单元格一开始都是文本。转换器可以选择把完全匹配的 true、false、null 和无损的数字转过去,但 007 这类有歧义的值应该留作字符串。
转 JSON 时,XML 属性该怎么处理?
用一套能把属性和子元素区分开的约定。常见做法是给属性加 @ 前缀,于是 <user id="1"> 变成 { "user": { "@id": "1" } }。
为什么我 XML 转 JSON 的结果,有时是对象有时是数组?
因为很多转换器是靠数据本身来决定结构的:一个 <role> 变成字符串或对象,两个 <role> 就变成数组。请在解析后对已知可重复的字段做归一化,或者把解析器配置成对这些路径始终按数组处理。
该把 XML 的值转成数字和布尔吗?
只有当你的 schema 明确说这些字段是数字或布尔时才转。XML 的文本和属性都是字符串,盲目强转会毁掉 ID、编码和高精度数值。
转换来自不可信用户的 XML 安全吗?
请用禁用了 DTD 和外部实体的解析器,设置体积上限,并把转换出来的 JSON 当作不可信输入。XML 转换改的只是格式,它不改业务规则,也不会校验业务规则。
嵌套的 JSON 怎么转成 CSV?
先选一套明确的扁平化策略:已知的嵌套对象用点号列名,简单数组用连接符拼接,任意嵌套数据就放一段紧凑 JSON 到带引号的单元格里。没有这类约定,CSV 装不下一棵通用的 JSON 树。
JSON 的 null 在 XML 或 CSV 里该怎么表示?
两种格式都没有通用等价物。CSV 里用空单元格,或者一个有文档说明的哨兵值。XML 里可以选择省略、空元素、字面量文本,或者(schema 相关的)nil 属性。这应该由接收方的约定来决定。
是不是所有 YAML 文件都能干净地转成 JSON?
不是。注释、自定义标签、带环的锚点、非字符串的映射键和多文档,在 JSON 里都没有直接对应物。请使用安全加载器、禁止重复键,并逐一复核那些被隐式推断了类型的标量。
转换、校验与格式化
- JSON 转 CSV 转换器 —— 在浏览器里做 JSON 与 CSV 互转
- JSON 转 XML 转换器 —— JSON 与 XML 互转
- YAML 转 JSON —— 本地转换 YAML 并格式化 JSON 结果
- JSON 校验器 —— 确认转换出来的 JSON 合法
- JSON 查看器 —— 使用之前先看清转换后的结构
- JSON Fix —— 再次转换之前,先修好不太合法的 JSON
参考资料
- RFC 4180 —— 通用的 CSV 格式规则,包括带引号的字段
- Python csv 模块 —— 标准库的 CSV 解析与写入
- PapaParse —— JavaScript 里健壮的 CSV 解析器
- fast-xml-parser —— JavaScript 的 XML 转对象解析器
- xmltodict —— Python 的 XML 转 dict 工具
- OWASP XML 外部实体防护速查表 —— 处理不可信 XML 时的解析器加固建议
- YAML 1.2.2 规范 —— YAML 语法、表示模型,以及与 JSON 的关系
- yaml JavaScript 库 —— JavaScript 的 YAML 解析与序列化
- PyYAML 文档 —— 安全加载与 Python YAML API
最后校订于 2026 年 8 月。