JSON 与 CSV 互转完全指南:嵌套数据扁平化、大文件流式处理与编码陷阱

深度解析 JSON 与 CSV 数据格式互转的技术细节,涵盖嵌套对象扁平化算法、大文件流式处理、编码兼容性问题,以及 Node.js 和浏览器端的完整实现方案。

JSON 工具 2026-06-04 15 分钟

在日常开发中,JSON 与 CSV 的互转是最高频的数据处理操作之一——据 npm 统计,papaparse 包周下载量超过 350 万次,csv-parse 也超过 200 万次。然而,这个看似简单的转换背后隐藏着大量工程陷阱:嵌套对象如何扁平化?包含逗号和换行的字段怎么处理?1GB 的 JSON 文件如何避免内存溢出?本文将从算法实现到生产实践,彻底讲透 JSON 与 CSV 互转的技术细节。

🔧 一、JSON 转 CSV:从简单到复杂的四种场景

JSON 转 CSV 的难度取决于数据结构的复杂度。我们按递进顺序分析每种场景的处理方案。

1.1 扁平 JSON 数组:最简单但有坑

最简单的场景是扁平的 JSON 数组,每个对象的 key 完全一致:

[
  {"name": "张三", "age": 28, "city": "北京"},
  {"name": "李四", "age": 32, "city": "上海"}
]

看似简单,但有一个常见错误——直接用字符串拼接:

// ❌ 错误写法:直接拼接会导致包含逗号的字段被错误分割
function jsonToCsvNaive(data) {
  const headers = Object.keys(data[0]);
  const rows = data.map(row => headers.map(h => row[h]).join(','));
  return [headers.join(','), ...rows].join('\n');
}

// 测试:包含逗号的字段
const data = [{ name: "张三,博士", age: 28 }];
console.log(jsonToCsvNaive(data));
// 输出: name,age
//       张三,博士,28  ← 错误!age 列被吞掉了

正确做法是使用 RFC 4180 标准的转义规则:

// ✅ 正确写法:遵循 RFC 4180 标准
function escapeCsvField(value) {
  if (value === null || value === undefined) return '';
  const str = String(value);
  // 包含逗号、双引号、换行符时需要用双引号包裹
  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
    return '"' + str.replace(/"/g, '""') + '"';
  }
  return str;
}

function jsonToCsv(data) {
  if (!data.length) return '';
  const headers = Object.keys(data[0]);
  const headerLine = headers.map(escapeCsvField).join(',');
  const rows = data.map(row =>
    headers.map(h => escapeCsvField(row[h])).join(',')
  );
  return [headerLine, ...rows].join('\n');
}

// 测试:各种边界情况
const testData = [
  { name: "张三,博士", age: 28, note: '他说"你好"' },
  { name: "李四\n工程师", age: 32, note: null },
];
console.log(jsonToCsv(testData));
// 输出:
// name,age,note
// "张三,博士",28,"他说""你好"""
// "李四
// 工程师",32,

📌 **记住:**CSV 字段中包含逗号、双引号、换行符时,必须用双引号包裹字段,字段内的双引号需要转义为两个双引号。这是 RFC 4180 标准的核心规则。

1.2 嵌套 JSON 扁平化:算法设计

真实场景中,JSON 数据往往是嵌套的。将嵌套结构扁平化为 CSV 的关键决策是:用什么分隔符连接嵌套路径?

// 嵌套 JSON 示例
const nestedData = [
  {
    id: 1,
    user: { name: "张三", address: { city: "北京", district: "朝阳" } },
    scores: [90, 85, 92],
    tags: ["前端", "Vue"]
  }
];

扁平化算法实现:

// ✅ 递归扁平化嵌套 JSON 对象
function flattenObject(obj, prefix = '', separator = '.') {
  const result = {};

  for (const [key, value] of Object.entries(obj)) {
    const newKey = prefix ? `${prefix}${separator}${key}` : key;

    if (value === null || value === undefined) {
      result[newKey] = '';
    } else if (Array.isArray(value)) {
      // 策略选择:数组如何处理?
      // 方案A:展开为 user.tags.0, user.tags.1(保留索引)
      // 方案B:序列化为 JSON 字符串(保留结构)
      // 方案C:用分隔符连接(适合简单值数组)
      if (value.every(v => typeof v !== 'object')) {
        // 简单值数组:用分号连接
        result[newKey] = value.join(';');
      } else {
        // 复杂对象数组:保留索引
        value.forEach((item, index) => {
          if (typeof item === 'object' && item !== null) {
            Object.assign(result, flattenObject(item, `${newKey}${separator}${index}`, separator));
          } else {
            result[`${newKey}${separator}${index}`] = item;
          }
        });
      }
    } else if (typeof value === 'object') {
      Object.assign(result, flattenObject(value, newKey, separator));
    } else {
      result[newKey] = value;
    }
  }

  return result;
}

// 测试
const flat = flattenObject(nestedData[0]);
console.log(JSON.stringify(flat, null, 2));
// {
//   "id": 1,
//   "user.name": "张三",
//   "user.address.city": "北京",
//   "user.address.district": "朝阳",
//   "scores": "90;85;92",
//   "tags": "前端;Vue"
// }

⚠️ **警告:**扁平化分隔符的选择很重要。用 . 可能与 key 本身的点号冲突(如 MongoDB 的嵌套字段)。如果 key 可能包含点号,建议用 /__ 作为分隔符。

1.3 处理非结构化数据:列合并策略

当数组中每个对象的 key 不完全一致时,需要做列合并:

// ✅ 处理 key 不一致的 JSON 数组
function jsonToCsvFlexible(data) {
  // 收集所有可能的 key(保持首次出现顺序)
  const headerSet = new Set();
  for (const row of data) {
    const flat = flattenObject(row);
    Object.keys(flat).forEach(k => headerSet.add(k));
  }
  const headers = Array.from(headerSet);

  const headerLine = headers.map(escapeCsvField).join(',');
  const rows = data.map(row => {
    const flat = flattenObject(row);
    return headers.map(h => escapeCsvField(flat[h])).join(',');
  });

  return [headerLine, ...rows].join('\n');
}

// 测试:key 不一致的数据
const irregularData = [
  { name: "张三", age: 28, email: "zhang@example.com" },
  { name: "李四", age: 32, phone: "13800138000" },  // 没有 email,多了 phone
  { name: "王五", email: "wang@example.com", phone: "13900139000" }  // 没有 age
];

console.log(jsonToCsvFlexible(irregularData));
// 输出:
// name,age,email,phone
// 张三,28,zhang@example.com,
// 李四,32,,13800138000
// 王五,,wang@example.com,13900139000

🚀 二、大文件处理:流式方案与性能对比

当 JSON 文件超过 100MB 时,一次性加载到内存会导致 Node.js 崩溃。流式处理是唯一可行的方案。

2.1 方案对比:三种流式处理方式

方案 内存占用 速度 复杂度 适用场景
JSON.parse() 全量加载 高(≈2-3 倍文件大小) 文件 < 50MB
stream-json 流式解析 低(常量级) 任意大小 JSON
jq 命令行工具 服务端批处理

2.2 Node.js 流式实现

// ✅ 使用 stream-json 流式处理大 JSON 文件
const { parser } = require('stream-json');
const { streamArray } = require('stream-json/streamers/StreamArray');
const { chain } = require('stream-chain');
const fs = require('fs');
const { Transform } = require('stream');

async function streamJsonToCsv(inputPath, outputPath) {
  const pipeline = chain([
    fs.createReadStream(inputPath),
    parser(),
    streamArray(),
    new Transform({
      objectMode: true,
      transform(chunk, encoding, callback) {
        if (this._first) {
          // 第一个对象:提取 header
          const flat = flattenObject(chunk.value);
          const headers = Object.keys(flat);
          this._headers = headers;
          // 写入 CSV header
          this.push(headers.map(escapeCsvField).join(',') + '\n');
          this._first = false;
        }
        // 写入数据行
        const flat = flattenObject(chunk.value);
        const line = this._headers.map(h => escapeCsvField(flat[h])).join(',') + '\n';
        this.push(line);
        callback();
      },
      _first: true,
      _headers: []
    }),
    fs.createWriteStream(outputPath)
  ]);

  return new Promise((resolve, resolve) => {
    pipeline.on('end', resolve);
    pipeline.on('error', reject);
  });
}

// 使用:处理 1GB JSON 文件,内存占用仅约 50MB
await streamJsonToCsv('large-data.json', 'output.csv');

💡 **提示:**对于超大文件(> 1GB),建议先用 stream-jsonstreamValues 模式逐条处理,配合 highWaterMark 参数控制背压。Node.js 默认的 16KB 缓冲区对于 CSV 写入可能偏小,建议调大到 64KB。

2.3 浏览器端大文件处理

浏览器端处理大文件时,可以使用 Web Worker + File API 的组合:

// ✅ 浏览器端 Web Worker 处理大 JSON 文件
// worker.js
self.onmessage = function(e) {
  const { file, chunkSize = 1024 * 1024 } = e.data;
  const reader = new FileReader();

  let offset = 0;
  let headers = null;
  const csvChunks = [];

  function processChunk(chunk) {
    const data = JSON.parse(chunk);
    if (!headers) {
      const flat = flattenObject(data[0]);
      headers = Object.keys(flat);
      csvChunks.push(headers.map(escapeCsvField).join(',') + '\n');
    }
    for (const row of data) {
      const flat = flattenObject(row);
      csvChunks.push(headers.map(h => escapeCsvField(flat[h])).join(',') + '\n');
    }
    self.postMessage({
      type: 'progress',
      percent: Math.round((offset / file.size) * 100)
    });
  }

  reader.onload = function(e) {
    try {
      processChunk(e.target.result);
      self.postMessage({ type: 'complete', csv: csvChunks.join('') });
    } catch (err) {
      self.postMessage({ type: 'error', message: err.message });
    }
  };

  reader.readAsText(file);
};

💡 三、CSV 转 JSON:解析陷阱与高级用法

CSV 转 JSON 看起来更简单(因为 CSV 结构更扁平),但实际上坑更多。

3.1 常见解析陷阱

陷阱 示例 正确处理
字段内含逗号 "张三,博士" 识别引号包裹
字段内含换行 "第一行\n第二行" 多行字段解析
字段内含双引号 "他说""你好""" 双引号转义
不同行列数不同 第1行3列,第2行4列 补齐或报错
BOM 头 UTF-8 BOM \uFEFF 自动检测并移除
编码混乱 GBK / UTF-8 混用 编码检测
// ✅ 健壮的 CSV 解析器核心逻辑
function parseCsvLine(line, separator = ',') {
  const fields = [];
  let current = '';
  let inQuotes = false;
  let i = 0;

  while (i < line.length) {
    const char = line[i];

    if (inQuotes) {
      if (char === '"') {
        if (line[i + 1] === '"') {
          // 转义的双引号
          current += '"';
          i += 2;
        } else {
          // 结束引号
          inQuotes = false;
          i++;
        }
      } else {
        current += char;
        i++;
      }
    } else {
      if (char === '"') {
        inQuotes = true;
        i++;
      } else if (char === separator) {
        fields.push(current);
        current = '';
        i++;
      } else {
        current += char;
        i++;
      }
    }
  }

  fields.push(current);
  return fields;
}

// 测试各种边界情况
console.log(parseCsvLine('张三,"北京,朝阳",28'));
// ["张三", "北京,朝阳", "28"]

console.log(parseCsvLine('"他说""你好""",32'));
// ["他说\"你好\"", "32"]

console.log(parseCsvLine('"多行\n字段",test'));
// ["多行\n字段", "test"]

3.2 自动类型推断

CSV 所有字段都是字符串,但转成 JSON 时通常需要推断类型:

// ✅ 智能类型推断
function inferType(value) {
  if (value === '' || value === null || value === undefined) return null;
  if (value === 'true') return true;
  if (value === 'false') return false;
  if (value === 'null') return null;

  // 数字检测:注意不要把 "0123" 这种前导零的字符串误判为数字
  if (/^-?\d+$/.test(value) && !value.startsWith('0') || value === '0') {
    const num = parseInt(value, 10);
    if (Number.isSafeInteger(num)) return num;
  }
  if (/^-?\d+\.\d+$/.test(value)) {
    const num = parseFloat(value);
    if (!isNaN(num)) return num;
  }

  // ISO 日期格式
  if (/^\d{4}-\d{2}-\d{2}(T|\s)/.test(value)) {
    const date = new Date(value);
    if (!isNaN(date.getTime())) return date.toISOString();
  }

  return value;
}

// CSV 转 JSON 完整实现
function csvToJson(csvText) {
  // 移除 BOM
  const text = csvText.replace(/^\uFEFF/, '');
  const lines = text.split(/\r?\n/).filter(line => line.trim());

  if (lines.length < 2) return [];

  const headers = parseCsvLine(lines[0]);
  const result = [];

  for (let i = 1; i < lines.length; i++) {
    const values = parseCsvLine(lines[i]);
    const obj = {};
    headers.forEach((header, index) => {
      obj[header.trim()] = inferType(values[index]?.trim());
    });
    result.push(obj);
  }

  return result;
}

// 测试
const csv = `name,age,active,score
张三,28,true,95.5
李四,32,false,88.0
王五,0,true,null`;

console.log(JSON.stringify(csvToJson(csv), null, 2));
// [
//   { "name": "张三", "age": 28, "active": true, "score": 95.5 },
//   { "name": "李四", "age": 32, "active": false, "score": 88 },
//   { "name": "王五", "age": 0, "active": true, "score": null }
// ]

⚠️ **警告:**自动类型推断可能导致误判。比如手机号 13800138000 会被识别为数字,丢失前导零的工号 00123 也会被误判。生产环境中建议支持列类型配置,或者禁用自动推断,保持所有值为字符串。

3.3 扁平 CSV 还原为嵌套 JSON

如果 CSV 是从嵌套 JSON 扁平化而来的(用 . 分隔的列名),可以还原为嵌套结构:

// ✅ 将扁平的 key(如 user.address.city)还原为嵌套对象
function unflattenObject(flat, separator = '.') {
  const result = {};

  for (const [key, value] of Object.entries(flat)) {
    const parts = key.split(separator);
    let current = result;

    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i];
      // 判断下一层是数组还是对象
      const nextPart = parts[i + 1];
      const isArray = /^\d+$/.test(nextPart);

      if (!(part in current)) {
        current[part] = isArray ? [] : {};
      }
      current = current[part];
    }

    const lastPart = parts[parts.length - 1];
    current[lastPart] = value;
  }

  return result;
}

// 测试
const flatData = {
  "id": 1,
  "user.name": "张三",
  "user.address.city": "北京",
  "scores": "90;85;92"
};

console.log(JSON.stringify(unflattenObject(flatData), null, 2));
// {
//   "id": 1,
//   "user": {
//     "name": "张三",
//     "address": { "city": "北京" }
//   },
//   "scores": "90;85;92"
// }

⚠️ 四、编码问题:最容易被忽视的坑

编码问题是 JSON ↔ CSV 转换中最容易被忽视、但影响最大的问题。

4.1 常见编码场景

场景 编码 特征 处理方式
Excel 打开的 CSV UTF-8 with BOM 文件头 \uFEFF 自动检测移除
Windows 记事本 UTF-8 with BOM 同上 同上
日文系统导出 Shift_JIS 日文乱码 iconv-lite 转码
中文旧系统 GBK / GB2312 中文乱码 iconv-lite 转码
macOS 导出 UTF-8 (LF) 正常 直接使用
Windows 导出 UTF-8 (CRLF) \r\n 换行 统一为 \n

4.2 Node.js 编码处理

// ✅ 自动检测编码并转换
const iconv = require('iconv-lite');
const chardet = require('chardet');

function readCsvWithEncoding(buffer) {
  // 检测编码
  const detected = chardet.detect(buffer);
  const encoding = detected || 'utf-8';

  console.log(`检测到编码: ${encoding}`);

  // 解码为 UTF-8
  let text;
  if (encoding.toLowerCase().replace('-', '') === 'utf8') {
    text = buffer.toString('utf-8');
  } else {
    text = iconv.decode(buffer, encoding);
  }

  // 移除 BOM
  text = text.replace(/^\uFEFF/, '');

  // 统一换行符
  text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');

  return text;
}

// 使用
const fs = require('fs');
const buffer = fs.readFileSync('data.csv');
const csvText = readCsvWithEncoding(buffer);
const jsonData = csvToJson(csvText);

💡 **提示:**如果不需要自动检测编码,可以指定编码直接转换。iconv-lite 支持 GBK、Shift_JIS、EUC-KR 等几十种编码,是 Node.js 端处理编码问题的首选库。

📊 五、生产级方案选型

在生产环境中,不建议自己实现 CSV 解析器。以下是主流库的对比:

库名 周下载量 浏览器 Node.js 流式 TypeScript 推荐场景
papaparse 350 万 通用首选,API 友好
csv-parse 200 万 Node.js 大文件流式处理
fast-csv 60 万 高性能 CSV 处理
csv-stringify 150 万 CSV 生成(配合 csv-parse)
d3-dsv 30 万 数据可视化场景
xlsx 150 万 Excel 格式支持

选型建议

  • 浏览器端:首选 papaparse,API 设计优秀,支持 Web Worker 和流式解析
  • Node.js 小文件papaparsefast-csv,使用简单
  • Node.js 大文件csv-parse + csv-stringify 的流式 API,内存占用可控
  • 需要 Excel 兼容xlsx 库,支持 .xlsx.csv 两种格式
  • 不推荐:自己实现 CSV 解析器——边界情况太多,容易出 bug

⚠️ 警告:papaparsedynamicTyping 选项会自动将数字字符串转为数字,这可能导致手机号、身份证号等长数字丢失精度。生产环境中建议关闭此选项,手动处理类型转换。

🎯 六、最佳实践总结

经过对 JSON 与 CSV 互转各种场景的分析,总结以下最佳实践:

JSON 转 CSV:

  • ✅ 始终使用 RFC 4180 标准的转义规则
  • ✅ 嵌套数据先扁平化,选择不会与 key 冲突的分隔符
  • ✅ 大文件使用流式处理(stream-json + csv-stringify
  • ❌ 不要用字符串拼接生成 CSV
  • ❌ 不要假设所有对象的 key 完全一致

CSV 转 JSON:

  • ✅ 处理 BOM 头和不同换行符(CRLF vs LF)
  • ✅ 检测并处理非 UTF-8 编码
  • ✅ 关闭自动类型推断,或提供列类型配置
  • ❌ 不要把手机号、身份证号等长数字字符串自动转为数字
  • ❌ 不要忽略字段数不一致的行

性能优化:

  • ✅ 大于 50MB 的文件必须使用流式处理
  • ✅ 浏览器端使用 Web Worker 避免阻塞主线程
  • ✅ 批量写入时使用 highWaterMark 控制背压
  • ✅ 生产环境使用成熟的库而非自己实现

如果你需要快速完成 JSON 与 CSV 的互转,可以使用 jsjson.com 的 JSON 转 CSV 工具,支持在线转换、自动识别嵌套结构、多种编码兼容,无需安装任何依赖。

📚 相关文章