การแยกวิเคราะห์ การเรนเดอร์ และการสตรีม JSON ขนาดใหญ่
สรุปสำคัญ: การแยกวิเคราะห์มักไม่ใช่คอขวด แต่การเรนเดอร์และการคัดลอกคือคอขวด ปรับแต่งตามสี่ชั้น "แยกวิเคราะห์ → ไล่ → เรนเดอร์ → ขนส่ง" เพื่อไม่ให้ความพยายามสูญเปล่า

สรุปสั้น: วัดการแยกวิเคราะห์ การไล่ การเรนเดอร์ และการขนส่งแยกกันก่อน จากนั้นทำตามลำดับนี้ — ลดสำเนา ขยายตามต้องการด้วย virtual scrolling ย้ายงานไปยัง Worker เปลี่ยนไปใช้การสตรีม และค่อยเปลี่ยนรูปแบบข้อมูล การสตรีมคุ้มค่าก็ต่อเมื่อไฟล์ไม่พอดีกับหน่วยความจำหรือคุณต้องการฟิลด์เพียงส่วนเล็ก ๆ
ข้อสรุปที่ว่า "JSON.parse ช้า" นั้นผิดแปดครั้งจากสิบ สิ่งที่ช้าจริง ๆ คือสิ่งที่คุณทำหลังแยกวิเคราะห์
1. วัดก่อน แล้วจึงปรับแต่ง
สำหรับไฟล์ JSON 10MB เดียวกัน เวลาที่ใช้ในแต่ละขั้นต่างกันหลายลำดับขนาด:
| ขั้น | ระยะเวลาปกติ | ต้นทุนหลัก |
|---|---|---|
JSON.parse (เนทีฟ) |
สิบมิลลิวินาทีถึงร้อยกว่ามิลลิวินาที | การสแกนเชิงเส้นครั้งเดียวบวกการสร้างออบเจ็กต์ |
Deep copy (structuredClone / เรียกซ้ำ) |
อาจเป็น 1–3 เท่าของการแยกวิเคราะห์ | การสร้างกราฟออบเจ็กต์ทั้งหมดใหม่ |
| การเปรียบเทียบเชิงลึก / diff | อาจเพิ่มอีกเท่าตัว | การไล่แบบเรียกซ้ำบวกการเปรียบเทียบสตริง |
| การเรนเดอร์เป็น DOM tree | ค้างเมื่อมีโหนดหลายหมื่น | การจัดวางและ reflow |
| การต่อสตริงแล้วทำให้เป็นอนุกรม | แปรผันตามขนาดเอาต์พุต | หน่วยความจำเพิ่มเป็นสองเท่า |
ดังนั้นวัดก่อนลงมือ: ในเบราว์เซอร์ ห่อแต่ละขั้นด้วย performance.now() หรือใช้ console.time;
ใน Node ใช้ process.memoryUsage() เฝ้าการเติบโตของ heap ไม่มี baseline ก็ไม่มีการปรับแต่ง
2. ชั้นการแยกวิเคราะห์: สามกลยุทธ์
2.1 การแยกวิเคราะห์เต็มรูปแบบ (ตัวเลือกเริ่มต้น)
const data = JSON.parse(text);
การใช้งานเนทีฟทำการสแกนและสร้างออบเจ็กต์ใน C++ ดังนั้นไลบรารี pure JS แทบจะเอาชนะไม่ได้ ใช้มันตราบเท่าที่เงื่อนไขสามข้อต่อไปนี้เป็นจริง:
- ข้อความพอดีกับหน่วยความจำทั้งหมด (ปกติ < 100MB);
- ต้องการฟิลด์ส่วนใหญ่จริง ๆ หลังแยกวิเคราะห์;
- ระดับความไม่น่าเชื่อถือของข้อมูลอยู่ในระดับที่จัดการได้
2.2 การแยกวิเคราะห์แบบเพิ่มพูนทีละส่วน
ด้วย parser สไตล์ SAX อย่าง stream-json คุณสามารถสแกนเอาฟิลด์เป้าหมายออกมาโดยไม่ต้องสร้างต้นไม้ออบเจ็กต์ที่สมบูรณ์:
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
// ประมวลผลทีละสมาชิก; หน่วยความจำไม่ขึ้นกับจำนวนสมาชิก
handleItem(value);
}
ต้นทุน:
- ความซับซ้อนของโค้ดเพิ่มขึ้นอย่างมากและการจัดการข้อผิดพลาดยุ่งเหยิงกว่า;
- เข้าถึงได้เฉพาะแบบลำดับ ไม่มี random access;
- ไม่เป็นมิตรกับความต้องการที่ต้องรวมข้อมูลแบบ global
กฎการตัดสินใจ: การสตรีมคุ้มค่าก็ต่อเมื่อไฟล์ใหญ่เกินกว่าจะพอดี หรือเมื่อคุณต้องการเพียงฟิลด์ส่วนน้อยมาก ๆ
2.3 เปลี่ยนรูปแบบข้อมูล
หากข้อมูลเป็น "ชุดเรกคอร์ด" ไม่ใช่ "เอกสารเดียว" คุณควรใช้ JSON Lines ตั้งแต่แรก รูปแบบที่คั่นด้วยบรรทัดเปลี่ยน "การสตรีม" เป็น "การอ่านทีละบรรทัด" ลดความซับซ้อนในการใช้งานลงหนึ่งลำดับขนาด
3. ชั้นการไล่: ลดสำเนาหนึ่งชุดชนะครึ่งหนึ่งของการต่อสู้
// แพตเทิร์นต้าน: deep copy ก่อน "เพื่อความปลอดภัย"
const safeCopy = structuredClone(bigData); // หน่วยความจำเพิ่มเป็นสองเท่าทันที
// แพตเทิร์นดี: ส่งการอ้างอิงโดยตรงในสถานการณ์อ่านอย่างเดียว
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
สำเนาที่ซ่อนอยู่ทั่วไป:
JSON.parse(JSON.stringify(x))ใช้เป็น "deep copy" — นี่คือชนิดที่แพงที่สุด;{ ...data }คัดลอกเพียงระดับเดียว แต่สำหรับออบเจ็กต์ขนาดใหญ่ยังคัดลอกอาร์เรย์การอ้างอิงของคีย์ระดับบนทั้งหมด;Array.prototype.mapสร้างอาร์เรย์ใหม่ ซึ่งก็มีต้นทุนสูงเมื่อมีสมาชิกมาก;- proxy ของระบบ reactive ในเฟรมเวิร์ก (
reactiveของ Vue) proxy ออบเจ็กต์ขนาดใหญ่แบบเรียกซ้ำ และ overhead อาจสูงกว่าการแยกวิเคราะห์เอง
คำแนะนำเชิงปฏิบัติ: เก็บ "ข้อมูลดิบ" เป็นออบเจ็กต์ plain ที่ไม่เปลี่ยนรูป และอย่าห่อด้วยระบบ reactive; คำนวณและแคชข้อมูลที่ได้มาที่ต้องแสดงแยกกัน
4. ชั้นการเรนเดอร์: อย่าเอาทั้งทรีใส่ DOM
ไฟล์ JSON 5MB มีประมาณ 30,000 ถึง 100,000 โหนด; การเรนเดอร์ทั้งหมดเป็น DOM element จะทำให้เบราว์เซอร์หยุดตอบสนอง
แนวทางที่เป็นไปได้สามวิธี:
4.1 ขยายตามต้องการ
เรนเดอร์เฉพาะลูกโดยตรงของโหนด root และคำนวณระดับถัดไปเฉพาะเมื่อโหนดถูกขยาย นี่คือแนวทางมาตรฐานของ tree viewer:
type TreeNode = { key: string; value: unknown; expanded: boolean };
/**
* คืนเฉพาะโหนดที่มองเห็นปัจจุบัน หลีกเลี่ยงการสร้างทรีทั้งหมดพร้อมกัน
* @param rootNode โหนด root
* @param expandedPaths ชุด path ที่ถูกขยายแล้ว
* @returns รายการโหนดที่มองเห็น
*/
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
const visibleNodes: TreeNode[] = [];
/**
* เก็บโหนดลูกของกิ่งที่ขยายแล้วแบบเรียกซ้ำ
* @param currentNode โหนดปัจจุบัน
* @param currentPath path ปัจจุบัน
*/
const walk = (currentNode: TreeNode, currentPath: string): void => {
visibleNodes.push(currentNode);
if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
return;
}
for (const [childKey, childValue] of Object.entries(currentNode.value)) {
walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
}
};
walk(rootNode, '');
return visibleNodes;
}
4.2 Virtual scrolling
เมื่อโหนดที่ขยายเกินไม่กี่ร้อย ให้เรนเดอร์เฉพาะไม่กี่สิบตัวภายใน viewport และสำรองที่ว่างสำหรับส่วนที่เหลือด้วยความสูงตัวแทน มันได้ผลที่สุดกับ view แบบรายการ (ตาราง, log)
4.3 แสดงเฉพาะสรุป
นำเสนอ "โครงสร้าง + ชนิด + จำนวน" ก่อน (เช่น items: Array(12000)); ดึงค่าจริงเฉพาะเมื่อผู้ใช้เปิดรายการ
เคล็ดลับนี้ได้ผลดีที่สุดกับสถานการณ์ "ฉันแค่อยากยืนยันว่าฟิลด์ถูกต้อง"
5. Main thread: ย้ายงานไปยัง Worker เมื่อควรทำ
การแยกวิเคราะห์ การจัดรูปแบบ และการ diff สิ่งใดที่ใหญ่กว่า 1MB ควรไปอยู่ใน Web Worker:
// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
try {
const parsed = JSON.parse(event.data.text);
self.postMessage({ ok: true, data: parsed });
} catch (parseError) {
self.postMessage({ ok: false, message: (parseError as Error).message });
}
};
/**
* แยกวิเคราะห์ JSON ภายใน Worker เพื่อไม่ให้ main thread ถูกบล็อก
* @param text ข้อความ JSON
* @returns ผลลัพธ์การแยกวิเคราะห์
*/
function parseInWorker(text: string): Promise<unknown> {
return new Promise((resolvePromise, rejectPromise) => {
const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event) => {
worker.terminate();
if (event.data.ok) {
resolvePromise(event.data.data);
} else {
rejectPromise(new Error(event.data.message));
}
};
worker.postMessage({ text });
});
}
ระวังต้นทุนการสื่อสารของ Worker: postMessage ใช้ structured cloning โดยค่าเริ่มต้น และการ clone ออบเจ็กต์ 10MB หนึ่งครั้งก็ใช้เวลาหลายสิบมิลลิวินาที
หากสิ่งที่คุณต้องการคือ "แยกวิเคราะห์แล้วส่งให้ main thread" ให้ทำงานหนักภายใน Worker (แยกวิเคราะห์ + แปลง + ตรวจสอบ) แล้วส่งกลับ สรุปผลลัพธ์
6. ชั้นการขนส่ง: การปรับขนาด
| เทคนิค | กำไรปกติ | ต้นทุน |
|---|---|---|
| gzip / brotli | เอาต์พุตบีบอัดมักเป็น 10%–25% ของขนาดเดิม | ต้องมีการสนับสนุนจากเซิร์ฟเวอร์ |
| ตัดการย่อหน้าและช่องว่าง | ลด 20%–40% | มนุษย์อ่านไม่ได้ |
| ย่อชื่อฟิลด์ | กำไรชัดเจนเมื่อซ้อนลึก | ความอ่านง่ายต่ำลง ต้องมีตารางแมป |
| ใช้ตัวเลขแทนสตริง enum | เล็กน้อย | ต้องมีพจนานุกรม |
| Pagination / ฟิลด์ตามต้องการ | มากที่สุด | ต้องออกแบบ API ใหม่ |
สำหรับหน้าที่มีไว้สำหรับมนุษย์ การผสมที่ถูกต้องคือ: API ส่ง JSON กระชับและบีบอัดแล้ว และ UI จัดรูปแบบให้อ่านง่ายเฉพาะตอนแสดง ไซต์เครื่องมือหลายแห่งส่งข้อความที่จัดรูปแบบแล้วกลับไปยัง frontend อีกครั้ง ทำให้ส่งไบต์เกินไป 30% เปล่า ๆ
7. วิธีทำ benchmark
/**
* วัดระยะเวลามัธยฐานของรูทีนแบบซิงโครนัส เพื่อไม่ให้การรันที่สั่นไหวครั้งเดียวบิดเบือนผล
* @param runOnce ฟังก์ชันที่ทดสอบ
* @param repeatCount จำนวนรอบที่ทำซ้ำ
* @returns ระยะเวลามัธยฐานเป็นมิลลิวินาที
*/
function measureMedian(runOnce: () => void, repeatCount = 9): number {
const durations: number[] = [];
for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
const startedAt = performance.now();
runOnce();
durations.push(performance.now() - startedAt);
}
durations.sort((firstValue, secondValue) => firstValue - secondValue);
return durations[Math.floor(durations.length / 2)];
}
กฎวินัยสามข้อ:
- ใช้ตัวอย่างคงที่และอย่าสร้างข้อมูลแบบสุ่ม มิฉะนั้นตัวเลขจะเทียบกันไม่ได้;
- รันอย่างน้อย 9 ครั้งและหาค่ามัธยฐาน; การรันครั้งแรกมีการ warm-up ของ JIT และต้องทิ้ง;
- บันทึกสภาพแวดล้อมรัน (เวอร์ชัน Node, CPU) มิฉะนั้นการเปรียบเทียบข้ามเครื่องไม่มีความหมาย
8. รายการตรวจสอบ
- [ ] มีตัวเลข baseline เพื่อเทียบก่อนและหลัง;
- [ ] การแยกวิเคราะห์สิ่งใดที่ใหญ่กว่า 1MB รันใน Worker;
- [ ] Tree view ขยายตามต้องการและรายการใช้ virtual scrolling;
- [ ] ไม่มี deep copy หรือการห่อด้วย reactive ที่ไม่มีประโยชน์;
- [ ] ใช้ JSON Lines เมื่อข้อมูลเป็น "ชุดเรกคอร์ด";
- [ ] API ส่ง JSON กระชับและการจัดรูปแบบเกิดขึ้นเฉพาะตอนแสดง