דו"ח באג הגיע וערער כל אינסטינקט של ניפוי שגיאות (debugging). משתמשים בטלפונים מבוססי Android בסיסיים דיווחו שהאפליקציה פשוט נעלמה. לא בזמן ההפעלה. לא בזמן לחיצה או החלקה ספציפית. בערך עשרים דקות בתוך סשן, המסך קפא והתהליך מת. הלוגים היו נקיים לחלוטין. צוות ה-QA לא הצליח לשחזר זאת על החומרה המתקדמת שלהם. לא היו שלבים לעקוב אחריהם. לאחר שלוש שעות של פרופיל זיכרון (memory profiling), התמונה התבהרה סוף סוף. מאזין אירועים (event listener) בודד ישב בתוך React hook. המאזין הזה ביצע closure על מערך נתונים גדול. הקומפוננטה עשתה unmount. המאזין נשאר. מערך הנתונים נשאר בזיכרון. במכשיר עם 2GB של RAM, הצטברות זו מיצה את ה-heap ומערכת ההפעלה הרגה את האפליקציה. זה לא היה שגיאת תחביר (syntax error) או פגם לוגי. זה היה באג של scope, והוא היה קטלני.

איך Closure הופך לדלי (Leak)

רוב המדריכים מלמדים scope כחידה אקדמית על המקום שבו משתנה נראה. בייצור (production), scope הוא חוזה לגבי אורך חיי הזיכרון. כשפונקציית JavaScript מבצעת closure על משתנה, המנוע שומר על המשתנה הזה חי כל עוד ניתן להגיע ל-closure עצמו. בקומפוננטת React, זה אומר שהנתונים שלכם שורדים זמן רב אחרי שהמשתמש עובר לדף אחר וצומת ה-UI נעלם.

חשבו על hook שרושם מאזין על אובייקט ה-window. הקומפוננטה מתרנדרת, מצמידה את המאזין, ומאוחר יותר עושה unmount. אם שלב הניקוי (cleanup phase) חסר או בוצע בצורה שגויה, המאזין נשאר. כל mount חדש מוסיף עותק רפאים נוסף של הנתונים הכלואים ל-RAM. בתחנת עבודה של מפתח עם זיכרון שופע, אולי לעולם לא תשימו לב לנפיחות. בטלפון תקציבי שמריץ Android Go, עשרים דקות של שימוש רגיל מספיקות כדי לרוקן את ה-heap הזמין. מערכת ההפעלה מתערבת ומסיימת את התהליך. אין שגיאה (exception) לכתוב ללוגים. המערכת פשוט "מנתקת את התקע".

זו הסיבה ש-scope הוא ניהול זיכרון. הסביבה הלקסיקלית (lexical environment) אינה גבול פילוסופי. היא גרף החזקה (retention graph). כל משתנה שאתם משאירים בתוך closure שלא נאסף הוא לבנה בחומה שבסופו של דבר תסגור על האפליקציה שלכם בפינה.

שלוש דרכים שבהן scope הורס אפליקציות בייצור

בעיות scope לא כולן נראות אותו דבר. חלקן מרוקנות זיכרון לאט. אחרות מתפוצצות באופן מיידי. להלן התבניות שמפילות אפליקציות באופן עקבי.

זיהום של Global Scope

ארכיטקטורות micro-frontend מאפשרות לצוותים להוציא גרסאות באופן עצמאי, אך כולן חולקות את אותו אובייקט window. כשאפליקציה אחת מגדירה משתנה גלובלי כמו window.config או מדביקה (patches) כלי עזר משותף על ה-window, היא לא חיה בבידוד. אפליקציה של צוות אחר עשויה להסתמך על מבנה שונה עבור אותו משתנה גלובלי, או לדרוס אותו במהלך ה-bootstrap שלה. התוצאה היא התנגשות פיצ'רים שגדלה ככל שהארגון גדל. למפתח במאגר (repository) אחד אין מושג שהקיצור שלו הוא שינוי שובר (breaking change) עבור צוות אחר. ככל ששטח הפנים גדל, הגלובלים הללו הופכים למוקשים הקבורים באדמה משותפת.

דלי זיכרון של Closures

אפליקציות בעמוד יחיד (SPA) בנויות לעבוד שעות ארוכות. העמידות הזו היא בדיוק הסיבה לכך ש-closures שדולפים הופכים לרעילים. התבנית נפוצה באופן מטעה: useEffect רושם callback עם event bus גלובלי, WebSocket handler, או ה-DOM עצמו. אם מערך התלויות (dependency array) אינו יציב או חסר, הניקוי לעולם לא יתאים למנוי (subscription) המקורי. ה-closure לוכד כל מה שנמצא בסביבה הלקסיקלית שלו, מה שיכול לכלול מערכים ענקיים שעברו parsing, JSON blobs שנמשכו, או הפניות לעצי DOM. כל ניווט מוסיף עוד משקל. המשתמש לא יודע למה לשונית הדפדפן שלו צורכת 800MB. הוא רק יודע שהאפליקציה מרגישה איטית ובסופו של דבר קורסת.

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

שגיאות TDZ במודולים דינמיים

ה-Temporal Dead Zone אינו מקרה קצה תיאורטי. כשאתם ניגשים ל-let או const לפני שההצהרה עליהם מבוצעת, המנוע זורק ReferenceError. ב-monorepos גדולים עם תלויות מעגליות (circular dependencies) וייבוא דינמי, סדר הביצוע המדויק הוא לעיתים קרובות משתנה (implicit). מודול A מייבא את מודול B, אשר מייבא באופן דינמי chunk שתלוי בחזרה במודול A. אם ענף אחד נוגע במשתנה שטרם סיים את אתחולו, האפליקציה קורסת במהלך הטעינה. הכשלים הללו הם מתסכלים במיוחד כי הם תלויים בתזמון. שינוי קטן בנקודות הפיצול (split points) של ה-bundler, עיכוב רשת בטעינת הקוד, או שינוי ב-caching של ה-chunks יכולים לשנות את הסדר בדיוק מספיק כדי להפעיל את ה-TDZ. הקריסה אינה צפויה, וה-stack trace בדרך כלל מצביע על שורת קוד תמימה לחלוטין.

טקטיקות הגנה

אינכם יכולים להסתמך על stack traces כדי להציל אתכם מבאגים של scope. אתם זקוקים למניעה וזיהוי.

התחילו בניתוח סטטי (static analysis). הגדירו את ESLint כך שיאכוף גבולות קשיחים. כללים כמו no-implicit-globals ו-no-shadow תופסים את העבירות הברורות. Shadowing הוא ערמומי במיוחד כי הוא מטעה אתכם לחשוב שאתם משנים משתנה מקומי, בעוד שאתם למעשה בונים closure על גבי משתנה חיצוני, או יוצרים כפילות מקרית. כללים אלו מחייבים כוונה מפורשת ומבטלים התנגשויות שקטות.

בצעו profiling לזיכרון שלכם באותה משמעת שבה אתם משתמשים בבדיקות יחידה (unit tests). פתחו את Chrome DevTools, צרו heap snapshot בנתיב ההתחלה שלכם, נווטו באפליקציה במשך חמש דקות, וצרו snapshot נוסף. השוו בין השניים. סננו לפי "Closure" וחפשו ערכים שגדלים ללא הגבלה. חפשו צמתי DOM מנותקים (detached DOM nodes) שעדיין מחזיקים מאזיני אירועים (event listeners). אם ה-snapshot השני מראה אלפי רשומות Closure חדשות בזמן שמספר המשתמשים נשאר קבוע, נתקעתם עם פונקציות "כלואות" שמחזיקות נתונים "כלואים". זהו הדליפה שלכם.

מבחינה ארכיטקטונית, הפסיקו לשלוף נתונים מאובייקט ה-window הגלובלי לצורך הגדרות (configuration). העבירו הגדרות כ-props או דרך context מוגדר טיפוסים (typed context). הזרקת תלויות (Dependency injection) אינה מילת באז (buzzword) של ארגונים כאן; זוהי הפרקטיקה של מתן כל מה שפונקציה צריכה דרך ארגומנטים, במקום לאפשר לה "להריח" את ה-scope הגלובלי. התוצאה היא קוד שניתן לבדוק ללא browser shims, ומודולים שאינם מתנגשים כאשר מספר אפליקציות מותקנות (mount) בתוך אותו shell.

לבסוף, כבדו את שלב הניקוי (cleanup phase) ללא רחמים. לכל addEventListener חייב להיות removeEventListener תואם בתוך ה-effect cleanup. עבור עבודה אסינכרונית, השתמשו ב-AbortController והעבירו את ה-signal שלו ל-fetch כדי שבקשות בתהליך (in-flight requests) יבוטלו כשהקומפוננטה מתה. ההרגלים הללו שולטים ישירות בחיים של ה-scope. הם אינם boilerplate. הם ניהול זיכרון.

מה זה אומר עבור הצוות שלכם

Scope אינו טריק של מופע שנועד לבחון מועמדים במהלך ראיונות. בייצור (production), scope הוא ניהול זיכרון. כל משתנה שאתם מצהירים עליו הוא בן ערובה פוטנציאלי. כל closure הוא הבטחה שהמנוע יקיים. כשאתם שוכחים לשחרר מאזין (listener), אתם לא רק משאירים אור דולק. אתם קושרים משקולת לאפליקציה שלכם ומשליכים אותה לאוקיינוס. על חומרה חזקה, האפליקציה שוחה בכל זאת. עבור משתמשים במכשירים חלשים, היא שוקעת. התחילו להתייחס ל-scope כאל המשאב המוגבל שהוא באמת. המשתמשים שלכם, וסשנים של ניפוי שגיאות (debugging) של שלוש שעות, יודו לכם.