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,或明確註明編碼;
  • [ ] 帶逗號、引號、換行的欄位正確轉義(不要自己拼字串,用庫);
  • [ ] 大檔案走流式讀寫;
  • [ ] 匯入時按列定義轉型,不做全域性智慧推斷。