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

结论:导出给人看用字段扁平化(嵌套键用点号拼接、数组序列化成 JSON 字符串),导入数据库用主表加关联表拆分,做数据透视再展开成多行;反向还原时按显式列定义转型,不要全局智能推断。
把树压平成表,一定会丢层级;把表还原成树,一定会猜类型。两边都要提前说清规则。
一、CSV 到底规定了什么
RFC 4180 只有几页,规定的内容比很多人想的更少:
- 每条记录一行,字段用逗号分隔;
- 行结束符是 CRLF,但实践中
\n也被接受; - 字段含逗号、双引号或换行时,必须用双引号包裹;
- 字段内的双引号写成两个双引号
""; - 第一行可以是表头;
- 没有规定字符编码,也没有规定类型。
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 或空串。把这组期望写成测试,比口头约定有效。