לדלג לתוכן

לולאת האירועים ב-JS מוסברת - Event Loop

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

שלושת החלקים המרכזיים

  • Call Stack (מחסנית קריאות) - המקום שבו ג'אווהסקריפט מריצה קוד, פונקציה אחר פונקציה, בסדר LIFO (אחרון נכנס, ראשון יוצא).
  • Web APIs - יכולות שהדפדפן מספק, כמו setTimeout, fetch, ואירועי DOM. הן לא רצות בתוך ג'אווהסקריפט עצמה, אלא "בצד" בדפדפן.
  • תור המשימות (Callback Queue) ותור המיקרו-משימות (Microtask Queue) - תורים שבהם ממתינות פונקציות שסיימו לחכות ב-Web APIs, עד שיגיע תורן לרוץ בחזרה במחסנית.

לולאת האירועים היא בעצם המנגנון שכל הזמן בודק - "האם ה-Call Stack ריק? אם כן, יש משהו בתור שממתין? אם כן, תעביר אותו למחסנית ותריץ אותו".

דוגמה שממחישה את הסדר

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");

התוצאה: 1, 3, 2 - למרות שה-setTimeout הוגדר עם השהיה של אפס מילישניות. הסיבה - setTimeout לא רץ מיד בתוך ה-Call Stack. הוא מעביר את הפונקציה שלו ל-Web API של הדפדפן, שמתזמן אותה, ורק כשהזמן עובר (גם אם זה 0 מילישניות) הפונקציה עוברת לתור, וממתינה שם עד שה-Call Stack יתפנה לגמרי. בינתיים, console.log("3") כבר רץ, כי הוא קוד סינכרוני רגיל שממשיך מיד.

מיקרו-משימות מול מאקרו-משימות

יש בעצם שני תורים, לא אחד, וזה בדיוק מה שמבלבל אנשים שכבר מכירים את הרעיון הבסיסי. פרומיסים (ותוצאות של async/await) הולכים לתור המיקרו-משימות, בעוד setTimeout הולך לתור המאקרו-משימות (הרגיל). לולאת האירועים תמיד מרוקנת את כל תור המיקרו-משימות לפני שהיא נוגעת אפילו במשימה אחת בתור המאקרו-משימות.

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

התוצאה: 1, 4, 3, 2. הקוד הסינכרוני (1 ו-4) רץ קודם. אז, גם ש-setTimeout "חיכה" קודם, פרומיס תמיד מקבל עדיפות, כי מיקרו-משימות תמיד מתרוקנות במלואן לפני מעבר למאקרו-משימות.

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

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

function heavyLoop() {
  for (let i = 0; i < 5000000000; i++) {}
}

heavyLoop();
console.log("זה ירוץ רק אחרי שהלולאה מסתיימת, הדף קפא בינתיים");

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

איך זה קשור לריאקט

ריאקט לא משנה את לולאת האירועים - היא עדיין אותה שפה, אותו מנגנון. אבל להבין אותה עוזר להבין למה setState לא מעדכן את המסך "מיד" באותה שורה, למה קריאות רשת בתוך useEffect לא חוסמות את הרינדור, ולמה קוד סינכרוני כבד בתוך קומפוננטה יכול לגרום לממשק "להיתקע" לרגע, בדיוק כמו בכל קוד ג'אווהסקריפט אחר.

איך זוכרים את זה

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

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

לסיכום

ג'אווהסקריפט חד-תהליכית, אבל מצליחה להיראות "מקבילית" בזכות לולאת האירועים, שמעבירה משימות אסינכרוניות דרך Web APIs ותורים, ורק מריצה אותן כשה-Call Stack מתפנה. פרומיסים (מיקרו-משימות) תמיד מקבלים עדיפות על פני setTimeout (מאקרו-משימות). זה נושא שדורש כמה דוגמאות כדי שיתיישב, אבל ברגע שהוא מתיישב, הוא מסביר המון "התנהגויות מוזרות" בקוד.

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

הצטרפו לקהילה בדיסקורד