רוב האנשים מתחילים ללמוד JavaScript על ידי בנייה. מחברים כפתור, שולפים נתונים, וצופים ב-DOM משתנה. ואז, ההפשטות (abstractions) מתחילות "לדלוף". באגים צצים ללא היגיון: משתנים קיימים לפני ההצהרה עליהם, פונקציות זוכרות משתנים שאין להן גישה אליהם, ומילת המפתח this מצביעה על ה-window, על כפתור, או בכלל לא על כלום. זה בדרך כלל הרגע שבו מבינים שצריך להציץ מתחת למכסה המנוע ולהבין מה המנוע באמת עושה.
הקשר ביצוע (Execution Context): תהליך ההקמה בשני שלבים
כאשר מנוע ה-JavaScript בדפדפן או ב-Node.js נתקל בסקריפט, הוא לא פשוט קורא את הקובץ מלמעלה למטה כמו אדם שסורק דף. במקום זאת, הוא בונה הקשר ביצוע (execution context), מיכל שמכיל את כל מה שנדרש כדי להריץ קטע קוד מסוים. כל הקשר ביצוע עובר שני שלבים נפרדים.
שלב יצירת הזיכרון (Memory Creation Phase). במהלך מעבר ראשוני זה, המנוע סורק את כל הטווח (scope) ומקצה זיכרון לכל הצהרת משתנה ופונקציה שהוא מוצא. אם הוא רואה var, הוא שומר מקום ומאחסן undefined כממלא מקום. אם הוא רואה הצהרת פונקציה, הוא מאחסן את גוף הפונקציה המלא. זו הסיבה שפונקציה המוגדרת עם מילת המפתח המסורתית function יכולה להיקרא משורות שנראות מוקדמות יותר באותו scope. המנוע כבר מכיר אותה לפני שהוא מתחיל בביצוע.
שלב ביצוע הקוד (Code Execution Phase). כעת המנוע מריץ את הקוד שלך שורה אחר שורה. השמות (assignments) מתבצעים כאן. ביטויים (expressions) מחושבים. פונקציות נקראות. אם כתבת var name = "Alice";, ממלא המקום משלב אחד מוחלף סוף סוף במחרוזת. הבנת ההתנהגות הזו של שני מעברים מבהירה כמות מפתיעה של בלבול. המנוע אינו רשלן; הוא פועל לפי שגרת הקמה קשיחה.
משתנים והאזור המת (Temporal Dead Zone)
הבחירה בין var, let, ו-const אינה רק עניין של העדפה סגנונית. var הוא בעל טווח פונקציונלי (function-scoped), מה שאומר שהוא מתעלם לחלוטין מסוגריים. אם תצהיר עליו בתוך בלוק if, הוא "ידלוף" החוצה. ההתנהגות הזו אולי הגיונית ב-JavaScript המוקדמת, אך באפליקציות מודרניות היא גורמת לכאבי ראש אמיתיים בתחזוקה. let ו-const הם בעלי טווח בלוק (block-scoped). הם מכבדים סוגריים מסולסלים ונעלמים כשהבלוק מסתיים.
יש גם הבדל עדין אך קריטי באופן שבו ה-hoisting מתייחס אליהם. הצהרות var מועלות (hoisted) ומאותחלות מיד עם undefined. גם let ו-const מועלים מבחינה טכנית; המנוע יודע שהם קיימים לפני שהוא מגיע לשורת ההצהרה. אך הם אינם מאותחלים. הם יושבים במצב של המתנה הנקרא "האזור המת" (temporal dead zone). אם תנסה לקרוא להם לפני ששורת ההצהרה מבוצעת, תקבל ReferenceError קשיח במקום undefined חמקמק. הקריסה הזו היא למעשה מועילה. היא מונעת מהלוגיקה להמשיך על בסיס נתונים שטרם אותחלו.
טווח לקסיקלי (Lexical Scope) וסגורים (Closures)
טווח (scope) עונה על שאלה פשוטה: איפה אני יכול לגשת למשתנה הזה? JavaScript משתמשת בטווח לקסיקלי (lexical scope), מה שאומר שזכויות הגישה של פונקציה נקבעות לפי המקום שבו היא נכתבה פיזית בקוד המקור, ולא לפי המקום שבו היא נקראת בפועל. אם תגדיר פונקציה בתוך פונקציה אחרת, הפונקציה הפנימית יכולה להגיע החוצה כדי לקרוא משתנים מהפונקציה האם שלה. הפונקציה החיצונית אינה יכולה להגיע פנימה. מערכת יחסים זו היא סטטית. תוכל להעביר את הפונקציה הפנימית הזו בין מודולים, לשמור אותה במשתנה גלובלי, ולקרוא לה מקובץ אחר לגמרי. היא עדיין תזכור את המשתנים מהטווח שבו היא נולדה.
התנהגות זו מייצרת באופן טבעי סגורים (closures). closure נוצר כאשר פונקציה פנימית שומרת הפניה למשתנה מהטווח החיצוני שלה. גם לאחר שהפונקציה החיצונית מסיימת את ביצועיה והמשתנים המקומיים שלה אמורים להימחק על ידי ה-garbage collector, JavaScript שומרת אותם בזיכרון מכיוון שהפונקציה הפנימית עדיין זקוקה להם. הפונקציה הפנימית נושאת איתה את הסביבה שסבבה אותה.
זהו אינו רק פרט אקדמי. closures מעניקים לך דרך מעשית ליצור מצב פרטי (private state) בשפה שחסרה בה מודפיני גישה (access modifiers) מפורשים.
function makeCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
כאן, count מוסתר. שום דבר מחוץ לפונקציה המוחזרת לא יכול לאפס אותו או לקרוא אותו ישירות. זהו משתנה פרטי שנבנה אך ורק ממכניקת הטווח (scope).
Hoisting בפרקטיקה
נפוץ לשמוע ש-JavaScript "מעלה הצהרות לראש הקוד". זה מודל מנטלי שימושי, אך הקוד לא באמת נכתב מחדש. במהלך שלב יצירת הזיכרון (memory creation phase), המנוע פשוט רושם הצהרות לפני תחילת הביצוע. פקודה כמו var x = 5; מתנהגת כאילו ההצהרה והאתחול מנותקים זה מזה. ההצהרה var x; מעובדת מוקדם יותר ומאותחלת כ-undefined. ההשמה x = 5; נשארת בדיוק במקום שבו כתבת אותה ומתבצעת במהלך שלב הביצוע (execution phase).
בגלל זה, var עלול להוביל לתוצאות מפתיעות. משתנה שנעשה בו שימוש ליד תחילת הפונקציה עלול להכיל undefined למרות שהשמה ענקית נמצאת בתחתיתה. שימוש ב-let ו-const מבטל את המלכודת הזו, כיוון שה-temporal dead zone מחייב אותך לשמור על ההצהרות שלך מעל המקום שבו אתה משתמש בהן.
איך this מקבל את הערך שלו
אם הקשר הביצוע (execution context) והסקופ (scope) קובעים היכן משתנים חיים, this קובע איזה אובייקט נמצא כעת בשליטה. בניגוד למשתנים לקסיקליים, this אינו נקבע לפי המקום שבו הפונקציה נכתבה. הוא נקבע כולו לפי האופן שבו הפונקציה נקראת.
קישור ברירת מחדל (Default binding) מתרחש כשאתה מפעיל פונקציה רגילה ועצמאית. במצב לא מחמיר (non-strict mode), this חוזר לאובייקט הגלובלי. בדפדפן, זהו window. אם תקרא לפונקציה ללא הקשר כלשהו, אתה עלול בטעות לגעת במצב הגלובלי מבלי לשים לב לכך.
קישור משתמע (Implicit binding) קורה כשאתה קורא לפונקציה כמתודה (method) של אובייקט. אם תכתוב user.sayName(), הנקודה אומרת למנוע בשקט להגדיר את this כ-user למשך זמן הקריאה הזו. המקום שבו מתבצעת הקריאה חשוב יותר מהמקום שבו הפונקציה הוגדרה.
קישור מפורש (Explicit binding) מאפשר לך לדרוס הכל באופן ידני. call() ו-apply() מפעילים פונקציה באופן מיידי תוך כפיית this להיות אובייקט ספציפי שסיפקת. ההבדל היחיד ביניהם הוא האופן שבו הארגומנטים מועברים: call מקבלת רשימה מופרדת בפסיקים, בעוד ש-apply מקבלת מערך. bind() עובדת אחרת. היא אינה מפעילה את הפונקציה מיד. במקום זאת, היא מחזירה פונקציה חדשה שבה this נעול לצמיתות על הערך שסיפקת. זהו כלי יקר ערך עבור callbacks שעלולים לאבד את ההקשר שלהם כאשר הם מועברים במקומות שונים.
קישור new (New binding) נכנס לתמונה כשמשתמשים במילת המפתח new לפני קריאה לפונקציה. המנוע בונה אובייקט חדש וריק, מגדיר את קישור הפרוטוטיפ שלו, ומפנה את this בתוך הקונסטרקטור (constructor) לאותו מופע (instance) חדש.
כתיבת קוד שנשאר לאורך זמן
הבנת המכניקה היא רק חצי מהמלאכה. החצי השני הוא כתיבת קוד שבני אדם יוכלו לקרוא גם בעוד שישה חודשים.
DRY (Don't Repeat Yourself) נשמע מובן מאליו, אך הוא מופר ללא הרף. אם אתה מוצא את עצמך כותב את אותה לוגיקת וולידציה או תבנית של קריאת API בשלושה קבצים שונים, הוצא אותה החוצה. כתוב פונקציה אחת. מקור אמת אחד (one source of truth) פירושו מקום אחד לעדכון כאשר הדרישות משתנות, וזה חוסך זמן בדרכים שקשה להפריז בהן.
KISS (Keep It Simple, Stupid) הוא מנגנון הגנה מפני אגו. אופרטורים טרנריים מקוננים (nested ternaries) ו-closures בשורה אחת מרגישים חכמים, אך הם גובים מחיר של שעות בזמן דיבאגינג. תבנית ה-closure שהצגתי קודם היא עוצמתית, אך קינון של חמש רמות רק כי אתה יכול לעשות זאת הוא טעות. קוד פשוט שורד החלפת צוותים, תקלות בייצור (production), ואת ההתראות באמצע הלילה כשמשהו נשבר ואף אחד לא זוכר למה.
התגמול האמיתי
לימוד של ביצוע
