2012-10-20

JavaScript's This - למפתחי ג'אווה ו #C מנוסים

"מפתח JavaScript הופך ממתכנת ממוצע למתכנת מקצועי ביום שהוא מבין את המילה השמורה this".

כמפתחי ג'אווה או #C אתם אמורים להישאר כרגע פעורי פה: האם ייתכן ש"מתכנתי ג'אווהסקריפט" לא מבינים דבר בסיסי כמו המילה השמורה this?!
אבל בג'אווהסקריפט, כמו בג'אווהסקריפט - יש הפתעות במקומות הלא צפויים, ו this איננה יוצאת דופן.

חלק גדול ממפתחי הג'אווהסקריפט הם מפתחי צד שרת (Java, Ruby, #C) אשר משתמשים בה "לכתוב קצת UI", סוג של "מפתחים מזדמנים".
אתם לא באמת צריכים להבין את כל הפינות של ג'אווהסקריפט על מנת לפתח בווב. עבור רוב עבודות הג'אווהסקריפט, תוכלו להסתדר עם "להבין מספיק".
אם אתם עושים עושים בג'אווהסקריפט קצת מעבר - להבין את this בהחלט יכול לעזור.

שייך לסדרה מבוא מואץ ל JavaScript ו jQuery


תשכחו את מה שידעתם בג'אווה / #C...

Scope בג'אווה כפי שאתם מכירים אותו. פשוט וברור.

בג'אווה, להבין את ה scope זה דיי פשוט: scope נוצר ע"י סוגריים מסולסלים.
אם אני רוצה לדעת מיהו המשתנה n - פשוט אטייל בעין, scope אחר scope עד שאמצא היכן הוא הוגדר.
אם אני רוצה לדעת מיהו this - פשוט אטייל בעין, scope אחר scope עד למחלקה הראשונה שאני רואה. כמעט תמיד תהיה רק מחלקה יחידה (קינון מחלקות הוא דיי נדיר בג'אווה).


בג'אווהסקריפט פיצלו את ה scope, כפי שקיים בג'אווה, לשניים:
  • Scope להגדרת משתנים, המוגדר ע"י פונקציה (ולא סוגריים מסולסלים! [א])
  • Context[ב] להגדרת this, המוגדר ע"י שייכות קונקרטית לאובייקט - בזמן ריצה. הסבר בהמשך.
הכללים לקביעת ה-context הם שונים מהכללים לקביעת ה scope. עצם ההבנה שהחוקים למציאת ה scope הם לא דומים לחוקים למציאת ה context - היא מחצית הדרך להבנה של this.


כיצד נוצר Context?
כלל #1: כאשר פונקציה (function) שאינה משויכת ישירות לאובייקט נקראת - ה context יהיה האובייקט הגלובלי. כלומר this יצביע לאובייקט הגלובלי. האובייקט הגלובלי הוא האובייקט עליו נרשמו כל המשתנים / פונקציות שהוגדרו ללא var או ללא שייכות לאובייקט כלשהו. בדפדפנים, האובייקט window (שמתאר Tab של דפדפן) נבחר לייצג את האובייקט הגלובלי. כל השמה "גלובלית" תתווסף עליו.

כלל #2: כאשר פונקציה המשויכת ישירות לאובייקט (מתודה method) נקראת - ה context שלה יהיה האובייקט אליו היא שויכה. כלומר, this יצביע לאובייקט שעליה היא הוגדרה, או ששויכה במהלך הריצה (דוגמאות בהמשך).

זהו זמן טוב לדוגמה.


השמשתי בצילומי מסך מה IDE האהוב עלי לכתיבת JavaScript, הרי הוא WebStorm. העטיפה "it" ומשפטי ה expect נובעים מכך שזו בדיקת-יחידה, הנעזרים בספרייה בשם Jasmine.
כל הבדיקות בפוסט זה עוברות, ולכם אתם יכולים להניח שהחלק שבתוך ה expect שווה לערך בתוך ה toBe.

ב1 אנחנו רואים שהפונקציה מחזירה את השם של האובייקט הגלובלי. בגלל שאני מריץ את הקוד בתוך "מריץ הבדיקות" השם שם האובייקט הגלובלי במקרה זה הוא "runner".

ב2 הגדרנו אותה פונקציה על אובייקט - והנה this התייחס לאובייקט עליה הוגדרה - הרי הוא A.

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


המילה השמורה new, ומה שהיא עושה ל this...
בנאי (constructor) בשפת ג'אווהסקריפט היא פונקציה רגילה לכל דבר. הקונבנציה המקובלת היא לקרוא לבנאי בשם עם אות ראשונה גדולה (capital letter). שאר הקסם קורא בעת הפעלת הפונקציה עם המילה השמורה new.

שימוש במילה השמורה new גורם לשלושה דברים:
  • נוצר אובייקט חדש ריק - השקול לכתיבת "{ }".
  • האובייקט החדש מועבר לבנאי, כפרמטר בשם this. הדברים האלו קורים מאחורי הקלעים - בקוד עצמו לא יהיה זכר ל this בחתימת הפונקציה.
  • במידה והבנאי לא סיפק ערך החזרה return (עדיף שלא יספק) - ג'אווהסקריפט מוסיפה return this ביציאה מהפונקציה.
הנה דוגמה:

במקרה זה, השתמשנו ב new על מנת לייצר אובייקט חדש.
למרות שהפונקציה ConstructorA (=בנאי) לא מוגדרת בתוך אובייקט, בזמן ההרצה this דווקא כן מתייחס לאובייקט.
מסקנה: אין לחפש את האובייקט במבנה הקוד, אלא לחפש את נקודת הקריאה (invocation) של הפונקציה ומאיזה context היא התבצעה.


הנה דוגמה בעייתית:

הבעיות הקשות לרוב לא צצות בפונקציות בנות 5 שורות - הן קורות באובייקטים בני עשרות או מאות שורות. הנה סימולציה לבעיה שיכולה להתרחש.
שימו לב למקרא הצבעים (יכול לעזור לעקוב):
  • משתנה / פונקציה מקומית = טורקיז.
  • משתנה / פונקציה של אובייקט = חום-צהוב.
  • (משתנה / פונקציה גלובליים = סגול)
ב1 קורה בדיוק מה שאנחנו מצפים - אנחנו מקבלים את A. זה שכפול של הדוגמה הקודמת.
ב2 הדברים משתבשים. אפשר בטעות לחשוב, שכל שימוש ב this בתוך הבנאי יתייחס לאובייקט החדש. זה לא המצב. במקרה זה הגענו לאובייקט הגלובלי.

הבעיה נובעת מכך שהפונקציה sayMyName איננה משויכת לאובייקט שנוצר - כי אם "נתלית" על ה scope של הפונקציה ConstructorA. הנה שמה צבוע בטורקיז = משתנה לוקאלי. אם אתם זוכרים את כלל #1 - פונקציה שלא קושרה לאובייקט משויכת ל context של האובייקט הגלובלי.
בגלל שמדובר ב closure [ג] היא תישאר "בחיים" ויהיה ניתן להשתמש בה גם לאחר שהפונקציה ConstructorA סיימה לרוץ.

Webstorm מזהה את הבעיה ואכן מציג אזהרה על ה this (מת עליו). כלי ניתוח ססטי כמו JSLint/JSHint - לא מזהים את המצב הזה. כנ"ל IDEs רבים אחרים.

למי שרוצה לחפור קצת יותר: ניתן לומר ש sayMyName דומה מאוד למתודה סטטית (static) בג'אווה: היא קיימת על המחלקה ולא מכירה את האובייקט - ולכן לא ניתן לגשת ממנה אל האובייקט. בעוד בג'אווה this בתוך מתודה סטטית היה מצביע על המחלקה - בג'אווהסקריפט הוא "הולך לאיבוד" ומצביע על האובייקט הגלובלי (בשל כלל #1).

הנה הפתרון המקובל לבעיה זו:

צרו משתנה ("that") ב closure שיצביע על ה context הרצוי - ואז השתמשו בו.


השילוב של ג'אווהסקריפט ו this יכול לבלבל... 

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

הקריאה ל ()objectD.objectF.getThatName זורקת שגיאה (error), בעוד רצינו שתחזיר "E".

מה שקצת מבלבל הוא ההנחה שהקריאה this.objectE תגיע ל objectE - שהוא אובייקט אח ולא אובייקט בן.
ג'אווהסקריפט מצידה לא מודיע על בעיה: השימוש ב this.objectE מוסיף ל objectF משתנה חדש בשם objectE, שערכו undefined, ומיד אחר כך צועק על כך של undefined אין מתודה בשם getMyName. בעסה.

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

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

    getThatName: function(){ return objectD.objectE.getName(); }

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


הנה תקלה נפוצה אחרת:

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

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

ספריית jQuery מציע utility function שיכולה לעזור, proxy.$ (לחצו על התמונה על מנת להגדיל):

proxy.$ מקבלת פונקציה ואובייקט (= context) ויוצרת פונקציה חדשה הקושרת "לעד" את ההרצה של הפונקציה ל context שנבחר.

אם אתם עובדים עם דפדפנים חדשים בלבד (+IE9), אתם יכולים להשתמש בפקודת bind של שפת ג'אווהסקריפט - שעושה בדיוק את אותו הדבר. proxy$ / bind שימושית במיוחד לצורך רישום אירועים:


השוו דוגמה זו לדוגמה הקודמת - הרעיון דומה.
ב1, לחיצה על הכפתור תפעיל את draw ב context של אובייקט ה button$[ד] - שאין לו מתודה בשם draw = תקלת זמן ריצה.
ב2, לחיצה על הכפתור תפעיל את draw ב context של האובייקט objectA - היכן שהמתודה אכן נמצאת.


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


כפי שאתם רואים call מאפשר להריץ מתודה (במקרה זה - של אובייקט A) על אובייקט (במקרה זה - D), כלומר - כך ש this יתייחס לאובייקט D.
כמובן שפונקציה לעתים דורשת ארגומנטים, call מאפשרת להוסיף אותם בסדר הנכון אחרי ה context שהועבר.
ההבדל בין call ו apply הוא רק בדרך בה מעבירים ארגומנטים לפונקציה - רשימת ארגומנטים או מערך. לא משהו גדול.

בפועל השימוש ב call יכול להיות מהיר עד פי כפליים מ apply - שוני שמשמעותי רק כאשר אתם עושים מאות או אלפי הפעלות של הפונקציה.


שיהיה בהצלחה!


------

[א] אתם יכולים לקרוא עוד קצת על הנושא בפוסט מבוא מואץ ל JavaScript עבור מפתחי Java / #C מנוסים - חלק 1, בערך באמצע.

[ב] רשמית נקרא Function Context, אבל יותר מדויק יהיה לקרוא לו Invocation Context.

[ג] ניתן לקרוא על Closure בסוף הפוסט מבוא מואץ ל JavaScript עבור מפתחי Java / #C מנוסים - חלק 1.

[ד] הכוונה ל wrapper של jQuery שעוטף את אלמנט הכפתור, אותו יצרנו בעזרת השאילתה ('.button')$.



2 תגובות:

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

    הקלטתי בעבר סקרינקאסט על this עם דוגמאות למשמעויות השונות. לינק:
    http://mobileweb.ynonperek.com/video/javascript-course-7-this

    השבמחק
    תשובות
    1. היי ינון,

      תודה על החידוד.

      ראיתי גם את האתר שלך עם סרטוני ההדרכה בעברית (!). מרשים מאוד!

      ליאור

      מחק