JavaScript – מתחת למכסה המנוע | מדריך למתקדמים

מדריך JavaScript

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

במאמר זה נתמקד ב- JavaScript כשהיא רצה בדפדפן. המאמר מבוסס על הרצאות ומצגות מהקורס The Complete JavaScript Course מאת ג'ונס שמדטמן

תוכן עניינים



לכל דפדפן יש מנוע JavaScript. המנוע הפופולרי ביותר הוא מנוע V8 של גוגל. מנוע זה הוא המנוע של גוגל כרום וגם של Node.js. כמובן, שלכל שאר הדפדפנים יש מנוע JavaScript משלהם. 

מנוע JavaScript תמיד יהיה מורכב מ-Call Stack ו-Memory Heap וירוץ על Thread (תהליכון) אחד. נרחיב אודותם בהמשך. 

הידור, פירוש וקומפילציה בזמן אמת

מחשבים מבינים רק שפה אחת – שפה אסמבלי . לפיכך כל תוכנה צריכה בסופו של דבר להתרגם לשפת אסמבלי.

ניתן לציין שלושה סוגים של תהליך תרגום שכזה:

  1. קומפילציה (הידור)
  2. פירוש (Interpretation)
  3. קומפילציה בזמן אמת (Just-in-time compilation)

הנה הסבר קצר על כל אחד מהתהליכים האלו.

1. הידור/קומפילציה (Ahed of time Compilation)

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

2. פירוש (Interpretation)

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

במהלך ההרצה, בהרצת שורה אחר שורה, הקוד גם מקומפל לשפת מכונה. 

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

3. קומפילציה בזמן אמת (Just-in-time compilation)

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

כיום JavaScript היא שפה שמתקמפלת בזמן אמת. ביצוע הקוד קורה מייד לאחר הקימפול. הסיבה שקומפילציה בזמן אמת מהירה יותר הן מפירוש והן מקומפילציה, היא מכיוון שהקוד מתקמפל בזמן ריצה לקוד מכונה שניתן לבצע ישירות על ידי המעבד, ממש בזמן שהוא עומד להתבצע. זה מבטל את העומס שנוצר עם פירוש הקוד שורה אחר שורה, כפי שנעשה בתהליך של פירוש. כמו כן, קומפילציית JIT (ר"ת Just In Time) יכולה גם לבצע אופטימיזציות המבוססות על מידע בזמן ריצה, מה שאינו אפשרי עם קומפילציה מבעוד מועד. התוצאה היא שקוד היוצא לפועל בשיטת זו הוא לרוב מהיר יותר מאותו קוד המופעל באמצעות פרשנות או קומפילציה מבעוד מועד.

איך מתבצעת קומפילציה בזמן אמת ב-JavaScript?

בתרשים הבא תוכלו לראות כיצד מתבצעת קומפילציית בזמן אמת (Just-in-time Compilation) ב-JavaScript. הנה פירוט קצר על השלבים השונים.

פארסינג

בשלב ראשון מתבצע פארסינג, לאמור קריאת הקוד. בתהליך זה נוצר Abstract Syntax Tree – AST. הקוד מפורק לחלקים שמשמעותיים לשפה, כמו מילות מפתח משמעותיות, כמו const או function או new ואז מתבצעת שמירה של כל החלקים האלה לעץ שבנוי בצורה מובנית. בשלב זה גם נעשית בדיקה אם יש שגיאות בקוד. העץ שנוצר ישמש לאחר מכן, להפוך לקוד מכונה.

נגיד שיש לנו משתנה פרימטיבי פשוט כמו ה-x הזה. 

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

הערה חשובה: אין קשר בין העץ הזה ל-DOM Tree. זה דבר אחר לגמרי מה-DOM. זה ייצוג של כל הקוד בתוך מנוע JavaScript. 

קומפילציה ו-Execution

השלב הבא הוא שלב הקומפילציה. בשלב זה ה-AST שנוצר מתקמפל לקוד מכונה. ואז בשלב הבא קוד המכונה הזה יוצא לפועל מייד. זאת משום שהמנוע המודרני של JavaScript משתמש כזכור ב-JIT Compilation. עוד מעט נרחיב על כך ששלב הביצוע, ה-Execution, קורה בCall Stack. אבל הסיפור לא מסתיים פה. למנועי JavaScript מודרניים יש אסטרטגיות אופטימיזציה מתקדמות. הם מייצרים גרסה של קוד מכונה מאוד לא יעיל, רק כדי שניתן יהיה להתחיל בהרצת הקוד, ואז ברקע, הקוד עובר תהליכי אופטימיזציה. ואז הוא מקומפל מחדש בגרסה הממוטבת, וככה זה יכול לקרות כמה וכמה פעמים, לאחר כל אופטימיזציה, והקוד הקודם פשוט מוחלף בקוד הממוטב, בלי להפסיק או להפריע להרצה.

התהליך הזה הוא מה שהופך מנועים מודרניים, כמו מנוע ה-V8, לכל כך מהירים. 

כל התהליכים האלה, של פרסור, קומפילציה ואופטימיזציה, קורים ב-threads (תהליכונים) מיוחדים במנוע, שאין לנו נגיעה וגישה אליהם. זה thread נפרד לחלוטין מה-thread הראשי שה-Call Stack רץ בו, איפה שהקוד שלנו רץ.

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

סביבת הריצה של JavaScript

כעת נעבור לדבר על סביבת הריצה של JavaScript בדפדפן. יש חשיבות רבה להבין זאת. 

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

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

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

אז, כאמור, ה-Web APIs הם לא חלק מהמנוע, אבל הם כן חלק מסביבת הריצה. כי כאמור, סביבת הריצה היא מיכל שמכיל את כל מה ש-JavaScript צריכה כדי לרוץ בדפדפן, בהקשר של המאמר הנוכחי. סביבת ריצה של JavaScript כוללת בנוסף את ה-Callback Queue. זהו מבנה נתונים שמכיל את כל ה-Callback Functions שמוכנות לצאת אל הפועל. לדוגמה, כאשר אנחנו מצמידים Event Listener לאלמנט ב-DOM, כמו כפתור, כדי שהוא יגיב ל-Event של קליק. נראה זאת בפעולה בהמשך.

אז הפונקציה שאני מעביר ל-Event Listener, היא Callback Function. וכאשר ה-Event קורה, לדוגמה קליק על הכפתור, אז פונקציית ה-Callback תיקרא. מה שקורה בפועל זה שקודם כל פונקציית ה-Callback מועברת ל-Callbacks Queue, ואז כשה-Call Stack ריק, ה-Callback Function עוברת ל-Call Stack כדי שהיא תוכל לצאת אל הפועל.  

כל זה קורה באמצעות מה שנקרא ה-Event Loop. ה-Event Loop לוקח את ה-Callback Functions מה-Callback Queue, ושם אותן ב-Call Stack, כדי שהן יוכלו לצאת אל הפועל.

ככה סביבת הריצה של JavaScript מממשת בעצם את ה-Nonblocking Concurrency Model, מודל מקבילי שאינו חוסם. מה שאומר, ש-JavaScript, שאמנם רצה על Thread אחד, מממשת ריבוי תהליכים באמצעות מרכיבים אחרים בסביבת הריצה. נדבר על זה בהרחבה בהמשך וגם נסביר למה זה הופך את סביבת ההרצה כולה של JavaScript ל-Nonblocking.

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

ככה נראית סביבת הריצה של Node.js. זה מאוד דומה לסביבת הריצה בדפדפן, אבל בגלל שאין לנו דפדפן, אז גם אין לנו את ה-Web APIs, כי אין דפדפן שיספק אותם. לחילופין, יש לנו C++ Bindings ו-Thread Pool, מאגר תהליכונים. פרטים אלה אינם מענייננו. מה שחשוב לזכור זה  שסביבות הרצה שונות של JavaScript קיימות. 

ה- Execution Context

אז איך קוד של JavaScript יוצא לפועל? עכשיו נצלול לעומק הנושא וראשית נכיר את ה-Execution Context. 

ה-Execution Context הוא מושג אבסטרקטי, אבל ניתן להגדירו כסביבה בה חלקי קוד של JavaScript יוצאים אל הפועל. הוא כמו מיכל שמכיל את כל המידע שנחוץ כדי שקוד מסוים יצא אל הפועל, כמו משתנים מקומיים, או ארגומנטים שמועברים לפונקציות. תמיד, אבל תמיד, יהיה רק Execution Context אחד שיוצא אל הפועל. ותמיד Execution Context ברירת המחדל הוא ה-Global Execution Context, היכן שהקוד ב-Top Level יוצא לפועל.

לאחר שהקוד ב-Top Level מסיים לצאת אל הפועל, הפונקציות מתחילות לצאת אל הפועל ולכל קריאת פונקציה נוצר Execution Context חדש משלה והוא מכיל את כל האינפורמציה שדרושה כדי להריץ את הפונקציה ואותה בלבד. אותו דבר חל גם לגבי מתודות. כיוון שהן פשוט פונקציות שצמודות למפתחות באובייקטים.

לאחר שכל הפונקציות מסיימות לצאת אל הפועל, המנוע מחכה ש-Callback Functions יגיעו, על מנת שהוא יוכל להריץ אותן. לדוגמה Callback Functions שמקושרות ל-Click Events. וכזכור, זה ה-Event Loop שמספק את ה-Callback Functions האלה מה-Callback Queue, כשה-Call Stack מתרוקן.

מרכיבי ה- Execution Context

הדבר הראשון שיש לנו ב-Execution Context זה סביבת המשתנים, Variables Environment. 

בסביבה זו כל המשתנים וה-Function Declarations מאוחסנים. בנוסף יש לנו אובייקט מיוחד של ארגומנטים. אובייקט זה מכיל, כנגזר משמו, את כל הארגומנטים שמועברים לפונקציה שנמצאת כרגע ב-Execution Context ושאובייקט זה שייך אליה, במידה וה-Execution Context היא של פונקציה כמובן, כי כאמור, לכל פונקציה יש Execution Context משלה. אז כל המשתנים שמוכרזים על ידי הפונקציה, ימצאו את עצמם בסביבת המשתנים של הפונקציה. בנוסף, פונקציה יכולה לגשת למשתנים שמחוצה לה. וזה קורה בגלל מה שנקרא ה-Scope Chain. ה-Scope Chain, בקצרה, מכיל רפרנסים לכל המשתנים שנמצאים מחוץ לפונקציה. לבסוף כל Execution Context גם מקבל משתנה מיוחד שנקרא this Keyword. 

לסיכום, ה-Execution Context מכיל את סביבת המשתנים, ה-Scope Chain ואת ה-this Keyword. הם נוצרים בשלב שנקרא Creation Phase, שקורה מייד לפני ה-Execution.

חשוב לציין של-Arrow Functions אין אובייקט ארגומנטים משלהם ואין להם this Keyword משלהם. הם משתמשים באובייקט הארגומנטים וב-this Keyword של פונקציית האב הראשונה שלהם או שהם מצביעים ל-Global Scope.

ה- Execution Context בפועל

בואו נסתכל בדוגמה כדי להבין את הנושא בצורה טובה יותר. 

הדבר הראשון שיקרה פה זה שיווצר Global Execution Context בשביל הקוד שנמצא ב-Top Level. הקוד שנמצא ב-Top Level זה כל קוד שלא נמצא בתוך פונקציה כאמור. אז בהתחלה רק הקוד הזה יצא אל הפועל. וזה הגיוני, כי פונקציות יוצאות אל הפועל רק כאשר הן נקראות/מופעלות. אז בדוגמה פה המשתנה first נמצא ב-Top Level Code ולכן הוא יצא לפועל ב-Global Execution Context. הוא יודע כעת שהערך של first הוא 5, הוא יודע ש-second ו-third הם פונקציות, והוא עדיין אינו יודע מה הערך של fourth.

אז בשורה 14 fourth צריך להפעיל את second כדי לקבל ערך, ואז second יוצא לפועל ונוצר ה-Execution Context של second. שם ידוע שהערך של a הוא 1, אבל הערך של b עדיין אינו ידוע, כי הוא צריך לקבל אותו מ-third. אז פונקציית third רצה ונוצר ה-Execution Context שלה ושם הערך של c הוא 3 ובאובייקט הארגומנטים שלה יש את 1 ו-2, שהועברו בעת הפעלת הפונקציה. אז עכשיו third מחזירה 6 = 1 + 2 + 3. חזרנו ל-Execution Context של second והערך של b כעת הוא 6. לבסוף second מחזירה 7 = 6 + 1 וסיימנו ב-Global Execution Context כשהערך של fourth הוא 7.

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

ה- Call Stack

אז אמרנו שסביבת הריצה של JavaScript כוללת את מנוע JavaScript. מנוע JavaScript כולל את ה-Memory Heap ואת ה-Call Stack. אבל מהי בכלל ה-Call Stack? 

ה-Call Stack היא המקום בו Execution Contexts נערמים זה מעל זה כדי לעקוב אחר יציאתה לפועל של התוכנית. ה-Execution Context העליונה היא זו שרצה בכל רגע נתון ובשיטת LastInFirstOut ה-Execution Contexts יוצאות לפועל.

אם נחזור לדוגמה שראינו קודם, ראשית נמצא ב-Call Stack ה-Execution Context הגלובלי ואז כאשר הפונקציה second נקראת היא עוברת לראש ה-Call Stack ואז כאשר הפונקציה third נקראת, היא עוברת לראש ה-Call Stack. ואז כאשר third מסיימת ומחזירה ערך, היא מוסרת מה-Call Stack ואז כאשר second מסיימת היא מוסרת מה-Call Stack וה-Execution Context הגלובלי נשאר עם כל הערכים שהוא חיכה להם. 

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

אז ככה ה-Call Stack שומרת על סדר ה-execution של ה-Execution Contexts וכך ה-Call Stack מבטיחה שסדר ה-Execution לעולם לא ילך לאיבוד. 

כיצד Asynchronous JavaScript עובדת מאחורי הקלעים 

בואו נסקור שוב בקצרה את סביבת הריצה של JavaScript.

כאמור, סביבת הריצה של JavaScript היא מין מיכל הכולל את כל החלקים השונים הנחוצים לביצוע קוד JavaScript. הלב של כל סביבת ריצה של JavaScript הוא המנוע של JavaScript. זה המקום שבו הקוד מבוצע בפועל והיכן שמאוחסנים האובייקטים בזיכרון והוא זה שרץ רק על Thread אחד. הקוד שמבוצע בפועל והיכן שמאוחסנים האובייקטים בזיכרון קורים ב-Call Stack וב-Memory Heap. כאמור, ל-JavaScript יש רק Thread אחד ולכן היא יכולה לעשות רק דבר אחד בכל פעם. אין ריבוי משימות ב-JavaScript עצמה. שפות אחרות כמו Java יכולות לבצע מספר חלקים של קוד בו-זמנית, אך לא JavaScript. בשלב הבא יש לנו את סביבת ה-Web APIs. אלו ממשקי API שמסופקים למנוע, אבל הם למעשה לא חלק משפת JavaScript עצמה. אלה דברים כמו הטיימרים, ה-DOM, fetch API, ה-API של Geolocation, וכן הלאה וכן הלאה. בשלב הבא, יש את ה-Callback Queue, וזהו מבנה נתונים שמכיל את כל Callback Functions המוכנות לביצוע אשר מצורפות ל-Events כלשהם שעומדים להתרחש. בכל פעם שה-Call Stack ריקה ה-Event Loop לוקחת Callbacks מה-Callback Queue ומכניסה אותן ל-Call Stack כדי שניתן יהיה לבצע אותם.

ה-Event Loop היא למעשה זו שמאפשרת את ההתנהגות האסינכרונית ב-JavaScript. זו הסיבה שבגינה אנחנו יכולים לקבל את ה-Non-Blocking Concurrency Model ב-JavaScript. פירוש המונח Concurrency-Model הוא איך שפה מתמודדת עם מספר דברים שקורים בו זמנית.

אז כיצד ה-Non-Blocking Concurrency Model של JavaScript באמת עובד? ולמה ה-Event Loop כל כך חשובה? בואו נתמקד בחלקים החיוניים של סביבת הריצה לנושא זה. אלה הם ה-Call Stack, ה-Event Loop, ה- Web APIs וה-Callback Queue. כאמור, מנוע JavaScript בנוי סביב הרעיון של Thread יחיד. אבל אם יש רק Thread אחד שבו ניתן לעבוד במנוע אז כיצד ניתן לבצע קוד אסינכרוני בצורה שהיא Non Blocking? להלן נראה כיצד באמת עובד מודל המקבילות של JavaScript מאחורי הקלעים, תוך שימוש בכל חלקי סביבת הריצה של JavaScript שכבר הכרנו, ונעשה זאת על ידי הסתכלות בדוגמת קוד אמיתית. ראינו לפנים כיצד ה-Call Stack עובדת וכעת נתמקד בקוד שב-Web APIs וב-Callback Queue.

בואו נביט באלמנט התמונה הזה, ה-el. בשורה השנייה אנחנו מגדירים שה-src של התמונה הזו הוא dog.jpg והתמונה תתחיל כעת להיטען באופן אסינכרוני ברקע (טעינת תמונות קורית בצורה אסינכרונית ב-JavaScript).

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

אז המשימות האסינכרוניות האלה יפעלו בסביבת ה-Web APIs של הדפדפן.

אם התמונה היתה נטענת ב-Call Stack זה היה מוביל לחסימת ביצוע שאר הקוד, ולכן טעינת תמונות ב-JavaScript היא אסינכרונית ולא קורית ב-Call Stack, ב-Main Thread of Execution, אלא בסביבת ה-Web APIs הנפרדת, כאמור.

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

אז, כאן אנו מצרפים eventListener ל-Event הטעינה של אותה תמונה ואנחנו מעבירים לו Callback Function כמו תמיד. בפועל, זה אומר לרשום את ה-Callback Function הזו בסביבת Web APIs, בדיוק איפה שהתמונה נטענת וה-Callback Function הזו תישאר שם עד שאירוע הטעינה יסתיים.

בשורה הבאה, אנו מבצעים קריאת AJAX באמצעות ה-fetch Api וגם פעולת ה-fetch האסינכרונית תתרחש בסביבת Web APIs כי אחרת היינו חוסמים את ה-Call Stack כאמור. לבסוף, אנו משתמשים במתודת ה-then על ה-Promise שהוחזרה על ידי פונקציית ה-fetch וזה גם ירשום Callback Function בסביבת ה-Web APIs כדי שנוכל להגיב לערך ה-Resolved העתידי של ה-Promise. אז ה-Callback Function הזו קשורה ל-Promise שיביא את הנתונים מה-API, וזאת נקודה שתהיה חשובה בהמשך.

אז יש לנו את התמונה שנטענת ברקע, ונתונים שנשלפים מה-API וכל זה קורה בסביבת ה-Web APIs.

עכשיו הגענו לרגע שזה הולך להיות ממש מעניין. נניח שהתמונה סיימה להיטען וה-Event של ה-Load מתרחש על אותה תמונה. מה שקורה לאחר מכן הוא שה-Callback עבור אירוע זה עובר ל-Callback Queue.

ה-Callback Queue הוא בעצם רשימה מסודרת של כל ה-Callback Functions שיש בתור לביצוע.

ניתן לחשוב על ה-Callback Queue כרשימת מטלות עם כל המשימות שיש לבצע, אבל ה-Call Stack היא זו שתבצע את אותן משימות בסופו של דבר.

כעת, בדוגמה שלנו, אין Callbacks אחרות ב-Callback Queue, אבל יכולות להיות כמובן. אז, אם היו Callbacks אחרות שהיו ב-Callback Queue, אז ה-Callback החדשה הזו היתה עוברת ישר לסוף התור ושם היא היתה מחכה בסבלנות לתורה לרוץ. 

למעשה יש לכך השלכות גדולות שחשוב מאוד לשים לב אליהן כעת. דמיינו מצב בו אתם מגדירים setTimeOut לחמש שניות ולאחר חמש שניות ה-Callback שב-setTimeOut לא תעבור מייד ל-Call Stack אלא תעבור ל-Callback Queue. ובואו נגיד שכבר יש ב-Callback Queue כמה Callbacks אחרות שמחכות שם לצאת אל הפועל, ובואו גם נגיד שזה לקח שניה אחת להפעיל את כל שאר ה-Callbacks הללו, אז, במקרה כזה, ה-Callback של ה-setTimeOut תפעל רק אחרי שש שניות ולא אחרי חמש! שש השניות הללו הן חמש השניות שחלפו עבור הטיימר, בתוספת השנייה שלקחה להפעיל את כל שאר ה-Callbacks שכבר חיכו בתור לפני הטיימר. אז מה שזה אומר הוא שהזמן שאנחנו מגדירים לפונקציות setTimeOut אינו ערובה!!! הערובה היחידה היא שה-setTimeOutsלא ירוצו לפני הזמן שהקצבנו, אבל יכול מאוד להיות שהן ירוצו אחרי שיחלוף הזמן שקבענו! הכל תלוי במצב ה-Callback Queue ושל ה-Call Stack (שגם היא צריכה להתפנות כדי לקבל אליה Callbacks).

עוד דבר שחשוב להזכיר כאן הוא שה-Callback Queue מכיל גם Callbacks שמגיעות חזרה מאירועי DOM כמו לחיצות עכבר או לחיצות מקשים או כל דבר אחר. יש לציין שרבים מאירועי ה-DOM, כמו Click Events, הם לא אסינכרוניים, אבל הם עדיין משתמשים ב-Callback Queue כדי להפעיל את ה-Callbacks שלהם. לכן, אם מתרחשת לחיצה על כפתור שהוספנו לו Event Listener אז מה שיקרה הוא בדיוק כמו מה שיקרה עם אירוע הטעינה האסינכרוני.

ה-Event Loop

עכשיו הגיע הזמן לראות את ה-Event Loop בפעולה. ה-Event Loop מסתכל לתוך Call Stack ובודק אם הוא ריק או לא, מלבד כמובן ה-Global Execution Context שתמיד יהיה שם. אז אם ה-Call Stack אכן ריק, מה שאומר שכרגע אין קוד שיוצא שם אל הפועל, אז הוא ייקח את ה-Callback הראשונה מה-Callback Queue וישים אותו ב-Call Stack.

וזה נקרא Event Loop Tick. אז בכל פעם שה-Event Loop לוקח Callback Function מה-Callback Queue ושם אותה ב-Call Stack אנחנו אומרים שהתרחש Event Loop Tick.

כפי שאנו יכולים לראות כאן ל-Event Loop יש את המשימה החשובה ביותר של ביצוע תיאום בין ה-Call Stack ל-Callbacks שנמצאות ב-Callback Queue. ה-Event Loop הוא בעצם מי שמחליט בדיוק מתי כל Callback Function תתבצע. אנחנו יכולים גם לומר שה-Event Loop גם מתזמר את כל סביבת הריצה של JavaScript.

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

אז בואו ננסה לסכם את כל מה שקרה כאן. אז, התמונה התחילה להיטען באופן אסינכרוני בסביבת ה-Web APIs ולא ב-Call Stack. לאחר מכן השתמשנו ב-addEventListener כדי לצרף את פונקציית ה-Callback ל-Event של טעינת התמונה. ה-Callback היא בעצם הקוד האסינכרוני שדחינו לעתיד, כי אנחנו רוצים להפעיל אותו רק פעם אחת לאחר שהתמונה תיטען ובינתיים, שאר הקוד המשיך לפעול. כעת ה-addEventListener לא העביר את ה-Callback חזרה ישירות ל-Callback Queue, הוא פשוט רשם את ה-Callback בסביבת ה-Web APIs, וה-Callback המשיכה לחכות בסביבת ה-Web APIs עד ש-Event הטעינה יקרה ורק אז, כשהוא קרה, סביבת ה-Web APIs העבירה את ה-Callback ל-Callback Queue. ואז ה-Callback המתינה ב-Callback Queue כדי שה-Event Loop יקלוט אותה וישים אותה ב-Call Stack. וזה קורה ברגע שה-Callback הייתה ראשונה בתור, וה-Call Stack הייתה ריקה, וזהו בעצם. וכל זה קרה כדי שהתמונה לא תיטען ב-Call Stack, אלא ברקע בצורה לא חוסמת (Non-Blocking).

לסיכום, סביבת ה-Web APIs, ה-Callback Queue וה-Event Loop, כולם ביחד, מאפשרים את זה שקוד אסינכרוני יוכל להתבצע בצורה לא חוסמת אפילו עם Thread אחד בלבד של מנוע ה-JavaScript.

Microtasks Queue

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

זה קורה עם Promise. עם Promises הדברים עובדים מעט שונה. נניח שהנתונים הגיעו סוף סוף.  ה-Callbacks שקשורות ל-Promises, כמו זו שרשמנו עם מתודת ה-then של ה-Promise, למעשה לא יעברו ל-Callback Queue, לחילופין, ל-Callback Functions של Promises יש תור מיוחד עבור עצמן, שנקרא Microtasks Queue.

מה שמיוחד ב-Microtasks Queue הוא שיש לו עדיפות על ה-Callback Queue. ה-Event Loop תבדוק אם יש Callbacks כלשהן ב-Microtasks Queue ואם יש, היא תריץ אותן לפני שהיא תריץ Callbacks נוספות מה-Callback Queue. 

Callback Function של Promises נקראות Microtasks ומכאן השם Microtasks Queue. למעשה יש עוד Microtasks אבל זה לא רלוונטי כעת. אם נחזור לדוגמה שלנו, נכון לעכשיו, למעשה יש לנו Microtask ב-Microtasks Queue, וה-Call Stack ריקה.

ולכן ה-Event Loop תקבל כעת את ה-Microtask ותכניס אותה ל-Call Stack בדיוק כמו שהיא עשתה עם Callback Function מה-Callback Queue, וזה לא משנה אם ה-Callback Queue ריק או לא. וזה היה עובד בדיוק באותו אופן גם אם היו כמה Callbacks ב-Callback Queue. ושוב, זה בגלל של-Microtasks תמיד יש עדיפות. בפועל, זה אומר ש-Microtasks יכולות בעצם לחתוך את התור לפני כל ה-Callbacks הרגילות האחרות.

כעת, אם תגיע עוד Microtask אז גם היא תתבצע לפני כל Callback Function שב-Callback Queue. וזה אומר שהתור של ה-Microtasks יכול למעשה להרעיב את ה-Callback Queue, כי אם נמשיך להוסיף עוד ועוד Microtasks, אז ה-Callback Functions ב-Callback Queue לעולם לא יכולו להתבצע.זה בדרך כלל אף פעם לא יקרה, אבל חשוב לציין את האפשרות הזו בכל מקרה. הוצאה לפועל של קוד אסינכרוני הן עם Callbacks רגילות והן עם Microtasks שמגיעות מ-Promises זה דבר מאוד דומה וההבדל היחיד הוא שהם נכנסים לתורים שונים ושה-Event Loop נותנת עדיפות למיקרו-משימות על פני Callbacks רגילות.

לקריאה נוספת:

המאמר עזר לך? כאן משתפים >>