JSON Toolkit

การแยกวิเคราะห์ การเรนเดอร์ และการสตรีม JSON ขนาดใหญ่

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

ระดับกลางอ่านประมาณ 13 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-31
ภาพปกสำหรับการแยกวิเคราะห์ การเรนเดอร์ และการสตรีม 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)];
}

กฎวินัยสามข้อ:

  1. ใช้ตัวอย่างคงที่และอย่าสร้างข้อมูลแบบสุ่ม มิฉะนั้นตัวเลขจะเทียบกันไม่ได้;
  2. รันอย่างน้อย 9 ครั้งและหาค่ามัธยฐาน; การรันครั้งแรกมีการ warm-up ของ JIT และต้องทิ้ง;
  3. บันทึกสภาพแวดล้อมรัน (เวอร์ชัน Node, CPU) มิฉะนั้นการเปรียบเทียบข้ามเครื่องไม่มีความหมาย

8. รายการตรวจสอบ

  • [ ] มีตัวเลข baseline เพื่อเทียบก่อนและหลัง;
  • [ ] การแยกวิเคราะห์สิ่งใดที่ใหญ่กว่า 1MB รันใน Worker;
  • [ ] Tree view ขยายตามต้องการและรายการใช้ virtual scrolling;
  • [ ] ไม่มี deep copy หรือการห่อด้วย reactive ที่ไม่มีประโยชน์;
  • [ ] ใช้ JSON Lines เมื่อข้อมูลเป็น "ชุดเรกคอร์ด";
  • [ ] API ส่ง JSON กระชับและการจัดรูปแบบเกิดขึ้นเฉพาะตอนแสดง