คำถามที่พบบ่อยเรื่องประสิทธิภาพและความปลอดภัย: ช้า กระตุก และข้อมูลที่ไม่น่าเชื่อถือ
สรุปสำคัญ: ทำไมไฟล์ JSON หลายสิบ MB เปิดไม่ขึ้น, การ deep clone แพงแค่ไหน, จะป้องกันการปนเปื้อน __proto__ อย่างไร และ XSS แทรกเข้ามาทางไหน — สิบข้อสรุปแบบปฐมพยาบาลที่จัดเรียงตามอาการ

สรุปสั้น ๆ: วินิจฉัยตามอาการก่อน — parse, render หรือหน่วยความจำ — แล้วไล่ลำดับการแก้: ลดปริมาณข้อมูล, เลิก deep clone, ย้ายการ parse ไปที่ Worker พร้อม virtual scrolling และใช้ streaming กับข้อมูลที่เกิน 50 MB สำหรับอินพุตที่ไม่น่าเชื่อถือ ให้จำกัดขนาดและความลึกของการซ้อนก่อน แล้วจึงกรอง __proto__, constructor และ prototype
"JSON ช้า" พูดผิดเก้าครั้งจากสิบครั้ง; สิ่งที่ช้าจริงมักเป็นการ render, การ deep clone หรือลำดับการทำงานที่ผิด
1. ทำไมมันถึงช้า
parse ไฟล์ JSON ขนาดใหญ่ช้า จะปรับประสิทธิภาพอย่างไร?
หาคอขวดก่อน แล้วค่อยพูดถึงการปรับ แต่ละขั้นต่างกันหลายระดับขนาด:
| ขั้นตอน | เวลาคร่าว ๆ สำหรับข้อมูล 10 MB | ทำไมจึงเป็นคอขวด |
|---|---|---|
| การส่งผ่านเครือข่าย | ขึ้นกับแบนด์วิดท์ | JSON ที่ไม่บีบอัดมักบีบเหลือประมาณ 1/5 |
JSON.parse |
20–80 ms | เป็น native implementation จึงเร็วอยู่แล้ว |
| การไล่ traversal แบบเรียกซ้ำ / deep clone | 200–2000 ms | ประมวลผลทีละ node ด้วย JS ล้วน |
| การ render DOM (มุมมอง tree) | หลายวินาที ค้างได้ง่าย | จำนวน node พุ่งสูง |
ดังนั้นลำดับการปรับควรเป็น:
- ลดปริมาณข้อมูล: ให้ API ทำ pagination, คืนเฉพาะฟิลด์ที่ต้องใช้ และเปิด gzip/brotli;
- เลี่ยง deep clone: ดูหัวข้อถัดไป นี่คือต้นทุนซ่อนเร้นที่พบบ่อยที่สุด;
- render แบบ lazy: ในมุมมอง tree ให้ render เฉพาะระดับที่ขยาย แทนที่จะสร้าง DOM node หลายหมื่นตัวพร้อมกัน;
- streaming: อย่า parse ทั้งหมดพร้อมกันเมื่อข้อมูลเกิน 10 MB
ถ้าคอขวดอยู่ที่ JSON.parse จริง ๆ นั่นหมายความว่าข้อมูลมีหลายสิบ MB ขึ้นไป และสิ่งที่ควรทำคือ streaming parser:
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// ประมวลผลสมาชิกของ array ทีละตัว หน่วยความจำไม่ขึ้นกับขนาดรวม
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
JSON หลายสิบ MB ทำให้เบราว์เซอร์พังทันทีที่เปิด ทำอย่างไร?
ก่อนอื่นยืนยันว่ามัน "พัง" หรือ "ค้าง" สองกรณีนี้ต้องจัดการต่างกันโดยสิ้นเชิง:
- ค้าง: แท็บขึ้น "Page unresponsive" และอาจฟื้นเองในหลายสิบวินาที สาเหตุคือ long task ที่กิน main thread หมด;
- พัง: แท็บกลายเป็น "Aw, Snap" มักเกิดจากหน่วยความจำหมด (OOM)
ไม่ว่ากรณีไหน อย่าประมวลผลบน main thread ให้ย้ายการ parse ไปที่ Web Worker:
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Worker แก้ปัญหา "UI ไม่ค้าง" ไม่ได้แก้เพดานหน่วยความจำ แท็บของเบราว์เซอร์มักมีหน่วยความจำให้ใช้ 1–4 GB; JSON 200 MB ที่ parse เป็น object อาจกิน 1 GB หรือมากกว่า (ทุกคู่ key-value มี overhead ของ object) พอรวมการ render เข้าไปก็แตะเพดานได้ง่าย
ทางแก้จริงคือ อย่าโหลดทั้งหมด:
| ขนาดข้อมูล | แนวทาง |
|---|---|
| < 5 MB | parse ด้วย JSON.parse ตรง ๆ และระวังการ render |
| 5–50 MB | parse ใน Worker + virtual scrolling |
| > 50 MB | streaming parsing เก็บเฉพาะฟิลด์ที่ต้องใช้ |
| > 500 MB | เปลี่ยนรูปแบบข้อมูล (Parquet / SQLite / JSON Lines แบบแบ่งส่วน) |
JSON.parse(JSON.stringify(obj)) เป็น deep clone ไหม?
เป็น deep clone แต่มัน "เปลี่ยนข้อมูลอย่างเงียบ ๆ" หลายจุด จึงไม่เหมาะเป็นเครื่องมือ clone ทั่วไป
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 กลายเป็น string
// nan: null, // NaN กลายเป็น null
// nested: { a: 1 } // ระดับนี้เท่านั้นที่ถูก
// }
// fn กับ undef หายไป, map กลายเป็น {}
รายการปัญหา:
| ชนิด | ผลลัพธ์ |
|---|---|
Date |
กลายเป็น ISO string |
NaN / Infinity |
กลายเป็น null |
undefined / function / Symbol |
หายไปใน object, กลายเป็น null ใน array |
Map / Set |
กลายเป็น {} |
BigInt |
throw TypeError |
| reference ที่วนซ้ำ | throw TypeError |
เบราว์เซอร์สมัยใหม่มี structuredClone ซึ่งรองรับ Date, Map, Set และ reference ที่วนซ้ำ แต่ไม่รองรับ function:
const copy = structuredClone(source);
ถ้าเป็นแค่การ merge config แบบตื้น spread operator คือตัวเลือกที่ถูกที่สุด: { ...config, timeout: 3000 }
ทำไมขยายมุมมอง tree แล้วกระตุกมาก?
เพราะทุก node เป็น DOM element และเมื่อจำนวน node ถึงหลักหมื่น ต้นทุน layout ของเบราว์เซอร์ก็พุ่ง
JSON 10 MB อาจมี leaf node 50k–100k ตัว แม้จะ render เฉพาะพื้นที่ที่มองเห็น การสร้าง DOM ครั้งเดียวยังใช้เวลาหลายร้อยมิลลิวินาทีถึงหลายวินาที และการเลื่อนจอทุกครั้งก็ทำให้เกิด reflow
แนวทางที่ถูกต้อง:
- virtual scrolling: render เฉพาะไม่กี่สิบแถวใน viewport และนำ DOM node กลับมาใช้ซ้ำขณะเลื่อน;
- ยุบไว้โดยค่าเริ่มต้น: ขยายเฉพาะสองระดับแรก และ render ระดับลึกเมื่อต้องการ;
- จำกัดขนาดการ render: เกิน 10k node ให้แสดง "ข้อมูลใหญ่เกินไป ใช้การค้นหาเพื่อระบุตำแหน่ง" แทนการฝืน render
นี่คือเหตุผลที่ editor บนเว็บไซต์นี้ลดคุณภาพการแสดงผลอย่างนุ่มนวลเมื่อเจอไฟล์ใหญ่ — ไม่ใช่เพราะทางเทคนิคมัน render ไม่ได้ แต่เพราะ render ออกมาแล้วใช้งานไม่ได้
2. ทำไมถึงถูกโจมตี
__proto__ injection ทำงานอย่างไร?
คือ prototype pollution ผู้โจมตีสร้าง JSON ที่มี key __proto__; ถ้าโค้ดของคุณ merge มันเข้า object โดยไม่กรอง
มันจะเขียนทับ Object.prototype และกระทบทุก object ใน runtime
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// โค้ดอันตราย: merge แบบเรียกซ้ำโดยไม่ตรวจชื่อ key
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 — ทุก object ถูกปนเปื้อนแล้ว
ข้อเท็จจริงสำคัญ: JSON.parse เอง ไม่ ปนเปื้อน prototype; {"__proto__": ...} parse ออกมาเป็น own property ธรรมดา
สิ่งที่อันตรายคือการ merge, การกำหนดค่า, Object.assign, lodash.merge และปฏิบัติการคล้ายกันหลัง parse
การป้องกันสามข้อ:
- กรองชื่อ key: ข้าม
__proto__,constructorและprototypeเมื่อ merge; - ใช้
Object.create(null)เป็น container เพื่อไม่มี prototype chain ให้ปนเปื้อน; - ใช้
Mapเพื่อเก็บคู่ key-value ที่ไม่น่าเชื่อถือ
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;
}
โปรดทราบว่า for...in ไล่ property ที่สืบทอดมาด้วย; Object.keys() ปลอดภัยกว่า
ต้องระวังอะไรเมื่อ parse JSON ที่ไม่น่าเชื่อถือ?
ทำตามลำดับนี้ — ต้นทุนต่ำ ผลตอบแทนสูง:
- จำกัดขนาด: ปฏิเสธทันทีถ้าเกินขีดที่ตั้งไว้ โดยไม่ต้องอ่านเข้า memory ก่อน;
- จำกัดความลึก: JSON ที่ซ้อนกันหลายร้อยระดับทำให้ stack ของ parser แบบเรียกซ้ำล้นได้;
- จำกัดจำนวน key: ป้องกันการโจมตีแบบ hash collision และการขยายหน่วยความจำ;
- กรอง key อันตราย:
__proto__,constructor,prototype; - ตรวจโครงสร้าง: ผ่าน Schema เพื่อยืนยันชนิดและฟิลด์ที่จำเป็น;
- อย่าเชื่อ
Content-Type: ชนิดที่ประกาศกับเนื้อหาจริงอาจไม่ตรงกัน
การตรวจความลึกมีต้นทุนต่ำ; ใช้ regex กรองหยาบ ๆ ก่อน parse หรือไล่ตรวจหลัง parse ก็ได้:
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;
}
แต่โค้ดนี้เองก็เรียกซ้ำ และอาจยังล้น stack กับข้อมูลที่จงใจทำลึก วิธีที่ปลอดภัยคือบังคับขีดจำกัดระหว่างการ parse
ด้วย parser ที่รองรับตัวเลือกความลึก (ไลบรารีบางตัวมีพารามิเตอร์ maxDepth)
XSS ผ่าน JSON เกิดขึ้นได้อย่างไร?
JSON เองไม่ใช่ HTML และไม่ถูก execute XSS เกิดตรงจุดที่ "ค่า JSON ถูกต่อ string เข้า HTML"
เส้นทางที่พบบ่อยสามทาง:
| เส้นทาง | ตัวอย่าง | การป้องกัน |
|---|---|---|
| ต่อ string ใน template ฝั่งเซิร์ฟเวอร์ | res.send('<div>' + data.name + '</div>') |
escape HTML ตอนส่งออก |
innerHTML ฝั่ง frontend |
el.innerHTML = data.html |
ใช้ textContent หรือ sanitize |
| ฝังใน script แบบ inline | <script>var d = ${JSON.stringify(data)}</script> |
escape <, >, & |
ข้อที่สามคือข้อที่ละเอียดอ่อนที่สุด JSON.stringify ไม่ escape < ดังนั้นข้อมูลที่มี </script><script>alert(1)</script>
สามารถปิดแท็ก script ก่อนเวลาอันควรได้:
const data = { name: '</script><script>alert(1)</script>' };
// ผลลัพธ์: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
วิธีแก้คือแปลงอักขระอันตรายเป็น Unicode escape:
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
ควรจำกัดความลึกของการซ้อนไหม?
ควร นี่คือหนึ่งในการป้องกันที่คุ้มค่าที่สุดเมื่อเทียบกับต้นทุน
ผลของการไม่จำกัด: ฟังก์ชันเรียกซ้ำทำให้ stack ล้น, JSON.parse พังในบาง implementation เมื่อความลึกมากเกิน
และการ serialize ก็ทำให้ stack ล้นแบบเดียวกัน [[[[...]]]] ขนาด 1 KB ก็เพียงพอที่จะล้ม parser ที่ไม่มีเกราะป้องกัน
ขีดที่สมเหตุสมผลขึ้นกับธุรกิจ:
| ชนิดข้อมูล | ขีดจำกัดที่แนะนำ |
|---|---|
| object ธุรกิจทั่วไป | 10–20 ระดับ |
| โครงสร้าง tree (org chart, ไดเรกทอรี) | 50–100 ระดับ |
| API gateway ทั่วไป | 20 ระดับ |
JSON hijacking คืออะไร?
ช่องโหว่เชิงประวัติศาสตร์: เบราว์เซอร์รุ่นเก่าอนุญาตให้ <script src> ข้ามโดเมนโหลดและ execute JSON และผู้โจมตีขโมยข้อมูลโดยนิยาม
constructor Array หรือ Object.prototype.__defineSetter__ ใหม่
การป้องกันสมัยใหม่ค่อนข้างครบ:
- เบราว์เซอร์ไม่อนุญาตให้ hijack array literal ด้วย
__defineSetter__แล้ว; Content-Type: application/jsonที่ถูกต้องป้องกันการ execute script;X-Content-Type-Options: nosniffป้องกัน MIME sniffing
ระบบเก่ายังมีความเสี่ยงตกค้าง คำแนะนำในทางปฏิบัติ: ใช้ object ที่ระดับบนสุด ไม่ใช่ array เปล่า
wrapper อย่าง {"data": [...]} ทำให้ response ไม่เป็น JavaScript statement ที่ถูกต้อง ({ ถูกมองเป็น code block)
และบล็อก hijacking ที่ระดับไวยากรณ์
การ log JSON มีความเสี่ยงอะไร?
สามข้อ:
- ข้อมูลละเอียดอ่อนถูกเขียนลงดิสก์: เบอร์โทร, เลขบัตรประชาชน, token และรหัสผ่านแบบ plaintext ตกลงในระบบ log ซึ่งมักมีสิทธิ์หลวมกว่าและเก็บนานกว่า;
- log injection: ค่า JSON ที่มีขึ้นบรรทัดใหม่และคำนำหน้า log ปลอมจะแยกระเบียนหนึ่งเป็นหลายรายการและปนเปื้อน audit trail;
- ขนาดพุ่ง: การเขียน response 10 MB ทั้งก้อนลง log ทำให้ระบบ log เต็มอย่างรวดเร็ว
แนวทาง:
// ปิดข้อมูลและตัดทอนก่อน log
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;
}
ระบบ log แบบ structured (เช่น pino) escape ขึ้นบรรทัดใหม่เอง แต่การปิดข้อมูลและการตัดทอนยังเป็นหน้าที่ของคุณ
3. สรุปเร็ว
| อาการ | ตรวจอะไรก่อน | วิธีแก้ |
|---|---|---|
| parse ช้า | ปริมาณข้อมูล, มี deep clone หรือไม่ | แบ่งส่วน/streaming, เลิกใช้ JSON.parse(JSON.stringify()) |
| หน้าเว็บค้าง | long task บน main thread, จำนวน node ที่ render | Worker + virtual scrolling |
| แท็บพัง | การใช้หน่วยความจำ | อย่าโหลดทั้งหมด เปลี่ยนไปใช้ streaming |
| deep clone ข้อมูลหาย | Date, NaN, Map |
ใช้ structuredClone |
| object จู่ ๆ มี property เกินมา | prototype pollution | กรอง __proto__, ใช้ Map |
| มี script เด้งขึ้นมา | string ถูกต่อเข้า HTML | escape <, ใช้ textContent |
| stack overflow | ความลึกของการซ้อน | จำกัดที่ 10–20 ระดับ |
| log เต็ม | log ทั้ง response | ตัดทอน + ปิดข้อมูล |