• בלוג
  • הגורמים לבעיות זכרון ב JavaScript וכיצד נתמודד אתם

הגורמים לבעיות זכרון ב JavaScript וכיצד נתמודד אתם

פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
 

בעיות זכרון קיימות ב JavaScript כשם שהן קיימות בשפות אחרות, למרות השימוש ב Garbage Collector. ב JavaScript בעיות זכרון נגרמות מניצול לא נכון של משאבי המערכת, חוסר מודעות ולפעמים גם חוסר ידע. בפוסט זה תלמדו על הגורמים הנפוצים לבעיות זכרון ב JavaScript ומה כדאי לזכור כשכותבים אפליקציה כדי להימנע מבעיות אלו.

1. למה חשוב לזהות בעיות זכרון דווקא עכשיו

בעיות זכרון היו קיימות ב JavaScript מאז תחילת ימי השפה בשנת 1995, אך בעוד באתרים ישנים כמעט ולא שמנו לב לנושא ניהול הזכרון, בשנים האחרונות חל שינוי גדול בחומרת הבעיות ובהשפעה שלהן על חווית הגלישה באתרים.

אתרי אינטרנט וותיקים נכתבו כאוסף של דפי HTML, כאשר כל דף תפקד באופן עצמאי. מבחינת קוד ה JavaScript, כל מעבר עמוד טען קובץ HTML חדש מהשרת. מאחר ומרחב הזכרון של JavaScript מחובר לעמוד HTML, כל שינוי דף הביא איתו ניקוי מוחלט של הזכרון. 
בשיטה זו ניהול הזכרון הוא טריוויאלי — גם העמוד הבזבזן ביותר לא מספיק לצרוך מספיק זכרון כדי לגרום לבעיות במעט הזמן שהוא פעיל. 
באנלוגיה אפשר להגיד שדפדפן מתנהג קצת כמו מערכת הפעלה, וכל דף הוא תהליך. כשאנחנו יוצאים מדף אנו סוגרים את התהליך, מוחקים את הזכרון שמוקצה עבורו וממשיכים לפתוח תהליך חדש.

כיום שיטת העבודה השתנתה. אפליקציות Web מודרניות (אפליקציות צד-לקוח) ממומשות לרוב בעמוד HTML אחד. כל מעברי הדפים מבוצעים על ידי תבניות צד-לקוח, וקריאות Ajax לשרת. שינוי זה הוביל לכך שאנו נשארים הרבה יותר זמן באותו דף: למעשה באפליקציות מורכבות אין מעברי דפים כלל.
קחו את Gmail לדוגמא: כתיבת הודעה, מעבר בין תיקיות, התחלה וסיום צ'ט ואפילו כניסה למסך ההגדרות, כל הפעולות הללו מבוצעות כשאנו באותו עמוד HTML.
מאחר ואפליקציית צד-לקוח נשארת באותו עמוד HTML לאורך זמן רב יותר, עלול להיווצר מצב שצריכת הזכרון תגדל באופן כזה שהדפדפן לא יוכל לעמוד בעומס. מה שבעבר נפתר על ידי גלישה לעמוד חדש (שבוצעה באופן תדיר), כיום מוביל להדרדרות בביצועי האפליקציה עד לקריסה של הדפדפן.

אז מהי בעיית זכרון ? יישום JavaScript שיש בו בעיית זכרון הוא יישום שצריכת הזכרון שלו עולה עם הזמן בקצב מהיר מקצב הירידה שלה, מאחר וכמות המשאבים הפתוחים שהוא מחזיק רק גדלה.

2. איך נדע שיש בעיית זכרון ?

הסימפטום הנפוץ לבעיית זכרון הוא ירידה בביצועי העמוד שמתרחשת אחרי הרבה זמן שהעמוד פתוח - כלומר יש נקודה שלאחריה העמוד מתחיל לעבוד לאט. דרך אחת לזהות מה צריכת הזכרון הנוכחית של הדפדפן היא פשוט לפתוח את ה Task Manager של כרום, ולראות כמה זכרון כל טאב צורך.

chrome task manager

אם טאב מסוים צורך כמות זכרון שרק עולה עם הזמן, ובשלב מסוים העמוד מתחיל לעבוד לאט, אנו יכולים להתחיל לחשוד שקיימת בעיית זכרון.

3. כיצד JavaScript מנהלת את הזכרון

שפת JavaScript מממשת מנגנון ניהול זכרון שנקרא Garbage Collection. זהו מנגנון אקטיבי המחפש באופן פעיל מדי פעם קטעי זכרון שאינם נמצאים בשימוש ומשחרר אותם לטובת שאר התוכנית. הייתרון במנגנון זה הוא שהוא דורש הכי פחות מודעות מצד המתכנת. במילים אחרות, JavaScript יודעת למצוא לבד אובייקטים בזכרון שאין לתוכנית שום דרך ״להגיע״ אליהם ולמחוק אותם. 

אז אם JavaScript מנהלת בעצמה את הזכרון, מדוע אנו עדיין נתקלים בבעיות זכרון? הרי היינו מצפים שמנגנון ה Garbage Collection יפתור עבורנו בעיות אלו. בעיית זכרון ב JavaScript מתרחשת כאשר צריכת הזכרון של היישום שלנו עולה ועולה כתוצאה מכך שהיישום צריך לטפל (או לזכור) יותר ויותר אובייקטים. דוגמא פשוטה לתקלה מסוג זה היא משחק שבכל פעם שמסתיים המשחק אנו מוסיפים את התוצאה החדשה לרשימת התוצאות, ולעולם איננו מוחקים תוצאות ישנות.
מצב זה איננו מתאר דליפת זכרון, מאחר ועדיין ניתן לצפות בכל התוצאות שאי פעם הושגו במשחק. הבעייה היא שאנו שומרים את כל המידע הלא רלוונטי הזה (תוצאות עבר) בזכרון המחשב, וכשיצטברו מספיק תוצאות עלול להיווצר לנו מחסור בזכרון לדברים חשובים אחרים.

ניתן להמחיש את האופן בו שפת JavaScript מנהלת את הזכרון באמצעות גרף. נתבונן בקוד הבא:

var x = 5;
var y = [];
(function() {
    var o2 = {
        a: 1,
        b: ['foo']
    };
    var o1 = {
        a: 10,
        b: 'bar',
        c: o2
    };
    y.push(o1, o2);
}());

לאחר ביצוע הקוד אנו נשארים עם שני משתנים גלובליים: x ו y. 
x הוא משתנה פשוט, אך y כולל התיחסות למשתנים אחרים. הגרף הבא מציג את היחסים בין המשתנים לאובייקטים עבור הקוד:

objects graph for code

 

כל שם של משתנה הוא מסלול על הגרף כדי להגיע לאובייקט. חלק מהמסלולים ארוכים יותר מאחרים, וקיימים אובייקטים שניתן להגיע אליהם ביותר ממסלול אחד, כגון האובייקט שבמסלול y[0].c.

כשמנוע הג'אווהסקריפט חושב שהגיע הזמן, הוא עובר על כל הגרף החל משורש העץ (הצומת המסומן בכחול) ומסמן לאיזה צמתים הוא הצליח להגיע. בסיום התהליך נשארים עם שני סוגי צמתים: אלו שהצלחנו למצוא מסלול אליהם, ואלו שלא ניתן להגיע אליהם. האחרונים ינוקו מהזכרון.

נמחיש את התהליך באמצעות הדוגמא. אם נוסיף את השורה הבאה לסוף הקוד שהצגנו קודם:

y.shift();

המערך y יחזיק כעת רק תא אחד, וגרף האובייקטים יראה כך:

objects graph with unreachable objects

כל האובייקטים שנמצאים בצד ימין למעלה אינם נגישים יותר, ולכן יפונו בפעם הבאה שיופעל אוסף הזבל.

 

4. בעיות זכרון הנובעות משימוש במערך

מתוך הדברים שנאמרו, ניתן לזהות מספר דפוסים נפוצים של בעיות זכרון באפליקציות צד-לקוח. דפוסים אלו כולם נגזרים מהעובדה שכדי שתיווצר בעיית זכרון על האפליקציה להגדיל את צריכת המשאבים שלה עם הזמן. זה אומר שכמות האובייקטים שהמשתנים שלנו יכולים להוביל אליהם עולה.

הדפוס הראשון שאציג הוא בעיות זכרון הנובעות משימוש במערכים. מאחר ואין מגבלת אורך על מערכים ב JavaScript, אפשר בקלות לשכוח שזכרון המחשב מוגבל ולכתוב קוד שרק מוסיף אלמנטים למערך אך לעולם לא מוחק אלמנטים ישנים. נתבונן בדוגמא הבאה:

var all_score = [];

function add_score(score) {
    all_score.push(score);
}

function top_10() {
    return all_score.sort(function(a, b) {
        return b - a
    }).slice(0, 10);
}

הקוד לקוח ממשחק שבכל סיום סיבוב שומר את התוצאה הנוכחית, וגם מאפשר להציג את עשרת התוצאות הטובות ביותר. ככל שנשחק יותר פעמים המערך יתפוס יותר ויותר זכרון, עד שיתחיל להכביד על פעולת המחשב.

5. בעיות זכרון הנובעות משימוש באובייקטים

באותו האופן שמילאנו את המערך בעוד ועוד אלמנטים, אנו יכולים למלא גם אובייקט בעוד ועוד ערכים ולקבל את אותה הבעייה. כותב קטע הקוד הבא רצה לשפר את ביצועי המערכת ולכן הוסיף מנגנון Cache: לאחר שליפת המידע הרלוונטי עבור משתמש מסוים, הקוד שומר את המידע באובייקט. אם התקבלה בקשה לשלוף מידע שכבר יש לנו, אין צורך לפנות שוב לשרת ופשוט נחזיר את המידע מתוך ה Cache. כך זה נראה:

var users = {};

function getUserDetails(user_id, cb) {
    if (!users[user_id]) {
        $.get('/users/' + user_id, function(info) {
            users[user_id] = info;
            cb(info);
        });
    } else {
        setTimeout(cb, 0, users[user_id]);
    }
}

הבעייה בקוד היא שבהיעדר מנגנון מתאים שגם ינקה את המטמון, לאורך זמן הקוד הזה יצרוך יותר ויותר זכרון, עד שיגרום לבעיות זכרון בתוכנית.

6. בעיות זכרון הנובעות משימוש בפונקציות

פונקציות הן טיפוס הנתונים הבעייתי ביותר ב JavaScript מבחינת בעיות זכרון. בדומה למערך ואובייקט, גם פונקצייה שומרת על אובייקטים אחרים “בחיים” כל זמן שהיא זמינה, אבל בעוד שבמערך ואובייקט די ברור לנו איזה אובייקטים מוחזקים - הפונקציה מחזיקה את המידע הזה כמעט בחשאי.
האובייקטים שפונקציה מחזיקה בחיים הם כל אלו הנגישים מתוך הפונקציה, פרט למשתנים פנימיים (המוגדרים באמצעות var). הסיבה היא שבפעם הבאה שהפונקציה תקרא המשתנים הפנימיים לא ישמרו על ערכם, אבל שאר המשתנים שלפונקציה יש גישה אליהם עדיין ישארו זמינים. 
אפשר לראות רשימה של משתנים המוחזקים בחיים מתוך פונקציה באמצעות כלי הפיתוח של כרום. אנו נגדיר נקודת עצירה בתוך פונקציה, ובצד ימין תחת הכותרת Closures נראה את רשימת כל המשתנים המחוברים לפונקציה בגרף האובייקטים. 

קחו לדוגמא את הקוד הבא המשתמש ב jQuery לייצור גלריית תמונות:

See the Pen NPYoja by ynonp (@ynonp) on CodePen.

בכל פעם שמשתמש לוחץ על תמונה נפתח Overlay המציג את התמונה. בנוסף, מתווסף Event Handler לחלון על אירוע Resize:

chrome debugger showing closures for a function

ניתן לראות כי פונקציית הטיפול באירוע ״מחזיקה״ את התמונה בחיים. כך, גם אם נמחק מה DOM את האלמנט, המקום שהוא תופס בזכרון לא ינוקה עד אשר נמחק גם את הפונקציה (באמצעות הסרת ה Event Handler באופן יזום עם הפונקציה off).

7. סיכום: עצות לצמצום בעיות זכרון באפליקציות JavaScript.

לפני שאנו רצים לצמצם את צריכת הזכרון של האפליקציה, זכרון שאופטימיזציה טרם זמנה כמעט אף פעם איננה רעיון טוב. התהליך עובד בערך כך: תחילה עליכם לוודא שקיימת בעיית זכרון, למשל על ידי שימוש ב Task Manager של כרום אחרי שהאפליקציה שלכם רצה במשך הרבה זמן.

יחד עם זאת, כדאי לנקוט זהירות גם בעת כתיבת הקוד ולאמץ את הגישות הבאות בעת כתיבת מודולים חדשים:

1. צמצמו את השימוש במשתנים גלובאליים. הם נגישים מכל מקום, נשמרים לנצח וכל אובייקט שנגיש מהם גם הוא יישמר לנצח.

2. בעת הוספת אלמנטים למערכים או אובייקטים, שאלו את עצמכם מתי אלמנטים אלו הולכים להתנקות. 

3. בעת הוספת Event Handlers שאלו את עצמכם את אותה השאלה: מתי פונקציה זו הולכת להתנקות. זכרו ש Event Handler על אלמנט נמחק כאשר מסירים את האלמנט מה DOM.

4. כשאתם עובדים עם Frameworks דאגו לוודא שאתם מבינים את ההשלכות של אותו ה Framework על הזכרון, והאם נדרשת פעולה יזומה שלכם כדי לנקות אחרי ה Framework. כל Framework מתנהג אחרת כאן אבל כדאי להיות חשדנים בכל הנוגע ל Data Binding ולתבנית Observer. אם הפריימוורק כולל קוד טיפול באירועים והאזנה אליהם, אז בכל פעם שמתחילים להאזין לאירוע כדאי לשאול איפה מפסיקים להאזין לו.

בעיות זכרון ב JavaScript אינן גזירת גורל וניתן למנוע אותן באמצעות תכנות נכון הלוקח בחשבון את מגבלות המערכת.