การแปลง JSON เป็นและจาก CSV และข้อมูลแบบตาราง
สรุปสำคัญ: CSV เป็นสองมิติ ส่วน JSON เป็นรูปต้นไม้ ส่วนที่ยากของการแปลงไม่ใช่โค้ด — แต่คือการตัดสินล่วงหน้าว่า array จะถูกแบนอย่างไรและชนิดข้อมูลจะถูกรักษาไว้อย่างไร

สรุปสั้น: แบนด้วยคีย์แบบจุดและ array ที่แปลงเป็นสตริง JSON เมื่อคนจะอ่านไฟล์ส่งออก แยกเป็นตารางหลักพร้อมตารางที่เกี่ยวข้องเมื่อโหลดเข้าฐานข้อมูล และขยาย array เป็นแถวเพิ่มเฉพาะเมื่อทำ pivot; ตอนกู้คืน ให้แปลงตามนิยามคอลัมน์ที่ชัดเจนแทนการอนุมานทั้งชุด
การแบนต้นไม้เป็นตารางย่อมสูญเสียลำดับชั้น ส่วนการคืนตารางเป็นต้นไม้ย่อมเดาชนิดข้อมูล ทั้งสองทิศทางต้องประกาศกฎล่วงหน้า
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""]"
- เชื่อมคีย์ของ object ที่ซ้อนด้วย
.; - array แสดงเป็นคอลัมน์ไม่ได้ จึงใช้
JSON.stringifyลงในเซลล์โดยตรง (ซึ่งยังเป็นฟิลด์ CSV ที่ถูกกฎหมาย); - ข้อเสีย: เครื่องมือสเปรดชีตกรอง array ไม่ได้ ต้องแยกวิเคราะห์ซ้ำอีกครั้ง
กลยุทธ์ 2: ขยาย array เป็นหลายแถว
id,user.name,tag
1,张三,a
1,张三,b
เหมาะกับความสัมพันธ์แบบหนึ่งต่อหลาย ซึ่งผู้บริโภคปลายน้ำรวมข้อมูลรายองค์ประกอบ ข้อเสียคือฟิลด์ของแถวหลักซ้ำกันและจำนวนแถวพองตัว
กลยุทธ์ 3: แยกเป็นหลายตาราง
ตารางหลักหนึ่งตารางพร้อมตารางที่เกี่ยวข้องเชื่อมด้วย id — วิธีสะอาดที่สุด แต่เกินขอบเขต "การส่งออกชิ้นเดียว" และดูเหมือนการออกแบบโมเดลข้อมูลมากกว่า
เลือกแบบไหน? ใช้กลยุทธ์ 1 สำหรับการส่งออกให้มนุษย์อ่าน กลยุทธ์ 3 สำหรับโหลดเข้าฐานข้อมูล และกลยุทธ์ 2 สำหรับทำ pivot ข้อมูล
3. 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';
/**
* 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 เปิด CSV แบบ UTF-8 ที่ไม่มี 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 ตัวคั่นที่ได้รับผลจาก locale
locale ยุโรปบางแห่งใช้เซมิโคลอน ; เป็นตัวคั่นโดยค่าเริ่มต้น เมื่อแจกจ่ายข้ามภูมิภาค ให้ตรึง CSV ให้ใช้เครื่องหมายจุลภาคและบอกให้ชัด หรือให้เวอร์ชัน TSV (คั่นด้วยแท็บ) เพิ่ม — TSV ทนต่อ locale มากกว่า
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) แทบไม่เคยเหมือนเดิมทุกประการ แต่คุณกำหนดได้ว่าความสูญเสียแบบไหนยอมรับได้:
| สิ่งที่ตรวจ | สิ่งที่คาดหวัง |
|---|---|
| จำนวนฟิลด์ | เหมือนกัน |
| ค่าสตริง | เหมือนกัน (รวมศูนย์นำหน้า) |
| ชนิดข้อมูลตัวเลข | กำหนดด้วยนิยามคอลัมน์ ไม่ใช่การอนุมาน |
| Object ที่ซ้อน | กู้คืนได้ผ่านเส้นทางแบบจุด |
| Array | มองเห็นได้หลังแปลงเป็นสตริง JSON |
| ค่าว่าง | ทำให้เป็น null หรือสตริงว่าง — เลือกอย่างเดียวเท่านั้น |
การใส่ตารางนี้ลงในเทสต์ได้ผลกว่าการตกลงด้วยวาจามาก
7. รายการตรวจสอบ
- [ ] ความหมาย "หนึ่งแถวคือหนึ่งเรกคอร์ด" ถูกระบุชัดเจนและสะท้อนในชื่อไฟล์ส่งออก;
- [ ] ทุกคอลัมน์ที่อาจสูญเสียความแม่นยำถูกส่งออกเป็นสตริง;
- [ ] CSV ภาษาจีนมี BOM หรือระบุการเข้ารหัสไว้ชัดเจน;
- [ ] ฟิลด์ที่มีเครื่องหมายจุลภาค เครื่องหมายคำพูด หรือการขึ้นบรรทัดใหม่ถูก escape ถูกต้อง (ใช้ไลบรารี อย่าต่อสตริงเอง);
- [ ] ไฟล์ใหญ่ผ่านการอ่านและเขียนแบบสตรีมมิ่ง;
- [ ] การนำเข้าแปลงชนิดข้อมูลตามนิยามคอลัมน์ ไม่ใช่การอนุมานอัจฉริยะทั้งชุด
คำถามที่พบบ่อย
จะเลือกระหว่างการแบน การขยายแถว และการแยกตารางอย่างไร
แบนสำหรับการส่งออกให้มนุษย์อ่าน: เชื่อมคีย์ที่ซ้อนด้วย . และ JSON.stringify array ลงในเซลล์ โดยยอมแลกกับการกรอง array เหล่านั้นในสเปรดชีตไม่ได้ แยกเป็นตารางหลักพร้อมตารางที่เกี่ยวข้องเมื่อปลายทางเป็นฐานข้อมูล ขยาย array เป็นแถวเพิ่มเมื่อผู้บริโภคปลายน้ำรวมข้อมูลรายองค์ประกอบ โดยยอมรับฟิลด์ของแถวหลักซ้ำกัน
ทำไมการนำเข้า CSV ไม่ควรอนุมานชนิดข้อมูลอัตโนมัติ
RFC 4180 กำหนดตัวคั่นและการ escape แต่ไม่กำหนดชนิดข้อมูล ทุกขั้นตอนกู้คืนจึงเป็นการเดา: 00123 ทำศูนย์นำหน้าหาย, 1.10 กลายเป็น 1.1 และ 1e5 กลายเป็น 100000 ให้ถือทุกอย่างเป็นสตริงโดยค่าเริ่มต้น แล้วให้ schema หรือนิยามคอลัมน์ที่ชัดเจนตัดสินว่าคอลัมน์ใดเป็นตัวเลขหรือบูลีน
ควรคาดหวังให้การไป-กลับ CSV ได้ JSON เดิมเป๊ะหรือไม่
แทบไม่เคย ให้กำหนดความสูญเสียที่ยอมรับได้แทน: จำนวนฟิลด์เท่ากัน ค่าสตริงเท่ากันรวมศูนย์นำหน้า ชนิดข้อมูลตัวเลขกำหนดด้วยนิยามคอลัมน์ object ที่ซ้อนกู้คืนได้ผ่านเส้นทางแบบจุด array มองเห็นได้หลังแปลงเป็นสตริง และค่าว่างถูกทำให้เป็น null หรือสตริงว่าง — เลือกอย่างเดียวเท่านั้น