דליפות זיכרון של חלון מנותק

איתור ותיקון דליפות זיכרון מורכבות שנגרמות על ידי חלונות מנותקים.

Bartek Nowierski
Bartek Nowierski

מהי דליפת זיכרון ב-JavaScript?

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

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

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

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

מה זה חלון נפרד?

בדוגמה הבאה, אפליקציה של צופה במצגת כוללת לחצנים לפתיחה ולסגירה של חלון קופץ עם הערות של המצגת. נניח שמשתמש לוחץ על Show Notes, ואז סוגר את החלון הקופץ ישירות במקום ללחוץ על הלחצן הסתרה של הערות – המשתנה notesWindow עדיין מכיל הפניה לחלון הקופץ שאפשר לגשת אליו, למרות שהחלון הקופץ כבר לא נמצא בשימוש.

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

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

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

שימוש בכלי הפיתוח ל-Chrome כדי להדגים איך אפשר לשמור מסמך אחרי שחלון נסגר.

אותה בעיה יכולה להופיע גם כשמשתמשים ברכיבי <iframe>. מסגרות iframe מתנהגות כמו חלונות מקוננים שמכילים מסמכים, והמאפיין contentWindow שלהם מספק גישה לאובייקט Window הכלול, בדומה לערך שהוחזר על ידי window.open(). קוד JavaScript יכול לשמור הפניות ל-contentWindow או ל-contentDocument של iframe, גם אם ה-iframe הוסר מה-DOM או אם הוא משתנה בכתובת ה-URL שלו. מצב זה מונע את איסוף האשפה במסמך כי עדיין יש גישה למאפיינים שלו.

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

במקרים שבהם נשמרת מ-JavaScript הפניה ל-document בתוך חלון או iframe, המסמך יישמר בזיכרון גם אם החלון או ה-iframe שמכיל את הקוד עוברים לכתובת URL חדשה. הדבר עלול להיות בעייתי במיוחד כש-JavaScript שמכיל את ההפניה הזו לא מזהה שהחלון/המסגרת עברו לכתובת URL חדשה, כי הם לא יודעים מתי הוא הופך לקובץ העזר האחרון ששומר את המסמך בזיכרון.

איך חלונות מנותקים גורמים לדליפות זיכרון

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

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

נניח שאנחנו סוגרים את חלון הדפדפן שנוצר על ידי showNotes() למעלה. אף גורם המטפל באירועים לא מאזין כדי לזהות שהחלון נסגר, ולכן הקוד שלנו לא מודיע על כך שהוא צריך למחוק הפניות למסמך. הפונקציה nextSlide() עדיין 'פעילה' כי היא מקושרת כ-handler של קליקים בדף הראשי שלנו, והעובדה ש-nextSlide מכילה הפניה אל notesWindow המשמעות היא שעדיין מתבצעת הפניה לחלון ואי אפשר לאסוף בו אשפה.

איור שמראה איך סגירת חלון מונעת איסוף אשפה אחרי סגירתו.

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

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

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

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

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

זיהוי דליפות זיכרון שנגרמו על ידי חלונות מנותקים

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

מקום מעולה להתחיל בניפוי באגים בבעיות זיכרון הוא לצלם קובץ snapshot של ערימה (heap snapshot). הפעולה הזו מספקת תצוגה מנקודת זמן של הזיכרון שמשמש כרגע אפליקציה – כל האובייקטים שנוצרו אבל עדיין לא נאספו אשפה. קובצי snapshot של ערימה (heap snapshot) מכילים מידע שימושי על אובייקטים, כולל הגודל שלהם ורשימה של המשתנים והסגירות ש מפנים אליהם.

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

כדי להקליט תמונת מצב של הזיכרון (heap snapshot), עוברים לכרטיסייה Memory בכלי הפיתוח של Chrome ובוחרים באפשרות heap Snapshot ברשימת סוגי הפרופילים הזמינים. בסיום ההקלטה, בתצוגה Summary מוצגים האובייקטים הנוכחיים בזיכרון, מקובצים לפי בנאי.

הדגמה של צילום תמונת מצב של ערימה בכלי הפיתוח ל-Chrome.

ניתוח של מצבי ערימה של ערימה יכול להיות משימה מרתיעה, ויכול להיות די קשה למצוא את המידע הנכון כחלק מניפוי הבאגים. כדי לעזור בכך, מהנדסי Chromium yossik@ ו-peledni@ פיתחו כלי עצמאי Heap Cleaner שיכול לעזור להדגיש צומת ספציפי, כמו חלון מנותק. הרצה של הכלי לניקוי ערימה (heap Cleaner) על מעקב מסירה מידע לא נחוץ אחר מתרשים השימור, וכתוצאה מכך נעשה ניקוי של נתוני המעקב בקלות רבה יותר לקריאה.

מדידת זיכרון באופן פרוגרמטי

קובצי snapshot של ערימה (heap snapshot) מספקים רמת פירוט גבוהה והם מצוינים כדי לזהות איפה מתרחשות דליפות, אבל צילום תמונת המצב של הערימה הוא תהליך ידני. דרך נוספת לבדוק אם יש דליפות זיכרון היא להשיג את גודל הערימה של JavaScript שבשימוש כרגע מה-API של performance.memory:

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

ה-API של performance.memory מספק מידע רק על גודל הערימה של JavaScript, כלומר הוא לא כולל זיכרון שמשמש את המסמך והמשאבים של החלון הקופץ. כדי לקבל את התמונה המלאה, נצטרך להשתמש ב-API performance.measureUserAgentSpecificMemory() החדש שנמצא כרגע בתקופת ניסיון ב-Chrome.

פתרונות למניעת דליפות של חלונות מנותקים

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

דוגמה: סגירת חלון קופץ

בדוגמה הבאה, שני לחצנים משמשים לפתיחה ולסגירה של חלון קופץ. כדי שהלחצן Close Popup יפעל, מתבצעת הפניה לחלון הקופץ שנפתח במשתנה:

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

במבט ראשון, נראה שהקוד שלמעלה מונע מלכודות נפוצות: לא נשמרות הפניות למסמך של החלון הקופץ, ולא רשומים רכיבי handler של אירועים בחלון הקופץ. עם זאת, אחרי שלוחצים על הלחצן Open Popup, המשתנה popup מפנה לחלון הפתוח, ואפשר לגשת למשתנה הזה דרך ה-handler של לחיצה על הלחצן Close Popup. אם לא תבוצע הקצאה מחדש של popup או ש-handler של הקליקים יוסר, ההפניה בתוך ה-handler של ה-handler הזה אל popup המשמעות היא שאי אפשר לאסוף אשפה.

פתרון: ביטול ההגדרה של הפניות

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

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

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

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

פתרון: מעקב והשלכה

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

האירוע pagehide יכול לשמש לזיהוי חלונות סגורים וניווט אל מחוץ למסמך הנוכחי. עם זאת, יש אזהרה חשובה אחת: כל החלונות ומסגרות ה-iframe החדשים שנוצרים מכילים מסמך ריק, ולאחר מכן עוברים באופן אסינכרוני לכתובת ה-URL הנתונה אם היא סופקה. כתוצאה מכך, אירוע pagehide ראשוני מופעל זמן קצר אחרי יצירת החלון או המסגרת, ממש לפני טעינת מסמך היעד. מאחר שהקוד לניקוי קובצי העזר צריך לפעול כשמסמך ה-target לא נטען, אנחנו צריכים להתעלם מהאירוע הראשון של pagehide. יש כמה שיטות לעשות את זה, והשיטה הפשוטה ביותר היא להתעלם מאירועי הסתרת דף שמקורם בכתובת ה-URL about:blank של המסמך הראשוני. כך זה ייראה בדוגמה הקופצת:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

חשוב לציין שהשיטה הזו פועלת רק בחלונות ובמסגרות שהמקור שלהם זהה לזה של דף ההורה שבו הקוד שלנו פועל. כשטוענים תוכן ממקור אחר, גם האירוע location.host וגם האירוע pagehide לא זמינים מטעמי אבטחה. באופן כללי, רצוי להימנע משמירת הפניות למקורות אחרים, אבל במקרים הנדירים שבהם זה הכרחי אפשר לעקוב אחרי המאפיינים window.closed או frame.isConnected. כשהמאפיינים האלה משתנים כדי לציין חלון סגור או iframe שהוסר, כדאי לבטל את הסימון של ההפניות אליו.

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

פתרון: השתמש ב-WeakRef

לאחרונה נוספה ב-JavaScript תמיכה בדרך חדשה להפנות לאובייקטים שמאפשרת איסוף אשפה, שנקראת WeakRef. WeakRef שנוצר לאובייקט אינו הפניה ישירה, אלא אובייקט נפרד שמספק שיטת .deref() מיוחדת שמחזירה הפניה לאובייקט כל עוד הוא לא נאסף באשפה. באמצעות WeakRef אפשר לגשת לערך הנוכחי של חלון או מסמך ועדיין לאפשר איסוף אשפה. במקום לשמור הפניה לחלון שצריך להגדיר באופן ידני בתגובה לאירועים כמו pagehide או מאפיינים כמו window.closed, מתקבלת גישה לחלון לפי הצורך. כשסוגרים את החלון, יכול להיות שנאסף אשפה וכתוצאה מכך השיטה .deref() מתחילה להחזיר undefined.

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

כשמשתמשים ב-WeakRef על מנת לגשת לחלונות או למסמכים, מומלץ לקחת בחשבון פרט מעניין אחד: בדרך כלל קובץ העזר נשאר זמין למשך זמן קצר אחרי סגירת החלון או הסרת ה-iframe. הסיבה לכך היא ש-WeakRef ממשיך להחזיר ערך עד שהאובייקט המשויך שלו נאסף. הפעולה הזו מתבצעת באופן אסינכרוני ב-JavaScript, ובדרך כלל גם בזמן חוסר פעילות. למרבה המזל, כשבודקים אם יש חלונות מנותקים בחלונית Memory של Chrome DevTools, צילום תמונת המצב של הזיכרון (heap snapshot) מפעיל את איסוף האשפה ומשחרר את החלון בעל ההפניה חלשה. אפשר גם לבדוק שאובייקט שיש הפניה אליו דרך WeakRef נמחק מ-JavaScript, על ידי זיהוי המועד שבו deref() מחזיר undefined או באמצעות ה-FinalizationRegistry API החדש:

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

הפתרון: תקשורת מעל postMessage

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

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

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

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

פתרון: הימנעות מקובצי עזר באמצעות noopener

במקרים שבהם נפתח חלון קופץ שלא צריך לתקשר עם הדף או לשלוט בו, יכול להיות שתוכלו להימנע מהשגת הפניה לחלון. זה שימושי במיוחד כשיוצרים חלונות או מסגרות iframe שטוענים תוכן מאתר אחר. במקרים כאלה, window.open() מקבל אפשרות "noopener" שפועלת בדיוק כמו המאפיין rel="noopener" עבור קישורי HTML:

window.open('https://example.com/share', null, 'noopener');

האפשרות "noopener" גורמת ל-window.open() להחזיר null, ולכן לא ניתן לאחסן בטעות הפניה לחלון הקופץ. היא גם מונעת מהחלון הקופץ לקבל הפניה לחלון ההורה שלו, כי המאפיין window.opener יהיה null.

משוב

אני מקווה שחלק מההצעות במאמר הזה יעזרו באיתור ותיקון של דליפות זיכרון. אם יש לכם שיטה אחרת לניפוי באגים בחלונות נפרדים או שהמאמר הזה עזר לחשוף דליפות באפליקציה, אשמח לדעת! אפשר למצוא אותי ב-Twitter @_developit.