JSON 工具箱

JSON 与 CSV、表格数据的互转

结论:CSV 是二维的,JSON 是树形的。转换的核心不是写代码,而是先决定「数组怎么摊、类型怎么留」。

入门约 10 分钟发布于 2026-10-30更新于 2026-10-30
JSON 与 CSV、表格数据的互转封面

结论:导出给人看用字段扁平化(嵌套键用点号拼接、数组序列化成 JSON 字符串),导入数据库用主表加关联表拆分,做数据透视再展开成多行;反向还原时按显式列定义转型,不要全局智能推断。

把树压平成表,一定会丢层级;把表还原成树,一定会猜类型。两边都要提前说清规则。

一、CSV 到底规定了什么

RFC 4180 只有几页,规定的内容比很多人想的更少:

  1. 每条记录一行,字段用逗号分隔;
  2. 行结束符是 CRLF,但实践中 \n 也被接受;
  3. 字段含逗号、双引号或换行时,必须用双引号包裹;
  4. 字段内的双引号写成两个双引号 "";
  5. 第一行可以是表头;
  6. 没有规定字符编码,也没有规定类型。
id,name,remark
1,张三,"喜欢 JSON,讨厌尾逗号"
2,李四,"他说:""这行有引号"""
3,王五,"多行
备注"

注意第 3 条:带换行的字段是合法的,所以「按 \n 切分再按 , 切分」的朴素解析器一定会错。

二、JSON → CSV:先决定结构

树形结构摊平成二维表,只有三种策略:

策略 1:字段扁平化(最常见)

{ "id": 1, "user": { "name": "张三", "city": "杭州" }, "tags": ["a", "b"] }
id,user.name,user.city,tags
1,张三,杭州,"[""a"",""b""]"
  • 嵌套对象用 . 连接键名;
  • 数组无法用列表达,直接 JSON.stringify 后塞进单元格(这也是合法的 CSV 字段);
  • 缺点:数组无法被表格工具筛选,需要二次解析。

策略 2:数组展开成多行

id,user.name,tag
1,张三,a
1,张三,b

适合「一对多」且下游要按元素聚合的场景。缺点是父级字段重复,行数膨胀。

策略 3:拆成多张表

主表 + 关联表,用 id 关联——这是最干净的做法,但已经超出「一次导出」的范围,更像数据建模。

选哪个? 导出给人看用策略 1,导入数据库用策略 3,做数据透视用策略 2。

三、CSV → JSON:类型怎么猜

CSV 没有类型,还原时每一步都是猜测:

单元格内容 可能含义 直接转数字的风险
00123 工号、邮编 前导零丢失 → 123
1.10 版本号、金额 变成 1.1
1e5 编号还是科学计数 变成 100000
2026-10-30 日期还是字符串 时区与格式歧义
13800138000 手机号 超过 11 位仍安全,但某些 ID 会丢精度
"" 空字符串还是 null 语义分歧

建议默认策略:全部当字符串,由 schema 或显式列定义决定哪些列转数字/布尔。 「智能推断」在导出场景很爽,在导入场景是事故来源。

type ColumnType = 'string' | 'number' | 'boolean' | 'json';

/**
 * 按列定义把 CSV 行转换为对象。
 * @param rows 原始行数组,第一行为表头
 * @param columnTypes 列名到类型的映射
 * @returns 转换后的对象数组
 */
function rowsToRecords(
  rows: string[][],
  columnTypes: Record<string, ColumnType>,
): Array<Record<string, unknown>> {
  const [headerRow, ...dataRows] = rows;

  return dataRows.map((dataRow) => {
    const record: Record<string, unknown> = {};

    headerRow.forEach((columnName, columnIndex) => {
      const rawValue = dataRow[columnIndex] ?? '';
      const columnType = columnTypes[columnName] ?? 'string';

      if (columnType === 'number') {
        const numericValue = Number(rawValue);
        record[columnName] = Number.isFinite(numericValue) ? numericValue : null;
        return;
      }
      if (columnType === 'boolean') {
        record[columnName] = rawValue === 'true' || rawValue === '1';
        return;
      }
      if (columnType === 'json') {
        try {
          record[columnName] = JSON.parse(rawValue);
        } catch {
          record[columnName] = rawValue;
        }
        return;
      }
      record[columnName] = rawValue;
    });

    return record;
  });
}

四、Excel 相关的三个坑

4.1 编码

Excel 打开 UTF-8 无 BOM 的 CSV,中文会乱码。导出时加 BOM(\uFEFF)是最省事的兼容做法:

const bom = '\uFEFF';
const csvContent = `${bom}${headerLine}\n${bodyLines.join('\n')}`;

4.2 长数字变科学计数法

18 位身份证号、19 位订单号在 Excel 里会显示成 1.23457E+17,而且值本身已被截断。 导出时在字段前加制表符或等号包裹(="1234...")能保持文本格式,但那会让单元格变成公式。 更稳的做法是:告诉用户「用导入功能并指定列为文本」,或在导出的同时提供 .xlsx。

4.3 分隔符受区域设置影响

欧洲部分地区默认分号 ; 作为分隔符。跨区域分发时,要么固定 CSV 用逗号并说明, 要么提供 TSV(制表符)版本——TSV 的区域兼容性更好。

五、大文件怎么处理

十万行以上的 CSV 不要一次读进内存:

import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';

const parser = createReadStream('orders.csv').pipe(
  parse({ columns: true, skipEmptyLines: true, bom: true }),
);

for await (const record of parser) {
  // 逐条处理,内存占用与文件大小无关
}

导出方向同理:边生成边写流,不要拼一个巨大的字符串。

六、往返一致性自检

CSV 往返(JSON → CSV → JSON)几乎不可能完全一致,但可以定义「允许的损失」:

检查项 期望
字段数量 一致
字符串值 一致(含前导零)
数字类型 由列定义决定,不靠推断
嵌套对象 通过点号路径可还原
数组 序列化为 JSON 字符串后可见
空值 统一为 null 或空串,二者只能选一个

把这张表写进测试,比口头约定有效得多。

七、检查清单

  • [ ] 明确「一行一条记录」的语义,并在导出文件名里体现;
  • [ ] 所有可能丢精度的列,导出为字符串;
  • [ ] 中文 CSV 加 BOM,或明确注明编码;
  • [ ] 带逗号、引号、换行的字段正确转义(不要自己拼字符串,用库);
  • [ ] 大文件走流式读写;
  • [ ] 导入时按列定义转型,不做全局智能推断。

常见问题

如何选择扁平化、展开行与拆表三种策略?

导出给人看用字段扁平化:嵌套对象用点号拼接键名,数组直接 JSON.stringify 塞进单元格。导入数据库用拆表:主表加关联表、用 id 关联最干净。做数据透视才展开成多行,代价是父级字段重复、行数膨胀。

为什么 CSV 导入时不应该自动推断类型?

RFC 4180 只规定分隔与转义,没有规定类型,所以每一步还原都是猜测。00123 会丢前导零,1.10 变成 1.1,1e5 变成 100000。默认把全部字段当字符串,再由 schema 或显式列定义决定哪些列转数字或布尔。

CSV 能否无损还原成原始 JSON?

几乎不可能完全一致。可以定义允许的损失:字段数量一致、字符串值含前导零一致、数字类型由列定义决定、嵌套对象通过点号路径还原、数组序列化后可见、空值统一为 null 或空串。把这组期望写成测试,比口头约定有效。