← 全部文章

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 值。你得决定:是拒绝重复表头、把它们改名(namename_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

转换完成后,在结果进入下一个系统之前,快速过一遍:

  1. 这份 JSON 能解析吗?
  2. 需要保留前导零的 ID 和邮政编码,还是字符串吗?
  3. 消费方期望是数组的地方,重复的 XML 元素真的变成数组了吗?
  4. 属性是不是落在了你的代码预期的那个前缀下?
  5. 空单元格变成了 ""null、缺失的键,还是别的什么?
  6. 如果之后还要转回 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 没有原生类型,每个单元格一开始都是文本。转换器可以选择把完全匹配的 truefalsenull 和无损的数字转过去,但 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 里都没有直接对应物。请使用安全加载器、禁止重复键,并逐一复核那些被隐式推断了类型的标量。

转换、校验与格式化

参考资料

最后校订于 2026 年 8 月。