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

Коротко: розгортайте через ключі з крапками та масиви, серіалізовані в JSON, коли експорт читатимуть люди; розділяйте на батьківську таблицю й пов'язані таблиці, коли завантажуєте в базу даних; розгортайте масиви в додаткові рядки лише для зведених таблиць; під час відновлення конвертуйте за явними визначеннями стовпців, а не глобальним виведенням.
Розгортання дерева в таблицю завжди втрачає ієрархію; відновлення таблиці в дерево завжди вгадує типи. Обидва напрямки потребують правил, заявлених заздалегідь.
1. Що насправді визначає CSV
RFC 4180 займає лише кілька сторінок і визначає менше, ніж багато хто припускає:
- Кожен запис займає один рядок, поля розділяються комами;
- Термінатор рядка — CRLF, хоча на практиці приймають і
\n; - Поле, що містить кому, подвійну лапку або переведення рядка, треба брати в подвійні лапки;
- Подвійні лапки всередині поля пишуться як дві подвійні лапки
""; - Перший рядок може бути заголовком;
- Кодування символів не визначено, як і типи.
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 або порожнього рядка — оберіть рівно одне.