למה עצי JSON גדולים מגמגמים: גלילה וירטואלית ואסטרטגיות קיפול
שורה תחתונה: הגמגום מתחיל כשנוצרים צמתי DOM, לא כשגוללים. הסבר על רינדור עם קיפול תחילה, רשימות וירטואליות, הדגשת חיפוש וגבולות Worker, עם מדדים.

שורה תחתונה: פצלו את "איטי" לניתוח, יצירת צמתים, פריסה וציור ותגובה לאינטראקציה, ומדדו כל שלב. יישמו בסדר הזה — קיפול כברירת מחדל, הוספת רשימה וירטואלית, העברת החישוב ל-Worker והגבלת ההדגשה לשורות נראות. קפיצה ישירה לרשימה וירטואלית בלי קיפול רק מקטינה את הקבוצה הנראית; חישוב מחדש של העץ המלא עדיין עולה לכם.
הגמגום מגיע בדרך כלל ממאות אלפי צמתי ה-DOM שיצרתם עבור אותם 5MB, לא מה-5MB עצמם.
1. פצלו את "איטי" לארבעה שלבים
כשמשתמש אומר "זה קופא בפתיחה", הבעיה יכולה לשבת בארבעה שלבים שונים לחלוטין, שלכל אחד מהם תרופה אחרת:
| שלב | טריגר טיפוסי | סף מורגש | העלות השלטת |
|---|---|---|---|
| ניתוח ובניית אובייקטים | פתיחת קובץ, הדבקת טקסט | כל משימה מעל 50ms | סריקת מחרוזות, בניית גרף האובייקטים |
| יצירת צמתי DOM | רינדור כל העץ בבת אחת | עשרות אלפי צמתים | הקצאת elements, צמתי טקסט, אובייקטי סגנון |
| פריסה וציור | תצוגה ראשונה, גלילה | כל frame מעל 16.7ms | גיאומטריה, reflow, ציור מחדש |
| תגובה לאינטראקציה | הרחבה, קיפול, חיפוש | מעל 100ms מורגש כמאוחר | שיטוח מחדש ורינדור מחדש |
ה-thread הראשי של הדפדפן עושה דבר אחד בכל רגע. אם שלב כלשהו תופס אותו יותר מ-50ms, הגלילה וההקלדה מאבדות frames; כמה משימות כאלה ברצף והדף נראה מת. האופטימיזציה הראשונה אינה requestAnimationFrame אפוא; היא למדוד איזה שלב באמת מבזבז את הזמן.
2. מספר הצמתים הוא האויב האמיתי
צופי עצים מכפילים מספרי צמתים כברירת מחדל. שורת JSON אחת הופכת בדרך כלל ל"הזחה + מפתח + נקודתיים + ערך + פסיק + חץ", ולכן הערכה שמרנית היא 3 עד 6 צמתי DOM לשורה:
| גודל קובץ | שורות משוערות | צמתי DOM בתצוגת עץ (הערכה) | איך נראה רינדור חד-פעמי |
|---|---|---|---|
| 100 KB | 2,500 | 10,000 | בדרך כלל נסבל |
| 1 MB | 25,000 | 100,000 | גמגום ברור, אולי שניות של מסך ריק |
| 5 MB | 125,000 | 500,000 | סביר מאוד שלא יגיב |
| 20 MB | 500,000 | מעל 2 מיליון | גם הזיכרון וגם הפריסה קורסים |
כל element הוא יותר מכמה עשרות בייטים של אובייקט: הוא נושא כללי סגנון, תיבת פריסה והוראות ציור. אחרי שנוצר, צומת עדיין עולה עבודת פריסה וסגנון גם אם הוסתר אחר כך על ידי הורה מקופל, כל עוד הוא לא הוסר בפועל. "לרנדר הכול ואז להסתיר" אינו אופטימיזציה; הוא רק דוחה את העלות ל-reflow הבא.
3. קודם קיפול, אחר כך וירטואליזציה
גלילה וירטואלית פותרת "יותר מדי שורות נראות". קיפול פותר "בכלל לא אמורים להזדקק לכמות כזו של שורות". הסדר חשוב: עבור מסמך של מיליון שורות שנפתח כברירת מחדל, אפילו רשימה וירטואלית מושלמת חייבת לחשב מחדש את כל העץ כשהמשתמש לוחץ "קפל הכול".
אסטרטגיית קיפול ישימה:
- הרחיבו רק את שתי הרמות הראשונות כברירת מחדל. השורש והשדות הישירים שלו נראים; רמות עמוקות יותר מציגות סיכום כמו
items: Array(12000); - שימו טיפוס וספירה בסיכום. משתמשים שבודקים "האם שמות השדות נכונים" כמעט לא צריכים עשרת אלפים רשומות;
- זכרו שורות לפי תת-עץ. לכל צומת מורחב יש מערך שורות משוטח משלו; קיפול משליך אותו, והרחבה מחשבת מחדש רק את תת-העץ הזה;
- לעולם אל תסרקו את העץ כולו בכל החלפה. אם ההרחבה מתרחשת בשורה 3,000, החליפו רק את הטווח הזה והשאירו את השורות שמסביב כמות שהן.
/**
* הכנסת השורות של תת-עץ אחרי ההורה שלו במקום שיטוח מחדש של העץ כולו.
* @param rows מערך השורות הנראות הנוכחי
* @param parentIndex האינדקס של שורת ההורה
* @param childRows השורות המשוטחות של תת-העץ
* @returns מערך חדש של שורות נראות
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
הדפוס שיש להימנע ממנו הוא "הרחבה אחת, פריסה מחדש מלאה". הליכה חוזרת על גרף האובייקטים השלם בכל לחיצה גורמת לעלות לגדול ליניארית עם המסמך. עם מטמון לכל תת-עץ, הרחבה בודדת עולה רק כמו תת-העץ הזה.
4. רשימות וירטואליות: רנדרו רק את ה-viewport
אחרי שהקיפול צמצם את מספר השורות, הנותר יכול עדיין להיות עשרות אלפי שורות. רשימה וירטואלית מגדירה את גובה המכל ל"סך השורות x גובה שורה קבוע" ויוצרת elements רק עבור ה-viewport ועוד חיץ קטן מעל ומתחת.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* חישוב טווח השורות שה-viewport צריך לרנדר.
* @param rows כל השורות הנראות
* @param scrollTop ההיסט הנוכחי של הגלילה במכל
* @param viewportHeight הגובה הנראה של המכל
* @returns השורות לרינדור, אינדקס ההתחלה וההיסט האנכי
*/
function getViewportRows(
rows: TreeRow[],
scrollTop: number,
viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(
rows.length,
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
);
const visibleNodes = rows.slice(start, end);
return {
visibleRows: visibleNodes,
startIndex: start,
offsetTop: start * ROW_HEIGHT,
};
}
אילוצים מוחלטים:
- גובה השורה חייב להיות קבוע. רק אז
scrollTop / ROW_HEIGHTנותן את האינדקס ישירות. התרת שורות נשברות מחייבת למדוד ולזכור כל גובה, מה שמוסיף סדר גודל של מורכבות ורעד; - הזיזו עם
transformאו מיקום מוחלט. אל תדחפו תוכן למטה עם element מרווח, אחרת כל גלילה משנה את הפריסה; - השתמשו במפתחות יציבים. תנו מפתח לשורות לפי נתיב, לא לפי אינדקס מערך, אחרת הרחבת שורה אחת מייצרת מחדש כל שורה אחריה;
- עשו אריתמטיקה רק במטפל הגלילה. דחו את הרינדור ל-
requestAnimationFrameוהגנו מפני תזמון כפול; - שמרו את החיץ קטן. חמש עד עשר שורות בכל צד מספיקות; הכפלת החיץ מכפילה צמתי DOM ועבודת סגנון.
עם viewport של 800 פיקסלים ושורות של 24 פיקסלים, בערך 34 שורות נראות, ואפילו עם החיץ מרנדרים כחמישים. המסמך הבסיסי יכול להחזיק מאה אלף שורות בעוד ה-DOM מחזיק באופן עקבי כמה מאות elements, מה ששומר על עלות פריסה שטוחה.
5. קיפול, הרחבה ועיגון גלילה
ברגע שאינדקס השורה הופך לקואורדינטה היחידה, כמה פרטי אינטראקציה מתחילים להישבר:
- שמרו את העוגן החזותי כשמקפלים מעל ה-viewport. השורות שמתחת זזות כלפי מעלה; אם
scrollTopלא מותאם באותו commit, התוכן נראה קופץ. רשמו את הנתיב של שורת העוגן ושחזרו את מיקום הגלילה לפי האינדקס החדש שלו. - הרחיבו אבות קודם לקפיצה. כשחיפוש פוגע בשדה עמוק, הרחיבו קודם כל הורה, אחר כך חשבו את אינדקס שורת היעד, ואז גללו והדגישו. דלגו על הצעד הראשון ושורת היעד אפילו לא נמצאת במערך הנראה.
- הניעו ניווט במקלדת מתוך השורות הנראות. למעלה ולמטה מזיזים את אינדקס השורה; שמאלה וימינה מרחיבים ומקפלים. לעולם אל תעברו על העץ כולו בתוך מטפל מקש.
- אגדו עדכונים ל-frame אחד. כשמרחיבים כמה רמות ברצף, מזגו את שינויי המערך ורנדרו פעם אחת, אחרת כל רמה מפעילה reflow מלא.
6. חיפוש והדגשה: חשבו פעם אחת, הציגו מעט
חיפוש הוא מלכודת הביצועים הנפוצה ביותר שמתעלמים ממנה בצופה עצים. אם כל הקשה עוברת על גרף האובייקטים כולו ובונה מחדש את רשימת התוצאות, מסמך של מאה אלף צמתים יקפא מיד.
גישה שכבתית:
- עכבו את הקלט ב-150-250ms ותייגו כל חיפוש במספר סידורי כדי לזרוק תוצאות מיושנות;
- סרקו ב-Worker. הליכה על גרף אובייקטים היא חישוב טהור בלי תלות ב-DOM, ולכן היא לא צריכה לחסום הקלדה;
- שמרו רק נתיבים ואינדקסי שורות. אל תעתיקו כל צומת מתאים ואל תבנו מראש HTML מודגש;
- הדגישו רק שורות נראות. ה-viewport מחזיק כמה עשרות שורות; החילו סגנונות לפי נתיב מתאים בזמן הרינדור;
- השתמשו שוב ב"הרחיבו אבות, ואז אתרו את האינדקס" לקפיצות, באותו נתיב שמשמש ללחיצה על חץ.
פרט אבטחה אחד: מפתחות וערכים בצופה עצים הם נתוני משתמש. בניית HTML מודגש מהם מחייבת escaping, אחרת מחרוזת שמכילה תגיות הופכת ל-XSS. צופה JSON לא צריך rich text, ולכן צמתי טקסט עם שמות מחלקה גם בטוחים יותר וגם מהירים יותר.
7. הדגשת תחביר: CodeMirror ו-Monaco הם כלים שונים
תצוגת עץ ועורך טקסט הם מודלי רינדור שונים, ולכן "איזה מהיר יותר" אינו השוואה ישירה:
| ממד | CodeMirror 6 | Monaco Editor |
|---|---|---|
| מיצוב | ליבת עורך מודולרית | עורך IDE מלא |
| רינדור | מבוסס viewport | מבוסס viewport, אבל מודלים וקישוטים כבדים יותר |
| ניתוח | ניתוח אינקרמנטלי של Lezer על טווחים שהשתנו | טוקנייזר Monarch, שורה אחר שורה |
| קבצים גדולים | תלוי בהרחבות; קל כברירת מחדל | מלא בתכונות, יותר זיכרון ועלות אתחול |
| קיפול וחיפוש | הרחבות נפרדות, נטענות לפי דרישה | מובנים, עם תקורה קבועה |
| מלכודת טיפוסית | יותר מדי הרחבות מחזירות את העלות | minimap, גלילה דביקה וקישוטים מוערמים |
שתי מסקנות מעשיות:
- תצוגת עץ לא צריכה טוקנייזר מלא. תנו למפתחות, מחרוזות, מספרים ובוליאנים צבע אחד כל אחד עם צמתי טקסט פשוטים ומחלקות CSS; זה עולה סדר גודל פחות מהרצת הדגשת תחביר;
- אל תרכיבו תצוגת עץ ועורך בו-זמנית. תחזוקה של שני מודלים למסמך אחד מכפילה זיכרון; השמידו את התצוגה הישנה במקום להסתיר אותה.
אם עורך באמת שייך בתוך הצופה, CodeMirror 6 מתאים ל"קריאה בלבד, קובץ גדול, הדגשה מקומית", בעוד Monaco מתאים להשלמה, אבחון ועריכת multi-cursor. מדדו זמן ציור ראשון וזיכרון תושב לפני שאתם קוראים את רשימת התכונות. להשוואה מעמיקה יותר של עורכים וכלי CLI, ראו עורכים, כלי שורת פקודה ואוטומציה.
8. גבולות Worker: העבירו ניתוח ובניית שורות מה-thread הראשי
החצי השני של גמגום הרינדור הוא ניתוח ב-thread הראשי. עבור JSON מעל 1MB, ניתוח, עיצוב ובניית שורות שטוחות שייכים ל-Web Worker; ה-thread הראשי רק מקבל שורות וצובע את ה-viewport.
Workers אינם בחינם: postMessage משתמש ב-structured clone, ולכן שליחת גרף אובייקטים של 50MB חזרה ל-thread הראשי בונה העתקה שנייה ומכפילה את השיא. הגבול הנכון הוא לנתח ולשטח בתוך ה-Worker, ולהחזיר רק את השורות שהרנדרר צריך:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
אם נתוני השורות עדיין גדולים, דחסו שורות למערכים מקבילים (נתיב, טיפוס, היסט ערך), הניחו אותם ב-ArrayBuffer והעבירו בעלות כדי שה-thread הראשי לא ישלם על העתקה. המחיר הוא כתיבת סריאליזר משלכם, ושווה לעשות זאת רק במיליוני שורות.
הכלל לגבול פשוט: ה-thread הראשי צריך לעשות רק עבודה שחייבת לגעת ב-DOM. ניתוח, חיפוש, מיון, עיצוב ו-diffing יכולים כולם לעבור משם. רינדור, גלילה, קלט ופוקוס נשארים ב-thread הראשי, וכל frame חייב להתאים לתקציב העבודה.
9. מדדים וסדר מימוש
אופטימיזציה לא יכולה לעצור ב"זה מרגיש מהיר יותר". הקבוצה הבאה יכולה להיכנס ישירות לצ'קליסט קבלה:
| מדד | יעד | איך למדוד |
|---|---|---|
| frame נראה ראשון | מתחת ל-300ms | performance.now() מפתיחה עד ציור ראשון |
| משימות ארוכות | אף אחת מעל 50ms | PerformanceObserver שעוקב אחרי longtask |
| זמן frame בגלילה | יציב מתחת ל-16.7ms | הקלטת Performance ב-DevTools |
| צמתי DOM ב-viewport | מתחת ל-2,000 | document.querySelectorAll('*').length |
| תגובת הרחבה / קיפול | מתחת ל-50ms | חותמות זמן סביב האינטראקציה |
| חיפוש על 100,000 שורות | מתחת ל-300ms, בתוך Worker | מדדו ב-Worker והחזירו את משך הזמן |
| heap תושב | מתחת לפי 3 מגודל הטקסט | snapshot של ה-heap בפאנל Memory ב-DevTools |
מדדו עם קובץ אמיתי ולא עם אובייקטים קטנים שנוצרו אחיד; צורת הנתונים חשובה, ומחרוזות ארוכות, קינון עמוק ומערכים רחבים מאוד מפיקים עקומות שונות. לפנקס גרף האובייקטים עצמו, ראו פנקס הזיכרון של JSON.
יישמו בסדר הזה:
- מדדו קו בסיס: זמן רינדור ראשון, מספר צמתים, זמן frame בגלילה;
- קיפול כברירת מחדל: הרחיבו שתי רמות, הציגו טיפוס וספירה בסיכומים;
- רשימה וירטואלית: גובה שורה קבוע, מפתחות יציבים, viewport ועוד חיץ קטן;
- העברת חישוב: ניתוח, חיפוש ושיטוח ל-Worker;
- הגבלת הדגשה: עיצוב שורות נראות בלבד, הימנעות מציור מחדש של העץ כולו;
- הוספת מעקות בטיחות: מעל סף, הציעו "הצג את N השורות הראשונות" או חייבו מסנן חיפוש.
לכל צעד מטרה אחת: לצמצם את הצמתים והאובייקטים שחייבים להתקיים באותו רגע, במקום לבקש מהדפדפן לעבוד קשה יותר עליהם. הרקע המלא לאסטרטגיה השכבתית נמצא בניתוח, רינדור והזרמה של JSON גדול.
האם גלילה וירטואלית יכולה לפתור גמגום בזמן ניתוח?
לא. גלילה וירטואלית רק מקטינה צמתי DOM. אם הניתוח עדיין מתרחש ב-thread הראשי, הדף עדיין יגמגם. העבירו את הניתוח ל-Worker עבור מסמכים מעל 1MB וטפלו בשתי הבעיות בנפרד.
הכל מורחב וזה עדיין מגמגם. מה לא בסדר?
או שהרשימה הווירטואלית לא באמת פעילה, או שכל הרחבה סורקת את העץ כולו. בדקו אם שורות מחוץ למסך עדיין יוצרות DOM, ואם הרחבה מחשבת מחדש רק את תת-העץ המושפע.
CodeMirror או Monaco לצופה JSON?
העדיפו CodeMirror 6 לקריאה בלבד, קבצים גדולים והדגשה מקומית; הטעינה המודולרית לפי דרישה שלו קלה יותר. בחרו Monaco כשצריך השלמה, אבחון ועריכת multi-cursor. עם כל אחד מהם, הימנעו מהרכבת תצוגת עץ ועורך לאותו מסמך בו-זמנית.
למה מיקום הגלילה שגוי אחרי קפיצת חיפוש?
בדרך כלל מפני שהתצוגה גללה לפני ההרחבה. הרחיבו קודם כל את האבות, אחר כך קבעו את מיקום הגלילה לפי אינדקס השורה החדש, והדגישו לבסוף. תנו מפתח לשורות לפי נתיב ולא לפי אינדקס, כדי שהרחבה לא תזיז כל אינדקס.
FAQ
איך אדע מאיזה שלב מגיע הגמגום?
פצלו את "איטי" לניתוח ובניית אובייקטים, יצירת צמתי DOM, פריסה וציור ותגובה לאינטראקציה, ומדדו כל שלב עם performance.now() מפתיחה עד ציור ראשון. אחר כך עקבו אחרי longtask עם PerformanceObserver — כל משימה מעל 50ms מאבדת frames, והקלטת Performance ב-DevTools מראה אם זמן frame בגלילה נשאר מתחת ל-16.7ms.
למה רינדור כל העץ בבת אחת מרוקן את המסך?
מסמך של 5MB הוא בערך 125,000 שורות, ותצוגת עץ הופכת בדרך כלל כל שורה ל-3 עד 6 צמתי DOM — בערך 500,000 elements בבת אחת. כל element נושא גם כללי סגנון, תיבת פריסה והוראות ציור, ולכן כמה משימות ב-thread הראשי מעל 50ms ברצף גורמות לדף להיראות מת. צמתים שהוסתרו על ידי הורה מקופל ממשיכים לעלות עבודת פריסה, ולכן "לרנדר הכול ואז להסתיר" אינו אופטימיזציה.
האם לשלוח גרף אובייקטים של 50MB חזרה מה-Worker?
לא. postMessage משתמש ב-structured clone, ולכן הצד המקבל בונה העתקה שנייה והשיא מוכפל. הגבול הנכון הוא לנתח ולשטח בתוך ה-Worker ולהחזיר רק את השורות שהרנדרר צריך; במיליוני שורות אפשר לארוז מערכים מקבילים (נתיב, טיפוס, היסט ערך) ב-ArrayBuffer ולהעביר בעלות.