ניתוח, רינדור והזרמה של JSON גדול
שורה תחתונה: ניתוח הוא לעתים קרובות לא צוואר הבקבוק; רינדור והעתקה כן. בצעו אופטימיזציה לאורך ארבע השכבות "ניתוח → מעבר → רינדור → העברה" כדי שהמאמץ לא ילך לאיבוד.

שורה תחתונה: מדדו בנפרד ניתוח, מעבר, רינדור והעברה, ואז עבדו בסדר הזה — קצצו העתקות, הרחיבו לפי דרישה עם גלילה וירטואלית, העבירו עבודה ל-Worker, עברו להזרמה, ורק אז שנו את הפורמט. הזרמה שווה את זה רק כשהקובץ לא נכנס לזיכרון או כשצריך תת-קבוצה קטנה של השדות שלו.
המסקנה "JSON.parse איטי" שגויה שמונה פעמים מתוך עשר. מה שבאמת איטי הוא מה שאתם עושים אחרי הניתוח.
1. מדדו קודם, ואז בצעו אופטימיזציה
עבור אותו קובץ JSON של 10MB, הזמן שמבוזבז בשלבים שונים נבדל בסדרי גודל:
| שלב | משך טיפוסי | עלות עיקרית |
|---|---|---|
JSON.parse (native) |
עשרות עד קצת יותר ממאה מילישניות | סריקה ליניארית אחת ועוד בניית אובייקטים |
העתקה עמוקה (structuredClone / רקורסיבית) |
אולי פי 1–3 מהניתוח | בנייה מחדש של כל גרף האובייקטים |
| השוואה עמוקה / diff | אולי פי שניים נוספים | מעבר רקורסיבי ועוד השוואת מחרוזות |
| רינדור לעץ DOM | קופא כשמגיעים לעשרות אלפי צמתים | פריסה ו-reflow |
| שרשור מחרוזות ואז סריאליזציה | פרופורציונלי לגודל הפלט | מכפיל זיכרון |
אז מדדו לפני שאתם פועלים: בדפדפן, עטפו כל שלב ב-performance.now() או השתמשו ב-console.time;
ב-Node, השתמשו ב-process.memoryUsage() כדי לעקוב אחרי גידול ה-heap. אין קו בסיס, אין אופטימיזציה.
2. שכבת הניתוח: שלוש אסטרטגיות
2.1 ניתוח מלא (ברירת המחדל)
const data = JSON.parse(text);
המימוש ה-native מבצע את הסריקה ואת בניית האובייקטים ב-C++, כך שספריית JS טהורה כמעט לא יכולה לנצח אותו. השתמשו בו כל עוד שלושת התנאים הבאים מתקיימים:
- הטקסט נכנס כולו לזיכרון (בדרך כלל < 100MB);
- רוב השדות באמת נדרשים אחרי הניתוח;
- מידת חוסר האמון בנתונים ניתנת לניהול.
2.2 ניתוח אינקרמנטלי זורם
עם פרסר בסגנון 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);
}
עלויות:
- מורכבות הקוד עולה משמעותית וטיפול בשגיאות נעשה מבולגן יותר;
- אפשרית רק גישה סדרתית, בלי גישה אקראית;
- היא עוינת דרישות שצריכות אגרגציה גלובלית.
כלל החלטה: הזרמה שווה את זה רק כשהקובץ גדול מדי כדי להיכנס, או כשצריך רק תת-קבוצה קטנה מאוד של השדות שלו.
2.3 שינוי הפורמט
אם הנתונים הם "אצווה של רשומות" ולא "מסמך אחד", הייתם צריכים להשתמש ב-JSON Lines מלכתחילה. פורמט מופרד בשורות הופך את "ההזרמה" ל"קריאה שורה אחר שורה", ומוריד את מורכבות המימוש בסדר גודל.
3. שכבת המעבר: העתקה אחת פחות מנצחת חצי מהקרב
// אנטי-דפוס: העתקה עמוקה קודם "למען הבטיחות"
const safeCopy = structuredClone(bigData); // הזיכרון מוכפל מיד
// דפוס טוב: העבירו הפניות ישירות בתרחישי קריאה בלבד
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
העתקות נסתרות נפוצות:
JSON.parse(JSON.stringify(x))בתור "העתקה עמוקה" — זה הסוג היקר ביותר;{ ...data }מעתיק רק רמה אחת, אבל עבור אובייקט גדול הוא עדיין מעתיק את מערך ההפניות של כל המפתחות ברמה העליונה;Array.prototype.mapיוצר מערך חדש, שגם הוא יקר כשיש הרבה elements;- פרוקסי reactivity של מסגרות (ה-
reactiveשל Vue) עוטפים אובייקטים גדולים רקורסיבית, והתקורה יכולה להיות גבוהה מהניתוח עצמו.
עצה מעשית: שמרו את "הנתונים הגולמיים" כאובייקט פשוט ובלתי משתנה ואל תעטפו אותו ב-reactivity; חשבו וזכרו בנפרד את הנתונים הנגזרים שצריך להציג.
4. שכבת הרינדור: אל תדחפו את העץ כולו ל-DOM
לקובץ JSON של 5MB יש בערך 30,000 עד 100,000 צמתים; רינדור כולם כ-elements של DOM יגרום לדפדפן להפסיק להגיב.
שלוש גישות ישימות:
4.1 הרחבה לפי דרישה
רנדרו רק את הילדים הישירים של צומת השורש, וחשבו את הרמה הבאה רק כשהצומת מורחב. זו הגישה הסטנדרטית של צופי עצים:
type TreeNode = { key: string; value: unknown; expanded: boolean };
/**
* מחזיר רק את הצמתים הנראים כרגע, כדי להימנע מבניית העץ כולו בבת אחת.
* @param rootNode צומת השורש
* @param expandedPaths קבוצת הנתיבים שהורחבו
* @returns רשימת הצמתים הנראים
*/
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
const visibleNodes: TreeNode[] = [];
/**
* אוסף רקורסיבית את צומתי הילדים של ענפים מורחבים.
* @param currentNode הצומת הנוכחי
* @param currentPath הנתיב הנוכחי
*/
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 גלילה וירטואלית
ברגע שצמתים מורחבים עוברים כמה מאות, רנדרו רק את כמה העשרות שבתוך ה-viewport והקצו מקום לשאר עם גבהים של placeholder. היא יעילה ביותר בתצוגות בסגנון רשימה (טבלאות, לוגים).
4.3 הצגת סיכומים בלבד
הציגו קודם "מבנה + טיפוס + ספירה" (למשל items: Array(12000)); רק כשהמשתמש פותח פריט תביאו את הערך הקונקרטי.
הטריק הזה עובד הכי טוב בתרחיש "אני רק רוצה לוודא שהשדות נכונים".
5. ה-thread הראשי: העבירו עבודה ל-Worker כשצריך
ניתוח, עיצוב ו-diffing של כל דבר גדול מ-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 כדי שה-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 });
});
}
שימו לב לעלות התקשורת של Workers: postMessage משתמש ב-structured cloning כברירת מחדל, ושכפול אובייקט של 10MB פעם אחת גם הוא לוקח עשרות מילישניות.
אם כל מה שצריך הוא "לנתח ולהעביר ל-thread הראשי", עשו את העבודה הכבדה בתוך ה-Worker (ניתוח + טרנספורמציה + אימות) והחזירו סיכום תוצאה.
6. שכבת ההעברה: אופטימיזציית גודל
| טכניקה | רווח טיפוסי | עלות |
|---|---|---|
| gzip / brotli | פלט דחוס הוא לעתים קרובות 10%–25% מהגודל המקורי | דורש תמיכת שרת |
| הסרת הזחה ורווח לבן | הפחתה של 20%–40% | לא קריא לבני אדם |
| קיצור שמות שדות | רווחים ניכרים עם קינון עמוק | קריאות נמוכה; דורש טבלת מיפוי |
| מספרים במקום מחרוזות enum | קטן | דורש מילון |
| עימוד / שדות לפי דרישה | הגדול ביותר | עיצוב מחדש של API |
עבור דפים שמיועדים לבני אדם, השילוב הנכון הוא: ה-API שולח JSON קומפקטי ודחוס, וה-UI מייפה אותו רק לתצוגה. אתרי כלים רבים שולחים כברירת מחדל טקסט יפה שוב לפרונט, ומעבירים 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 פעמים וקחו את החציון; הריצה הראשונה כוללת חימום JIT וחייבים לזרוק אותה;
- רשמו את סביבת הריצה (גרסת Node, CPU), אחרת השוואות בין מכונות חסרות משמעות.
8. צ'קליסט
- [ ] קיימים מספרי קו בסיס כדי שאפשר להשוות לפני ואחרי;
- [ ] כל ניתוח של משהו גדול מ-1MB רץ ב-Worker;
- [ ] תצוגות עץ מתרחבות לפי דרישה ורשימות משתמשות בגלילה וירטואלית;
- [ ] אין העתקות עמוקות חסרות תכלית ואין עטיפת reactivity;
- [ ] JSON Lines משמש כשהנתונים הם "אצווה של רשומות";
- [ ] ה-API שולח JSON קומפקטי והעיצוב קורה רק לתצוגה.