JSON Toolkit

Chuyển đổi JSON sang và từ CSV cùng dữ liệu dạng bảng

Kết luận: CSV là hai chiều, còn JSON có dạng cây. Phần khó của chuyển đổi không nằm ở mã — mà ở việc quyết định trước xem mảng được làm phẳng thế nào và kiểu được giữ lại ra sao.

Người mớiĐọc khoảng 10 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa cho chuyển đổi JSON ↔ CSV và dữ liệu dạng bảng

Kết luận: hãy làm phẳng bằng khóa dạng dấu chấm và stringify mảng thành JSON khi người ta sẽ đọc bản xuất, tách thành bảng cha cộng các bảng liên quan khi nạp vào cơ sở dữ liệu, và chỉ mở rộng mảng thành thêm dòng khi cần pivot; khi phục hồi, hãy chuyển đổi theo định nghĩa cột tường minh thay vì suy luận toàn cục.

Làm phẳng một cây thành bảng luôn mất đi thứ bậc; phục hồi một bảng thành cây luôn phải đoán kiểu. Cả hai chiều đều cần quy tắc được nêu rõ ngay từ đầu.

1. CSV thực sự quy định những gì

RFC 4180 chỉ dài vài trang và quy định ít hơn nhiều người tưởng:

  1. Mỗi bản ghi chiếm một dòng, các trường phân cách bằng dấu phẩy;
  2. Dấu kết thúc dòng là CRLF, dù trên thực tế \n cũng được chấp nhận;
  3. Trường chứa dấu phẩy, dấu ngoặc kép hoặc xuống dòng phải được bao trong ngoặc kép;
  4. Dấu ngoặc kép bên trong trường được viết thành hai dấu ngoặc kép "";
  5. Dòng đầu tiên có thể là header;
  6. Không có quy định nào về mã hoá ký tự, và cũng không có quy định về kiểu.
id,name,remark
1,张三,"喜欢 JSON,讨厌尾逗号"
2,李四,"他说:""这行有引号"""
3,王五,"多行
备注"

Chú ý quy tắc 3: trường chứa xuống dòng là hợp lệ, nên một parser ngây thơ tách theo \n rồi tách theo , chắc chắn sai.

2. JSON → CSV: quyết định cấu trúc trước

Chỉ có ba chiến lược để làm phẳng một cấu trúc cây thành bảng hai chiều:

Chiến lược 1: làm phẳng trường (phổ biến nhất)

{ "id": 1, "user": { "name": "张三", "city": "杭州" }, "tags": ["a", "b"] }
id,user.name,user.city,tags
1,张三,杭州,"[""a"",""b""]"
  • Nối khóa của object lồng nhau bằng .;
  • Mảng không thể biểu diễn thành cột, nên hãy JSON.stringify thẳng vào ô (đây vẫn là một trường CSV hợp lệ);
  • Nhược điểm: công cụ bảng tính không thể lọc mảng, nên cần phân tích lần thứ hai.

Chiến lược 2: mở rộng mảng thành nhiều dòng

id,user.name,tag
1,张三,a
1,张三,b

Cách này phù hợp với quan hệ một-nhiều khi nơi tiêu thụ hạ nguồn tổng hợp theo từng phần tử. Nhược điểm là trường của cha bị trùng lặp và số dòng phình to.

Chiến lược 3: tách thành nhiều bảng

Một bảng chính cộng các bảng liên quan nối bằng id — cách gọn gàng nhất, nhưng nó vượt ra ngoài phạm vi "một bản xuất duy nhất" và trông giống mô hình hoá dữ liệu hơn.

Chọn cách nào? Dùng chiến lược 1 cho bản xuất dành cho con người, chiến lược 3 khi nạp vào cơ sở dữ liệu, và chiến lược 2 khi cần pivot dữ liệu.

3. CSV → JSON: kiểu được đoán như thế nào

CSV không có kiểu, nên mỗi bước phục hồi đều là một phỏng đoán:

Nội dung ô Ý nghĩa khả dĩ Rủi ro khi chuyển thẳng sang số
00123 Mã nhân viên, mã bưu chính Mất số 0 đầu → 123
1.10 Số phiên bản, số tiền Trở thành 1.1
1e5 Định danh hoặc ký hiệu khoa học Trở thành 100000
2026-10-30 Ngày hoặc chuỗi Nhập nhằng múi giờ và định dạng
13800138000 Số điện thoại Vẫn an toàn ở 11 chữ số, nhưng một số ID vẫn mất độ chính xác
"" Chuỗi rỗng hoặc null Nhập nhằng ngữ nghĩa

Mặc định đề xuất: coi mọi thứ là chuỗi, và để schema hoặc định nghĩa cột tường minh quyết định cột nào trở thành số hay boolean. "Suy luận thông minh" rất thú vị khi xuất nhưng lại là nguồn sự cố khi nhập.

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. Ba cạm bẫy quanh Excel

4.1 Mã hoá

Khi Excel mở một CSV UTF-8 không có BOM, văn bản tiếng Trung biến thành mojibake. Thêm BOM (\uFEFF) khi xuất là cách tương thích rẻ nhất:

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

4.2 Số dài biến thành ký hiệu khoa học

Một số CMND 18 chữ số hay mã đơn hàng 19 chữ số sẽ hiển thị trong Excel là 1.23457E+17, và bản thân giá trị đã bị cắt. Thêm một ký tự tab trước trường hoặc bọc nó thành công thức (="1234...") sẽ giữ được định dạng văn bản, nhưng lại biến ô thành công thức. Cách an toàn hơn là hướng dẫn người dùng "dùng tính năng import và chỉ định cột là text", hoặc cung cấp thêm tệp .xlsx bên cạnh bản xuất.

4.3 Dấu phân cách bị ảnh hưởng bởi locale

Một số locale châu Âu mặc định dùng dấu chấm phẩy ; làm dấu phân cách. Khi phân phối qua nhiều khu vực, hãy hoặc cố định CSV dùng dấu phẩy và nói rõ điều đó, hoặc cung cấp thêm phiên bản TSV (phân cách bằng tab) — TSV dễ dung sai với locale hơn.

5. Xử lý tệp lớn

Đừng nạp CSV có hơn trăm nghìn dòng vào bộ nhớ trong một lần:

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
}

Chiều xuất cũng vậy: vừa sinh vừa ghi ra stream, thay vì nối thành một chuỗi khổng lồ.

6. Tự kiểm tra nhất quán khứ hồi

Một vòng khứ hồi CSV (JSON → CSV → JSON) gần như không bao giờ giống hệt, nhưng bạn có thể định nghĩa những mất mát nào là chấp nhận được:

Kiểm tra Kỳ vọng
Số trường Giống hệt
Giá trị chuỗi Giống hệt (kể cả số 0 đầu)
Kiểu số Do định nghĩa cột quyết định, không do suy luận
Object lồng nhau Có thể phục hồi qua đường dẫn dấu chấm
Mảng Nhìn thấy được sau khi tuần tự hoá thành chuỗi JSON
Giá trị rỗng Chuẩn hoá thành null hoặc chuỗi rỗng — chọn đúng một

Đưa bảng này vào test hiệu quả hơn nhiều so với một thỏa thuận bằng lời.

7. Danh sách kiểm tra

  • [ ] Ngữ nghĩa "một dòng là một bản ghi" được nêu rõ và phản ánh trong tên tệp xuất;
  • [ ] Mọi cột có thể mất độ chính xác đều được xuất dưới dạng chuỗi;
  • [ ] CSV tiếng Trung có BOM, hoặc mã hoá được nêu rõ;
  • [ ] Trường chứa dấu phẩy, dấu ngoặc kép hoặc xuống dòng được escape đúng (hãy dùng thư viện, đừng tự nối chuỗi);
  • [ ] Tệp lớn được đọc và ghi theo streaming;
  • [ ] Khi nhập, kiểu được chuyển theo định nghĩa cột, không bao giờ dùng suy luận thông minh toàn cục.

FAQ

Làm sao chọn giữa làm phẳng, mở rộng dòng và tách bảng?

Làm phẳng cho bản xuất dành cho con người: nối khóa lồng bằng . và JSON.stringify mảng vào ô, đổi lại là không thể lọc các mảng đó trong bảng tính. Tách thành bảng cha cộng các bảng liên quan khi đích là cơ sở dữ liệu. Mở rộng mảng thành thêm dòng khi nơi tiêu thụ hạ nguồn tổng hợp theo từng phần tử, chấp nhận trường của cha bị trùng lặp.

Vì sao không nên để import CSV tự suy luận kiểu?

RFC 4180 quy định dấu phân cách và escaping nhưng không quy định kiểu, nên mỗi bước phục hồi đều là phỏng đoán: 00123 mất số 0 đầu, 1.10 thành 1.1, và 1e5 thành 100000. Hãy mặc định coi mọi thứ là chuỗi và để schema hoặc định nghĩa cột tường minh quyết định cột nào trở thành số hay boolean.

Có nên kỳ vọng khứ hồi CSV tái tạo chính xác JSON gốc?

Gần như không bao giờ. Thay vào đó hãy định nghĩa những mất mát chấp nhận được: số trường giống hệt, giá trị chuỗi giống hệt kể cả số 0 đầu, kiểu số do định nghĩa cột quyết định, object lồng nhau phục hồi được qua đường dẫn dấu chấm, mảng nhìn thấy được sau khi tuần tự hoá, và giá trị rỗng chuẩn hoá thành null hoặc chuỗi rỗng — chọn đúng một.