איתור ותיקון של דליפות זיכרון מסובכות שנגרמות על ידי חלונות מופרדים.
מהי דליפת זיכרון ב-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 (הצגת ההערות) ואז סוגר את חלון הקופץ ישירות, במקום ללחוץ על הלחצן Hide 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 או iframe, לא ניתן יהיה למחזר את הזיכרון הזה עד שלא יהיו יותר הפניות של JavaScript למאפיינים של החלון.
אותה בעיה יכולה להתרחש גם כשמשתמשים ברכיבי <iframe>
. iframes מתנהגים כמו חלונות מוערמים שמכילים מסמכים, והמאפיין contentWindow
שלהם מספק גישה לאובייקט Window
שמכיל אותם, בדומה לערך שמוחזר על ידי window.open()
. קוד JavaScript יכול לשמור הפניה ל-contentWindow
או ל-contentDocument
של iframe גם אם ה-iframe הוסר מה-DOM או שכתובת ה-URL שלו השתנתה. הפניה כזו מונעת את איסוף האשפה של המסמך כי עדיין אפשר לגשת לנכסים שלו.
במקרים שבהם הפניה ל-document
בתוך חלון או iframe נשמרת מ-JavaScript, המסמך הזה יישמר בזיכרון גם אם החלון או ה-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()
עדיין 'פעילה' כי היא מקושרת כטיפול בקליק בדף הראשי שלנו, והעובדה ש-nextSlide
מכיל הפניה ל-notesWindow
מצביעה על כך שעדיין יש הפניה לחלון ולא ניתן לבצע איסוף אשפה.
יש כמה תרחישים נוספים שבהם הפניות נשמרות בטעות, וכתוצאה מכך חלונות מופרדים לא עומדים בדרישות לאיסוף אשפה:
אפשר לרשום פונקציות טיפול באירועים במסמך הראשוני של iframe לפני שהמסגרת מנווטת לכתובת ה-URL המיועדת שלה. כתוצאה מכך, יהיו הפניות לא מכוונות למסמך וה-iframe יישאר אחרי שההפניות האחרות יימחקו.
מסמך שמכיל הרבה נתונים שנטען בחלון או ב-iframe יכול להישאר בזיכרון בטעות הרבה אחרי שמנווטים לכתובת URL חדשה. הסיבה העיקרית לכך היא שבדף ההורה נשארות הפניות למסמך כדי לאפשר הסרה של ה-listener.
כשמעבירים אובייקט 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 של אשכול הוא מקום מעולה להתחיל בו בניפוי באגים של בעיות שקשורות לזיכרון. כך אפשר לקבל תצוגה של זיכרון האפליקציה בזמן נתון – כל האובייקטים שנוצרו אבל עדיין לא בוצעה להם איסוף גרוטאות. קובצי snapshot של ערימה מכילים מידע שימושי על אובייקטים, כולל הגודל שלהם ורשימה של המשתנים והסגירות (closures) שמפנים אליהם.
כדי להקליט קובץ snapshot של אשכול, עוברים לכרטיסייה זיכרון בכלי הפיתוח ל-Chrome ובוחרים באפשרות קובץ snapshot של אשכול ברשימת סוגי הפרופיל הזמינים. בסיום ההקלטה, בתצוגה Summary יוצגו האובייקטים הנוכחיים בזיכרון, מקובצים לפי מגדיר (constructor).
ניתוח של דמפים של אשכול יכול להיות משימה מרתיעה, ולפעמים קשה למצוא את המידע הנכון כחלק מתהליך ניפוי הבאגים. כדי לעזור בכך, מהנדסי Chromium yossik@ ו-peledni@ פיתחו כלי עצמאי בשם Heap Cleaner שיכול לעזור להדגיש צומת ספציפי, כמו חלון מנותק. הפעלת Heap Cleaner על נתיב מעקב מסירה מידע מיותר אחר מתרשים השימור, וכך הנתיב נקי יותר וקל יותר לקריאה.
מדידת זיכרון באופן פרוגרמטי
תמונות מצב של ערימות (heap snapshot) מספקות רמה גבוהה של פירוט ומשמשות היטב לזיהוי מקורות של דליפות, אבל יצירת snapshot של ערימות היא תהליך ידני. דרך נוספת לבדוק אם יש דליפות זיכרון היא לקבל את גודל אשכול ה-JavaScript הנוכחי מ-performance.memory
API:
ה-API של performance.memory
מספק מידע רק על גודל הערימה של JavaScript, כלומר הוא לא כולל את הזיכרון שמשמש את המסמך ואת המשאבים של חלון הקופץ. כדי לקבל תמונה מלאה, נצטרך להשתמש ב-performance.measureUserAgentSpecificMemory()
API החדש שנמצא כרגע בבדיקה ב-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>
במבט ראשון, נראה שהקוד שלמעלה נמנע ממלכודות נפוצות: לא נשמרות הפניות למסמך של החלון הקופץ, ולא רשומים מנהלי אירועים בחלון הקופץ. עם זאת, אחרי שלוחצים על הלחצן Open Popup, המשתנה popup
מפנה עכשיו לחלון שנפתח, ואפשר לגשת למשתנה הזה מההיקף של פונקציית הטיפול באירוע הלחיצה על הלחצן Close Popup. אלא אם popup
יוקצה מחדש או שהטיפול בקליק יוסר, ההפניה הסגורה של ה-handler ל-popup
תגרום לכך שלא ניתן יהיה לאסוף את האשפה.
פתרון: ביטול ההגדרה של הפניות
משתנים שמפנים לחלון אחר או למסמך שלו גורמים לשמירתם בזיכרון. מאחר שאובייקטים ב-JavaScript הם תמיד הפניות, הקצאת ערך חדש למשתנים מסירה את ההפניה שלהם לאובייקט המקורי. כדי "לבטל את ההגדרה" של הפניות לאובייקט, אפשר להקצות מחדש את המשתנים האלה לערך null
.
אם מחילים את זה על דוגמת החלון הקופץ הקודמת, אפשר לשנות את הטיפול בלחצן הסגירה כך שיבטל את ההפניה שלו לחלון הקופץ:
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
הראשון. יש כמה שיטות לעשות זאת, והפשוטה שבהן היא להתעלם מאירועי 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 ובדרך כלל בזמן חוסר פעילות
זְמַן. למרבה המזל, כאשר בודקים חלונות מנותקים בחלונית Chrome DevTools Memory, לוקחים
תמונת מצב של ערמה למעשה מפעילה איסוף אשפה ופוסקת את החלון בעל ההתייחסות החלשה. אפשר גם לבדוק אם אובייקט שמצוין באמצעות WeakRef
הושמד ב-JavaScript, על ידי זיהוי של מקרים שבהם deref()
מחזיר את הערך undefined
או באמצעות ה-API החדש של FinalizationRegistry
:
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
במקרים שבהם נפתח חלון קופץ שהדף שלכם לא צריך לתקשר איתו או לשלוט בו, יכול להיות שתוכלו להימנע מלקבל הפניה לחלון. האפשרות הזו שימושית במיוחד כשיוצרים חלונות או iframes שיטעמו תוכן מאתר אחר. במקרים כאלה, השדה window.open()
מקבל אפשרות "noopener"
שפועלת בדיוק כמו המאפיין rel="noopener"
בקישורים מסוג HTML:
window.open('https://example.com/share', null, 'noopener');
האפשרות "noopener"
גורמת ל-window.open()
להחזיר את הערך null
, כך שלא ניתן לאחסן בטעות הפניה לחלון הקופץ. הוא גם מונע מהחלון הקופץ לקבל הפניה לחלון ההורה שלו, כי הערך של המאפיין window.opener
יהיה null
.
משוב
אני מקווה שחלק מההצעות במאמר הזה יעזרו לכם למצוא דליפות זיכרון ולתקן אותן. אם יש לך שיטה אחרת לניפוי באגים בחלונות מנותקים, או אם המאמר הזה עזר לך לגלות דליפות באפליקציה, נשמח לשמוע ממך. אפשר למצוא אותי ב-Twitter @_developit.