JSON Toolkit

Phân tích, kết xuất và truyền luồng JSON lớn

Kết luận: Phân tích thường không phải nút cổ chai; kết xuất và sao chép mới là. Hãy tối ưu theo bốn lớp "phân tích → duyệt → kết xuất → truyền tải" để công sức của bạn không bị lãng phí.

Trung cấpĐọc khoảng 13 phútXuất bản 2026-10-30Cập nhật 2026-10-31
Ảnh bìa cho Phân tích, kết xuất và truyền luồng JSON lớn

Kết luận: hãy đo riêng việc phân tích, duyệt, kết xuất và truyền tải trước, rồi làm theo thứ tự này — cắt bớt bản sao, mở rộng theo yêu cầu bằng cuộn ảo, chuyển việc sang Worker, chuyển sang truyền luồng, và chỉ sau đó mới đổi định dạng. Truyền luồng chỉ đáng khi tệp không vừa bộ nhớ hoặc khi bạn chỉ cần một tập con nhỏ các trường.

Kết luận "JSON.parse chậm" sai tám trên mười lần. Thứ thật sự chậm là những gì bạn làm sau khi phân tích.

1. Đo trước, rồi tối ưu

Với cùng một tệp JSON 10MB, thời gian ở các giai đoạn khác nhau chênh lệch theo bậc độ lớn:

Giai đoạn Thời lượng điển hình Chi phí chính
JSON.parse (native) Từ vài chục đến hơn một trăm mili giây Một lần quét tuyến tính cộng việc dựng đối tượng
Sao chép sâu (structuredClone / đệ quy) Có thể gấp 1–3 lần phân tích Dựng lại toàn bộ đồ thị đối tượng
So sánh sâu / diff Có thể gấp đôi lần nữa Duyệt đệ quy cộng so sánh chuỗi
Kết xuất thành cây DOM Đóng băng khi có hàng chục nghìn nút Bố cục và reflow
Nối chuỗi rồi tuần tự hóa Tỉ lệ với kích thước đầu ra Nhân đôi bộ nhớ

Vậy hãy đo trước khi hành động: trong trình duyệt, bọc từng giai đoạn bằng performance.now(), hoặc dùng console.time; trong Node, dùng process.memoryUsage() để theo dõi mức tăng của heap. Không có đường cơ sở thì không có tối ưu.

2. Lớp phân tích: ba chiến lược

2.1 Phân tích toàn bộ (lựa chọn mặc định)

const data = JSON.parse(text);

Hiện thực native thực hiện việc quét và dựng đối tượng bằng C++, nên một thư viện JS thuần khó lòng vượt qua. Hãy dùng nó chừng nào ba điều kiện sau còn đúng:

  • Văn bản vừa hoàn toàn trong bộ nhớ (thường < 100MB);
  • Phần lớn các trường thật sự cần sau khi phân tích;
  • Mức độ không đáng tin của dữ liệu nằm trong tầm kiểm soát.

2.2 Phân tích tăng dần dạng luồng

Với một trình phân tích kiểu SAX như stream-json, bạn có thể quét ra các trường mục tiêu mà không dựng cây đối tượng hoàn chỉnh:

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) {
  // Process one element at a time; memory is independent of element count
  handleItem(value);
}

Cái giá:

  • Độ phức tạp mã tăng đáng kể và việc xử lý lỗi trở nên lộn xộn hơn;
  • Chỉ có thể truy cập tuần tự, không có truy cập ngẫu nhiên;
  • Không thân thiện với yêu cầu cần tổng hợp toàn cục.

Quy tắc quyết định: truyền luồng chỉ đáng khi tệp quá lớn để vừa, hoặc khi bạn chỉ cần một tập con rất nhỏ các trường.

2.3 Đổi định dạng

Nếu dữ liệu là "một lô bản ghi" thay vì "một tài liệu duy nhất", đáng lẽ bạn nên dùng JSON Lines từ đầu. Một định dạng theo dòng biến "truyền luồng" thành "đọc từng dòng", hạ độ phức tạp hiện thực xuống một bậc độ lớn.

3. Lớp duyệt: bớt một bản sao thắng nửa trận

// Anti-pattern: deep copying first "for safety"
const safeCopy = structuredClone(bigData);   // Memory doubles immediately

// Good pattern: pass references directly in read-only scenarios
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

Các bản sao ẩn thường gặp:

  • JSON.parse(JSON.stringify(x)) dùng như "sao chép sâu" — đây là loại tốn kém nhất;
  • { ...data } chỉ sao chép một cấp, nhưng với một object lớn nó vẫn sao chép mảng tham chiếu của mọi khóa cấp cao nhất;
  • Array.prototype.map tạo một mảng mới, cũng tốn kém khi có nhiều phần tử;
  • Proxy phản ứng của framework (reactive của Vue) tạo proxy cho object lớn một cách đệ quy, và chi phí có thể cao hơn cả việc phân tích.

Lời khuyên thực tế: giữ "dữ liệu thô" là một object bất biến thuần và đừng bọc nó trong hệ phản ứng; hãy tính và đệm riêng dữ liệu dẫn xuất bạn cần hiển thị.

4. Lớp kết xuất: đừng nhồi cả cây vào DOM

Một tệp JSON 5MB có khoảng 30.000 đến 100.000 nút; kết xuất tất cả thành phần tử DOM sẽ làm trình duyệt ngừng phản hồi.

Ba cách khả thi:

4.1 Mở rộng theo yêu cầu

Chỉ kết xuất các con trực tiếp của nút gốc, và chỉ tính cấp kế tiếp khi nút được mở rộng. Đây là cách chuẩn cho trình xem cây:

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * Returns only the currently visible nodes, avoiding building the whole tree at once.
 * @param rootNode Root node
 * @param expandedPaths Set of paths that have been expanded
 * @returns List of visible nodes
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * Recursively collects the child nodes of expanded branches.
   * @param currentNode Current node
   * @param currentPath Current 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 Cuộn ảo

Khi các nút đã mở rộng vượt vài trăm, chỉ kết xuất vài chục nút nằm trong khung nhìn và dự trữ không gian cho phần còn lại bằng chiều cao giữ chỗ. Cách này hiệu quả nhất với chế độ xem dạng danh sách (bảng, nhật ký).

4.3 Chỉ hiển thị tóm tắt

Trước hết trình bày "cấu trúc + kiểu + số lượng" (ví dụ items: Array(12000)); chỉ khi người dùng mở một mục bạn mới lấy giá trị cụ thể. Mẹo này hiệu quả nhất với tình huống "tôi chỉ muốn xác nhận các trường là đúng".

5. Luồng chính: chuyển việc sang Worker khi cần

Việc phân tích, định dạng và diff với bất kỳ dữ liệu nào lớn hơn 1MB nên được đưa vào 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 });
  }
};
/**
 * Parses JSON inside a Worker so the main thread is not blocked.
 * @param text JSON text
 * @returns Parse result
 */
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 });
  });
}

Hãy chú ý chi phí giao tiếp của Worker: postMessage mặc định dùng sao chép có cấu trúc, và sao chép một object 10MB một lần cũng tốn vài chục mili giây. Nếu bạn chỉ cần "phân tích nó và giao cho luồng chính", hãy làm việc nặng bên trong Worker (phân tích + biến đổi + kiểm tra) và gửi lại một tóm tắt kết quả.

6. Lớp truyền tải: tối ưu kích thước

Kỹ thuật Lợi ích điển hình Cái giá
gzip / brotli Đầu ra sau nén thường còn 10%–25% kích thước gốc Cần máy chủ hỗ trợ
Bỏ thụt lề và khoảng trắng Giảm 20%–40% Không đọc được bằng mắt người
Viết tắt tên trường Lợi ích rõ khi lồng sâu Khó đọc hơn; cần bảng ánh xạ
Số thay cho chuỗi enum Nhỏ Cần một từ điển
Phân trang / lấy trường theo yêu cầu Lớn nhất Phải thiết kế lại API

Với các trang dành cho con người, cách kết hợp đúng là: API gửi JSON gọn đã nén, và giao diện chỉ làm đẹp nó để hiển thị. Nhiều trang công cụ mặc định gửi lại văn bản đã in đẹp cho frontend, mang thêm 30% byte một cách vô ích.

7. Cách đo hiệu năng

/**
 * Measures the median duration of a synchronous routine so a single jittery run does not skew the result.
 * @param runOnce Function under test
 * @param repeatCount Number of repetitions
 * @returns Median duration in milliseconds
 */
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)];
}

Ba quy tắc kỷ luật:

  1. Dùng mẫu cố định và không bao giờ sinh dữ liệu ngẫu nhiên, nếu không các con số không so sánh được;
  2. Chạy ít nhất 9 lần và lấy trung vị; lần đầu bao gồm khởi động JIT và phải bị loại bỏ;
  3. Ghi lại môi trường chạy (phiên bản Node, CPU), nếu không việc so sánh giữa các máy là vô nghĩa.

8. Danh sách kiểm tra

  • [ ] Có các con số đường cơ sở để so sánh trước và sau;
  • [ ] Việc phân tích bất kỳ dữ liệu nào lớn hơn 1MB chạy trong Worker;
  • [ ] Chế độ xem cây mở rộng theo yêu cầu và danh sách dùng cuộn ảo;
  • [ ] Không có bản sao sâu hay bọc phản ứng vô ích;
  • [ ] Dùng JSON Lines khi dữ liệu là "một lô bản ghi";
  • [ ] API gửi JSON gọn và việc định dạng chỉ diễn ra để hiển thị.