JSON Toolkit

Конвертація JSON у CSV і табличні дані та навпаки

Головне: CSV двовимірний, JSON деревоподібний. Складна частина конвертації — не код, а рішення заздалегідь, як розгортати масиви та як зберігати типи.

Початковий~10 хв читанняОпубліковано 2026-10-30Оновлено 2026-10-30
Обкладинка статті про конвертацію JSON ↔ CSV і табличних даних

Коротко: розгортайте через ключі з крапками та масиви, серіалізовані в JSON, коли експорт читатимуть люди; розділяйте на батьківську таблицю й пов'язані таблиці, коли завантажуєте в базу даних; розгортайте масиви в додаткові рядки лише для зведених таблиць; під час відновлення конвертуйте за явними визначеннями стовпців, а не глобальним виведенням.

Розгортання дерева в таблицю завжди втрачає ієрархію; відновлення таблиці в дерево завжди вгадує типи. Обидва напрямки потребують правил, заявлених заздалегідь.

1. Що насправді визначає CSV

RFC 4180 займає лише кілька сторінок і визначає менше, ніж багато хто припускає:

  1. Кожен запис займає один рядок, поля розділяються комами;
  2. Термінатор рядка — CRLF, хоча на практиці приймають і \n;
  3. Поле, що містить кому, подвійну лапку або переведення рядка, треба брати в подвійні лапки;
  4. Подвійні лапки всередині поля пишуться як дві подвійні лапки "";
  5. Перший рядок може бути заголовком;
  6. Кодування символів не визначено, як і типи.
id,name,remark
1,张三,"喜欢 JSON,讨厌尾逗号"
2,李四,"他说:""这行有引号"""
3,王五,"多行
备注"

Зверніть увагу на правило 3: поля з переведенням рядка є законними, тож наївний парсер, який ділить за \n, а потім за ,, гарантовано помилятиметься.

2. 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 для зведення даних.

3. CSV → JSON: як вгадуються типи

CSV не має типів, тож кожен крок відновлення — це здогад:

Вміст комірки Можливе значення Ризик перетворення просто на число
00123 Табельний номер, поштовий індекс Провідні нулі втрачено → 123
1.10 Номер версії, сума Стає 1.1
1e5 Ідентифікатор або наукова нотація Стає 100000
2026-10-30 Дата або рядок Двозначність часового поясу й формату
13800138000 Номер телефону На 11 цифрах ще безпечно, але деякі ID втрачають точність
"" Порожній рядок або null Семантична двозначність

Рекомендований підхід за замовчуванням: трактувати все як рядок, а вибір стовпців, які стануть числами чи булевими значеннями, доручити схемі або явному визначенню стовпця. «Розумне виведення» чудове під час експорту і є джерелом інцидентів під час імпорту.

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

/**
 * Converts CSV rows into objects according to the column definitions.
 * @param rows The raw row array; the first row is the header
 * @param columnTypes Mapping from column name to type
 * @returns The converted array of objects
 */
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;
  });
}

4. Три пастки довкола Excel

4.1 Кодування

Коли Excel відкриває UTF-8 CSV без BOM, китайський текст перетворюється на «кракозябри». Додавання BOM (\uFEFF) під час експорту — найдешевше виправлення сумісності:

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

4.2 Довгі числа перетворюються на наукову нотацію

18-значний національний ID або 19-значний номер замовлення в Excel показується як 1.23457E+17, і саме значення вже обрізано. Префікс поля табуляцією або обгортання його як формули (="1234...") зберігає текстове форматування, але перетворює комірку на формулу. Безпечніше — сказати користувачам «скористайтеся імпортом і вкажіть стовпець як текст» або додати до експорту файл .xlsx.

4.3 Роздільники, залежні від локалі

Деякі європейські локалі за замовчуванням використовують крапку з комою ; як роздільник. Під час поширення між регіонами або закріпіть CSV на комах і скажіть про це, або запропонуйте версію TSV (розділену табуляцією) — TSV більш толерантний до локалі.

5. Обробка великих файлів

Не завантажуйте 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) {
  // Обробляйте один запис за раз; використання пам'яті не залежить від розміру файлу
}

Напрямок експорту працює так само: генеруйте й записуйте в потік по ходу, а не склеюйте один гігантський рядок.

6. Самоперевірка узгодженості зворотного перетворення

Повний цикл CSV (JSON → CSV → JSON) майже ніколи не буває точно ідентичним, але ви можете визначити, які втрати прийнятні:

Перевірка Очікування
Кількість полів Ідентична
Рядкові значення Ідентичні (включно з провідними нулями)
Числові типи Визначені визначенням стовпця, а не виведенням
Вкладені об'єкти Відновлювані через шляхи з крапками
Масиви Видимі після серіалізації в рядок JSON
Порожні значення Нормалізовані до null або порожнього рядка — оберіть рівно одне

Додавання цієї таблиці в тести набагато ефективніше за усну домовленість.

7. Чек-лист

  • [ ] Семантика «один рядок — один запис» заявлена явно й відображена в імені файлу експорту;
  • [ ] Кожен стовпець, що може втратити точність, експортується як рядок;
  • [ ] Китайські CSV містять BOM, або кодування заявлено явно;
  • [ ] Поля з комами, лапками чи переведеннями рядка екрановані правильно (використовуйте бібліотеку, не склеюйте рядки вручну);
  • [ ] Великі файли проходять через потокове читання й запис;
  • [ ] Імпорт конвертує типи за визначенням стовпця, ніколи через глобальне розумне виведення.

Часті питання

Як обирати між розгортанням, розширенням рядків і поділом таблиць?

Розгортайте для експорту, призначеного людям: з'єднуйте вкладені ключі через . і серіалізуйте масиви в JSON у комірку, ціною чого буде неможливість фільтрувати ці масиви в електронній таблиці. Розділяйте на батьківську таблицю плюс пов'язані таблиці, коли ціль — база даних. Розгортайте масиви в додаткові рядки, коли споживачі нижче за течією агрегують за елементом, приймаючи дублювання батьківських полів.

Чому імпорт CSV не має виводити типи автоматично?

RFC 4180 визначає роздільники й екранування, але не типи, тож кожен крок відновлення — здогад: 00123 втрачає провідні нулі, 1.10 стає 1.1, а 1e5 стає 100000. Трактуйте все як рядок за замовчуванням і дозвольте схемі або явному визначенню стовпця вирішувати, які стовпці стануть числами чи булевими значеннями.

Чи варто очікувати, що цикл CSV точно відтворить вихідний JSON?

Майже ніколи. Натомість визначте прийнятні втрати: ідентична кількість полів, ідентичні рядкові значення з провідними нулями, числові типи визначені визначенням стовпця, вкладені об'єкти відновлювані через шляхи з крапками, масиви видимі після серіалізації, а порожні значення нормалізовані до null або порожнього рядка — оберіть рівно одне.