JSON Toolkit

Câu hỏi thường gặp về hiệu năng và bảo mật: chậm, giật và dữ liệu không đáng tin

Kết luận: Tại sao một tệp JSON vài chục MB không mở được, sao chép sâu tốn kém đến mức nào, làm sao ngăn ô nhiễm __proto__, và XSS lọt vào từ đâu — mười kết luận sơ cứu xếp theo triệu chứng.

Trung cấpĐọc khoảng 13 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa câu hỏi thường gặp về hiệu năng và bảo mật

Tóm lại: hãy chẩn đoán theo triệu chứng trước — phân tích, kết xuất hay bộ nhớ — rồi làm theo thứ tự sửa: giảm lượng dữ liệu, dừng sao chép sâu, đưa phân tích vào Worker kèm cuộn ảo, và dùng dạng luồng cho mọi thứ trên 50 MB. Với đầu vào không đáng tin, hãy giới hạn kích thước và độ sâu lồng nhau trước khi lọc __proto__, constructor và prototype.

"JSON chậm" sai trong chín trên mười trường hợp; thứ thực sự chậm thường là kết xuất, sao chép sâu, hoặc thứ tự thao tác sai.

1. Tại sao nó chậm

Phân tích một tệp JSON lớn rất chậm. Tôi tối ưu thế nào?

Hãy xác định nút cổ chai trước, bàn đến tối ưu sau. Các giai đoạn chênh nhau vài bậc độ lớn:

Giai đoạn Thời gian ước lượng cho 10 MB dữ liệu Tại sao nó là nút cổ chai
Truyền qua mạng Tùy băng thông JSON không nén thường nén được còn khoảng 1/5
JSON.parse 20–80 ms Hiện thực native, vốn đã nhanh
Duyệt đệ quy / sao chép sâu 200–2000 ms Xử lý từng nút bằng JS thuần
Kết xuất DOM (dạng cây) Vài giây, dễ đóng băng Số nút bùng nổ

Vì thế thứ tự tối ưu nên là:

  1. Giảm lượng dữ liệu: để API phân trang, chỉ trả về những trường cần thiết, và bật gzip/brotli;
  2. Tránh sao chép sâu: xem phần tiếp theo; đây là chi phí ẩn phổ biến nhất;
  3. Kết xuất theo yêu cầu: trong dạng xem cây, chỉ kết xuất các cấp đang mở rộng thay vì sinh hàng chục nghìn nút DOM cùng lúc;
  4. Dạng luồng: đừng phân tích tất cả cùng lúc khi dữ liệu vượt quá 10 MB.

Nếu nút cổ chai thực sự là JSON.parse, điều đó nghĩa là dữ liệu đã vài chục MB trở lên, và cách đúng là dùng trình phân tích dạng luồng:

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';

// Handle array elements one by one; memory usage is independent of the total size
const pipeline = fs.createReadStream('big.json')
  .pipe(parser())
  .pipe(streamArray());

pipeline.on('data', ({ value }) => {
  processItem(value);
});

Một JSON vài chục MB làm trình duyệt sập ngay khi mở. Giờ sao?

Trước hết hãy xác nhận nó "sập" hay "đóng băng". Hai trường hợp cần cách xử lý hoàn toàn khác nhau:

  • Đóng băng: tab hiện "Page unresponsive" và có thể phục hồi sau vài chục giây. Nguyên nhân là các tác vụ dài làm nghẽn luồng chính;
  • Sập: tab biến thành "Aw, Snap", thường là cạn bộ nhớ (OOM).

Dù thế nào, đừng xử lý nó trên luồng chính. Hãy đưa việc phân tích vào Web Worker:

// worker.js
self.onmessage = (event) => {
  const parsed = JSON.parse(event.data);
  self.postMessage(parsed);
};

Worker giải quyết "giao diện không đóng băng", không giải quyết trần bộ nhớ. Một tab trình duyệt thường có 1–4 GB khả dụng; một JSON 200 MB được phân tích thành object có thể chiếm 1 GB trở lên (mỗi cặp khóa-giá trị đều mang chi phí object), và cộng thêm kết xuất thì dễ chạm giới hạn.

Giải pháp thực sự là không bao giờ tải tất cả:

Kích thước dữ liệu Cách làm
< 5 MB Phân tích trực tiếp bằng JSON.parse, chú ý khâu kết xuất
5–50 MB Phân tích trong Worker + cuộn ảo
> 50 MB Phân tích dạng luồng, chỉ giữ các trường cần dùng
> 500 MB Đổi định dạng (Parquet / SQLite / JSON Lines chia nhỏ)

JSON.parse(JSON.stringify(obj)) có phải sao chép sâu không?

Nó là sao chép sâu, nhưng nó "âm thầm thay đổi dữ liệu" ở một số chỗ, nên không phù hợp làm công cụ sao chép tổng quát.

const source = {
  date: new Date(),
  fn: () => {},
  undef: undefined,
  nan: NaN,
  map: new Map(),
  nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
//   date: "2026-10-30T03:00:00.000Z",  // Date becomes a string
//   nan: null,                          // NaN becomes null
//   nested: { a: 1 }                    // only this level is correct
// }
// fn and undef disappear, map becomes {}

Danh sách vấn đề:

Kiểu Kết quả
Date Biến thành chuỗi ISO
NaN / Infinity Biến thành null
undefined / hàm / Symbol Biến mất trong object, biến thành null trong mảng
Map / Set Biến thành {}
BigInt Ném TypeError
Tham chiếu vòng Ném TypeError

Trình duyệt hiện đại có structuredClone, hỗ trợ Date, Map, Set và tham chiếu vòng nhưng không hỗ trợ hàm:

const copy = structuredClone(source);

Nếu chỉ là gộp cấu hình nông, toán tử spread là lựa chọn rẻ nhất: { ...config, timeout: 3000 }.

Tại sao mở rộng dạng xem cây lại giật thế?

Vì mọi nút đều là một phần tử DOM, và khi số nút lên đến hàng chục nghìn, chi phí dàn trang của trình duyệt bùng nổ.

Một JSON 10 MB có thể chứa 50k–100k nút lá. Ngay cả khi chỉ kết xuất vùng nhìn thấy, việc dựng DOM một lần tốn hàng trăm mili giây đến vài giây, và mỗi lần cuộn sau đó lại kích hoạt reflow.

Cách đúng:

  1. Cuộn ảo: chỉ kết xuất vài chục dòng trong khung nhìn và tái sử dụng nút DOM khi cuộn;
  2. Thu gọn mặc định: chỉ mở rộng hai cấp đầu và kết xuất các cấp sâu hơn theo yêu cầu;
  3. Giới hạn quy mô kết xuất: trên 10k nút, hãy hiện "Dữ liệu quá lớn, dùng tìm kiếm để định vị" thay vì ép kết xuất.

Đây cũng là lý do trình biên tập trên trang này suy giảm mượt mà với tệp lớn — không phải vì về mặt kỹ thuật nó không thể kết xuất, mà vì kết xuất sẽ không dùng được.

2. Tại sao nó bị tấn công

__proto__ injection hoạt động thế nào?

Ô nhiễm prototype. Kẻ tấn công dựng JSON có khóa __proto__; nếu mã của bạn gộp nó vào một object mà không lọc, nó ghi đè Object.prototype và ảnh hưởng đến mọi object trong runtime.

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Dangerous code: recursive merge without checking key names
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

merge({}, payload);
({}).isAdmin;  // true — every object has been polluted

Sự thật then chốt: bản thân JSON.parse không làm ô nhiễm prototype; {"__proto__": ...} được phân tích thành một thuộc tính riêng bình thường. Điều nguy hiểm là việc gộp, gán, Object.assign, lodash.merge và các thao tác tương tự sau khi phân tích.

Ba lớp phòng thủ:

  1. Lọc tên khóa: bỏ qua __proto__, constructor và prototype khi gộp;
  2. Dùng Object.create(null) làm vật chứa để không có chuỗi prototype để ô nhiễm;
  3. Dùng Map để lưu các cặp khóa-giá trị không đáng tin.
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }
    const value = source[key];
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      target[key] = safeMerge(target[key] ?? {}, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

Cũng lưu ý for...in duyệt cả thuộc tính kế thừa; Object.keys() an toàn hơn.

Cần chú ý gì khi phân tích JSON không đáng tin?

Hãy làm theo thứ tự này — chi phí thấp, lợi ích cao:

  1. Giới hạn kích thước: từ chối ngay mọi thứ vượt ngưỡng, không đọc nó vào bộ nhớ trước;
  2. Giới hạn độ sâu: một JSON lồng hàng trăm cấp có thể làm tràn stack của trình phân tích đệ quy;
  3. Giới hạn số khóa: ngăn tấn công va chạm băm và khuếch đại bộ nhớ;
  4. Lọc khóa nguy hiểm: __proto__, constructor, prototype;
  5. Kiểm chứng cấu trúc: cho nó qua một Schema để xác nhận kiểu và trường bắt buộc;
  6. Đừng tin Content-Type: kiểu được khai báo và nội dung thực tế có thể không khớp.

Kiểm tra độ sâu rẻ; bạn có thể lọc thô bằng regex trước khi phân tích, hoặc duyệt sau khi phân tích:

function measureDepth(value, depth = 0) {
  if (value === null || typeof value !== 'object') {
    return depth;
  }
  let maxDepth = depth;
  for (const key of Object.keys(value)) {
    maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
  }
  return maxDepth;
}

Nhưng đoạn mã này bản thân là đệ quy và vẫn có thể tràn stack với dữ liệu cố tình lồng quá sâu. Cách an toàn là áp đặt giới hạn ngay khi phân tích, dùng trình phân tích hỗ trợ tùy chọn độ sâu (một số thư viện cung cấp tham số maxDepth).

XSS qua JSON xảy ra thế nào?

Bản thân JSON không phải HTML và không thực thi. XSS xảy ra tại điểm "một giá trị JSON được nối vào HTML".

Ba đường phổ biến:

Đường Ví dụ Phòng thủ
Nối trong template phía máy chủ res.send('<div>' + data.name + '</div>') Escape HTML khi xuất ra
innerHTML phía frontend el.innerHTML = data.html Dùng textContent hoặc làm sạch
Nhúng script nội tuyến <script>var d = ${JSON.stringify(data)}</script> Escape <, >, &

Đường thứ ba là tinh vi nhất. JSON.stringify không escape <, nên dữ liệu chứa </script><script>alert(1)</script> có thể đóng thẻ script sớm:

const data = { name: '</script><script>alert(1)</script>' };
// Output: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>

Cách sửa là chuyển các ký tự nguy hiểm thành escape Unicode:

function safeJsonForHtml(data) {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

Có nên giới hạn độ sâu lồng nhau không?

Có. Đây là một trong những biện pháp phòng thủ có tỉ lệ lợi ích/chi phí tốt nhất.

Hậu quả của việc không giới hạn: hàm đệ quy tràn stack, JSON.parse sập trên một số hiện thực khi độ sâu quá lớn, và việc tuần tự hóa cũng làm tràn stack theo cách tương tự. Một [[[[...]]]] 1 KB là đủ để hạ một trình phân tích không được bảo vệ.

Ngưỡng hợp lý tùy nghiệp vụ:

Loại dữ liệu Giới hạn đề xuất
Object nghiệp vụ thông thường 10–20 cấp
Cấu trúc cây (sơ đồ tổ chức, thư mục) 50–100 cấp
API gateway chung 20 cấp

JSON hijacking là gì?

Một lỗ hổng lịch sử: trình duyệt thời đầu cho phép <script src> khác nguồn tải và thực thi JSON, và kẻ tấn công đánh cắp dữ liệu bằng cách định nghĩa lại hàm dựng Array hoặc Object.prototype.__defineSetter__.

Các biện pháp bảo vệ hiện đại đã khá hoàn chỉnh:

  • Trình duyệt không còn cho phép chiếm đoạt array literal bằng __defineSetter__;
  • Content-Type: application/json đúng sẽ ngăn việc thực thi script;
  • X-Content-Type-Options: nosniff ngăn dò MIME.

Hệ thống cũ vẫn còn rủi ro tồn dư. Lời khuyên thực tế: dùng object ở cấp cao nhất, không dùng mảng trần. Một lớp bọc như {"data": [...]} giữ cho phản hồi không phải một câu lệnh JavaScript hợp lệ ({ được coi là khối mã), chặn hijacking ở cấp cú pháp.

Ghi log JSON có những rủi ro gì?

Ba rủi ro:

  1. Thông tin nhạy cảm bị ghi xuống đĩa: số điện thoại, số CMND, token và mật khẩu dạng rõ rơi vào hệ thống log, nơi thường có quyền hạn lỏng hơn và thời gian lưu lâu hơn;
  2. Log injection: một giá trị JSON chứa xuống dòng và tiền tố log giả sẽ tách một bản ghi thành nhiều bản ghi và làm ô nhiễm dấu vết kiểm toán;
  3. Kích thước bùng nổ: ghi cả một phản hồi 10 MB vào log sẽ làm đầy hệ thống log nhanh chóng.

Cách làm:

// Redact and truncate before logging
function safeLog(data) {
  const cloned = structuredClone(data);
  for (const key of ['password', 'token', 'idCard', 'phone']) {
    if (key in cloned) cloned[key] = '***';
  }
  const text = JSON.stringify(cloned);
  return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}

Hệ thống log có cấu trúc (như pino) tự escape xuống dòng, nhưng che dữ liệu và cắt bớt vẫn là việc của bạn.

3. Bảng tra nhanh

Triệu chứng Kiểm tra trước Cách sửa
Phân tích chậm Lượng dữ liệu, có liên quan đến sao chép sâu không Chia lô/dạng luồng, bỏ JSON.parse(JSON.stringify())
Trang đóng băng Tác vụ dài trên luồng chính, số nút được kết xuất Worker + cuộn ảo
Tab sập Mức sử dụng bộ nhớ Đừng tải tất cả; chuyển sang dạng luồng
Sao chép sâu làm mất dữ liệu Date, NaN, Map Dùng structuredClone
Object đột nhiên có thêm thuộc tính Ô nhiễm prototype Lọc __proto__, dùng Map
Một script tự bật lên Chuỗi được nối vào HTML Escape <, dùng textContent
Tràn stack Độ sâu lồng nhau Giới hạn 10–20 cấp
Log đầy lên Ghi cả phản hồi Cắt bớt + che dữ liệu