Преобразование 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 цифрах ещё безопасно, но некоторые идентификаторы теряют точность |
"" |
Пустая строка или 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-значный номер удостоверения или 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) {
// Process one record at a time; memory usage is independent of file size
}
Направление выгрузки работает так же: генерируйте и пишите в поток по мере обработки, а не склеивайте одну гигантскую строку.
6. Самопроверка согласованности туда-обратно
Круговое преобразование CSV (JSON → CSV → JSON) почти никогда не бывает точным до байта, но можно определить, какие потери допустимы:
| Проверка | Ожидание |
|---|---|
| Количество полей | Совпадает |
| Строковые значения | Совпадают (включая ведущие нули) |
| Числовые типы | Определяются определением столбца, а не выводом |
| Вложенные объекты | Восстановимы по путям с точками |
| Массивы | Видны после сериализации в JSON-строку |
| Пустые значения | Нормализованы к null или пустой строке — выберите ровно один вариант |
Внести эту таблицу в тесты гораздо эффективнее, чем договориться на словах.
7. Чек-лист
- [ ] Семантика «одна строка — одна запись» явно задана и отражена в имени файла выгрузки;
- [ ] Каждый столбец, который может потерять точность, выгружается строкой;
- [ ] Китайские CSV включают BOM или кодировка явно указана;
- [ ] Поля с запятыми, кавычками и переводами строк экранируются правильно (используйте библиотеку, не склеивайте строки сами);
- [ ] Большие файлы читаются и пишутся потоково;
- [ ] Импорт преобразует типы по определению столбцов, а не глобальным «умным выводом».
Вопросы и ответы
Как выбрать между уплощением, разворачиванием строк и разбиением на таблицы?
Уплощайте для выгрузок, предназначенных людям: соединяйте вложенные ключи через ., а массивы сериализуйте через JSON.stringify в ячейку, ценой невозможности фильтровать эти массивы в таблице. Разбивайте на родительскую и связанные таблицы, когда цель — база данных. Разворачивайте массивы в дополнительные строки, когда нижестоящие потребители агрегируют по элементу, принимая дублирование родительских полей.
Почему импорт CSV не должен автоматически выводить типы?
RFC 4180 задаёт разделители и экранирование, но не типы, поэтому каждый шаг восстановления — догадка: 00123 теряет ведущие нули, 1.10 становится 1.1, а 1e5 — 100000. По умолчанию считайте всё строками, а решение о том, какие столбцы становятся числами или логическими значениями, оставьте схеме или явному определению столбцов.
Стоит ли ожидать, что круговое преобразование CSV точно воспроизведёт исходный JSON?
Почти никогда. Вместо этого определите допустимые потери: одинаковое количество полей, одинаковые строковые значения, включая ведущие нули, числовые типы по определению столбца, вложенные объекты, восстановимые по путям с точками, массивы, видимые после сериализации, и пустые значения, нормализованные к null или пустой строке, — выберите ровно один вариант.