אמרו לכם 'אל תחסמו את ה-thread הראשי' ו'צריך לפצל את המשימות הארוכות', אבל מה זה אומר בפועל?
פורסם: 30 בספטמבר 2022, עדכון אחרון: 19 בדצמבר 2024
ההמלצות הנפוצות לשמירה על מהירות של אפליקציות JavaScript הן בדרך כלל:
- "Don't block the main thread" (אל תחסמו את ה-main thread).
- "כדאי לחלק את המשימות הארוכות".
זו עצה מצוינת, אבל איזו עבודה נדרשת כדי ליישם אותה? משלוח של פחות JavaScript הוא דבר טוב, אבל האם זה אומר אוטומטית שממשקי המשתמש יהיו רספונסיביים יותר? אולי, אבל אולי לא.
כדי להבין איך לבצע אופטימיזציה של משימות ב-JavaScript, צריך קודם להבין מהן משימות ואיך הדפדפן מטפל בהן.
מהי משימה?
משימה היא כל חלק נפרד של עבודה שהדפדפן מבצע. העבודה הזו כוללת עיבוד, ניתוח של HTML ו-CSS, הפעלת JavaScript וסוגים אחרים של עבודה שאולי אין לכם שליטה ישירה עליהם. מכל אלה, קוד ה-JavaScript שאתם כותבים הוא אולי המקור הגדול ביותר למשימות.

click
, מוצגת בפרופיל הביצועים של כלי הפיתוח ל-Chrome.
משימות שמשויכות ל-JavaScript משפיעות על הביצועים בכמה דרכים:
- כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מוסיף לתור משימות לניתוח ולקימפול של ה-JavaScript הזה, כדי שאפשר יהיה להריץ אותו בהמשך.
- בזמנים אחרים במהלך מחזור החיים של הדף, המשימות מתווספות לתור כש-JavaScript מבצע עבודה כמו תגובה לאינטראקציות באמצעות handlers של אירועים, אנימציות מבוססות-JavaScript ופעילות ברקע כמו איסוף נתונים של Analytics.
כל הפעולות האלה – למעט web workers וממשקי API דומים – מתבצעות ב-thread הראשי.
מהו ה-thread הראשי?
ה-main thread הוא המקום שבו רוב המשימות מופעלות בדפדפן, ושבו כמעט כל קוד ה-JavaScript שאתם כותבים מופעל.
ה-thread הראשי יכול לעבד רק משימה אחת בכל פעם. כל משימה שנמשכת יותר מ-50 אלפיות השנייה היא משימה ארוכה. אם משך המשימה חורג מ-50 אלפיות השנייה, משך הזמן הכולל של המשימה פחות 50 אלפיות השנייה נקרא תקופת החסימה של המשימה.
הדפדפן חוסם אינטראקציות בזמן שמופעלת משימה בכל אורך, אבל המשתמש לא יכול להבחין בכך כל עוד המשימות לא פועלות יותר מדי זמן. עם זאת, אם משתמש ינסה ליצור אינטראקציה עם דף שיש בו הרבה משימות ארוכות, הוא ירגיש שממשק המשתמש לא מגיב, ואולי אפילו יחשוב שהוא מקולקל אם השרשור הראשי חסום למשך תקופות ארוכות מאוד.

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

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

בחלק העליון של האיור הקודם, מטפל באירועים שהוכנס לתור על ידי אינטראקציה של משתמש נאלץ להמתין למשימה ארוכה אחת לפני שהוא יכול להתחיל, מה שגורם לעיכוב בהתרחשות האינטראקציה. במקרה כזה, יכול להיות שהמשתמש יבחין בהשהיה. בחלק התחתון, ה-event handler יכול להתחיל לפעול מוקדם יותר, והאינטראקציה יכולה להרגיש מיידית.
אחרי שהבנתם למה חשוב לחלק את המשימות, תוכלו ללמוד איך לעשות את זה ב-JavaScript.
שיטות לניהול משימות
עצה נפוצה בארכיטקטורת תוכנה היא לחלק את העבודה לפונקציות קטנות יותר:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
בדוגמה הזו, יש פונקציה בשם saveSettings()
שקוראת לחמש פונקציות כדי לאמת טופס, להציג אנימציה של גלגל מסתובב, לשלוח נתונים לחלק האחורי של האפליקציה, לעדכן את ממשק המשתמש ולשלוח ניתוח נתונים.
מבחינה רעיונית, saveSettings()
מתוכנן היטב. אם אתם צריכים לנפות באגים באחת מהפונקציות האלה, אתם יכולים לעבור על עץ הפרויקט כדי להבין מה כל פונקציה עושה. כשמחלקים את העבודה בצורה כזו, קל יותר לנווט בפרויקטים ולתחזק אותם.
עם זאת, בעיה פוטנציאלית כאן היא ש-JavaScript לא מריץ כל אחת מהפונקציות האלה כמשימה נפרדת, כי הן מופעלות בתוך הפונקציה saveSettings()
. כלומר, כל חמש הפונקציות יפעלו כמשימה אחת.

saveSettings()
שמפעילה חמש פונקציות. העבודה מופעלת כחלק ממשימה מונוליטית ארוכה אחת, שחוסמת כל תגובה חזותית עד שכל חמש הפונקציות מסתיימות.
במקרה הטוב, אפילו אחת מהפונקציות האלה יכולה להוסיף 50 מילישניות או יותר לאורך הכולל של המשימה. במקרה הגרוע, יותר מהמשימות האלה יכולות לפעול הרבה יותר זמן – במיוחד במכשירים עם מגבלות על המשאבים.
במקרה הזה, saveSettings()
מופעלת על ידי קליק של משתמש, ומכיוון שהדפדפן לא יכול להציג תגובה עד שכל הפונקציה מסיימת לפעול, התוצאה של המשימה הארוכה הזו היא ממשק משתמש איטי ולא רספונסיבי, והיא תימדד כמהירות תגובה לאינטראקציה באתר (INP) נמוכה.
דחייה ידנית של ביצוע הקוד
כדי לוודא שמשימות חשובות שמוצגות למשתמשים ותגובות של ממשק המשתמש מתבצעות לפני משימות בעדיפות נמוכה יותר, אפשר להעביר את השליטה לשרשור הראשי על ידי הפסקה קצרה של העבודה כדי לתת לדפדפן הזדמנויות להריץ משימות חשובות יותר.
אחת מהשיטות שבהן מפתחים משתמשים כדי לחלק משימות למשימות קטנות יותר כוללת setTimeout()
. בשיטה הזו, מעבירים את הפונקציה אל setTimeout()
. הפעולה הזו דוחה את הביצוע של פונקציית הקריאה החוזרת למשימה נפרדת, גם אם מציינים זמן קצוב לתפוגה של 0
.
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
הפעולה הזו נקראת החזרת ערך, והיא מתאימה במיוחד לסדרת פונקציות שצריכות לפעול ברצף.
עם זאת, יכול להיות שהקוד לא תמיד יהיה מאורגן בצורה הזו. לדוגמה, יכול להיות שיש לכם כמות גדולה של נתונים שצריך לעבד בלולאה, והמשימה הזו יכולה להימשך זמן רב מאוד אם יש הרבה איטרציות.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
השימוש ב-setTimeout()
כאן בעייתי בגלל ארגונומיה של מפתחים, ואחרי חמש הקפות של setTimeout()
מוטבעים, הדפדפן יתחיל להטיל עיכוב מינימלי של 5 מילישניות לכל setTimeout()
נוסף.
setTimeout
יש גם חסרון נוסף כשמדובר בהעברת השליטה: כשמעבירים את השליטה לשרשור הראשי על ידי דחיית הפעלת הקוד למשימה הבאה באמצעות setTimeout
, המשימה הזו מתווספת לסוף התור. אם יש משימות אחרות שממתינות, הן יפעלו לפני הקוד שנדחה.
API ייעודי להעברת השליטה: scheduler.yield()
scheduler.yield()
הוא API שנועד במיוחד להעברה לשרשור הראשי בדפדפן.
זה לא תחביר ברמת השפה או מבנה מיוחד, אלא פשוט פונקציה שמחזירה Promise
שתקבל ערך במשימה עתידית.scheduler.yield()
כל קוד שמשורשר להרצה אחרי ש-Promise
נפתר (בשרשור .then()
מפורש או אחרי await
שלו בפונקציה אסינכרונית) יורץ במשימה העתידית הזו.
בפועל: מוסיפים await scheduler.yield()
והפונקציה תשהה את ההרצה בנקודה הזו ותעבור ל-thread הראשי. הביצוע של שאר הפונקציה – שנקרא המשך הפונקציה – יתוזמן להפעלה במשימה חדשה של לולאת אירועים. כשהמשימה הזו תתחיל, ההבטחה שהמתינה תמומש, והפונקציה תמשיך לפעול מהנקודה שבה היא נעצרה.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}

saveSettings()
מחולק עכשיו לשתי משימות. כתוצאה מכך, הפריסה והציור יכולים לפעול בין המשימות, ולספק למשתמש תגובה חזותית מהירה יותר, כפי שנמדד על ידי האינטראקציה הקצרה בהרבה של מצביע העכבר.
היתרון האמיתי של scheduler.yield()
על פני גישות אחרות להעברת השליטה הוא שההמשך שלה מקבל עדיפות, כלומר אם מעבירים את השליטה באמצע משימה, ההמשך של המשימה הנוכחית יפעל לפני כל משימה דומה אחרת שתתחיל.
כך נמנע מצב שבו קוד ממקורות אחרים של משימות, כמו משימות מסקריפטים של צד שלישי, יפריע לסדר הביצוע של הקוד.

scheduler.yield()
, ההמשך מתחיל מהמקום שבו הוא הפסיק לפני המעבר למשימות אחרות.
תמיכה בדפדפנים שונים
התג scheduler.yield()
עדיין לא נתמך בכל הדפדפנים, ולכן צריך להשתמש בחלופה.
פתרון אחד הוא להוסיף את scheduler-polyfill
אל ה-build, ואז אפשר להשתמש ישירות ב-scheduler.yield()
. הפוליפיל יטפל בחזרה לפונקציות אחרות של תזמון משימות, כך שהפונקציה תפעל באופן דומה בדפדפנים שונים.
לחלופין, אפשר לכתוב גרסה פחות מתוחכמת בכמה שורות, באמצעות setTimeout
בלבד שעטוף ב-Promise כגיבוי אם scheduler.yield()
לא זמין.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
בדפדפנים שלא תומכים ב-scheduler.yield()
, לא תהיה אפשרות להמשיך את הפעולה בעדיפות גבוהה, אבל הדפדפן עדיין יגיב.
לבסוף, יכולים להיות מקרים שבהם הקוד לא יכול להעביר את השליטה לשרשור הראשי אם לא ניתנת עדיפות להמשך שלו (לדוגמה, דף עמוס ידוע שבו העברת השליטה עלולה לגרום לכך שהעבודה לא תושלם למשך זמן מסוים). במקרה כזה, אפשר להתייחס ל-scheduler.yield()
כאל שיפור הדרגתי: הפעולה תתבצע בדפדפנים שבהם scheduler.yield()
זמין, אחרת היא תימשך.
אפשר לעשות זאת באמצעות זיהוי תכונות וחזרה להמתנה של מיקרו-משימה אחת בשורה אחת נוחה:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
פיצול עבודה ארוכה באמצעות scheduler.yield()
היתרון בשימוש באחת מהשיטות האלה של scheduler.yield()
הוא שאפשר להשתמש בה בכל פונקציית async
.await
לדוגמה, אם יש לכם מערך של משימות להפעלה, שלעתים קרובות מצטברות למשימה ארוכה, אתם יכולים להוסיף yield כדי לפצל את המשימה.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
המשך הפעולה של runJobs()
יקבל עדיפות, אבל עדיין יאפשר להריץ משימות בעדיפות גבוהה יותר, כמו תגובה חזותית לקלט של משתמשים, בלי לחכות לסיום של רשימת המשימות הארוכה.
עם זאת, זה לא שימוש יעיל בהחזרת תנועה. scheduler.yield()
הוא מהיר ויעיל, אבל יש לו תקורה מסוימת. אם חלק מהמשימות ב-jobQueue
קצרות מאוד, יכול להיות שהתקורה תצטבר במהירות ותגרום לכך שיידרש יותר זמן להשהיה ולחידוש של המשימות מאשר לביצוע העבודה בפועל.
אחת הגישות היא להריץ את העבודות בקבוצות, ולבצע yield ביניהן רק אם עבר מספיק זמן מאז ה-yield האחרון. מועד סיום נפוץ הוא 50 מילישניות, כדי למנוע ממשימות להפוך למשימות ארוכות, אבל אפשר לשנות אותו כפשרה בין מהירות התגובה לבין הזמן שנדרש להשלמת תור העבודות.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
התוצאה היא שהעבודות מחולקות כך שהן אף פעם לא יימשכו יותר מדי זמן, אבל ה-runner מעביר את השליטה ל-thread הראשי רק כל 50 מילישניות בערך.

לא מומלץ להשתמש ב-isInputPending()
ממשק ה-API isInputPending()
מאפשר לבדוק אם משתמש ניסה ליצור אינטראקציה עם דף, ומחזיר ערך רק אם יש קלט בהמתנה.
כך, אם אין קלט בהמתנה, JavaScript ממשיך לפעול במקום להניב ולסיים את התור של המשימות. השיפורים האלה יכולים להוביל לשיפורים מרשימים בביצועים, כפי שמפורט בIntent to Ship, באתרים שאולי לא יחזרו לשרשור הראשי בדרך אחרת.
עם זאת, מאז השקת ה-API הזה, ההבנה שלנו לגבי ויתור על תעבורה השתפרה, במיוחד עם ההשקה של מדד INP. אנחנו כבר לא ממליצים להשתמש ב-API הזה, וממליצים להשתמש במקומו ב-API שמאפשר החזרת ערך ללא קשר לשאלה אם יש קלט בהמתנה או לא, מכמה סיבות:
- יכול להיות שהפונקציה
isInputPending()
תחזיר את הערךfalse
באופן שגוי, למרות שהמשתמש ביצע אינטראקציה בנסיבות מסוימות. - הקלט הוא לא המקרה היחיד שבו משימות צריכות להניב תוצאות. אנימציות ועדכונים אחרים בממשק המשתמש יכולים להיות חשובים באותה מידה כדי לספק דף אינטרנט רספונסיבי.
- מאז השקנו ממשקי API מקיפים יותר להחזרת נתונים, שנותנים מענה לבעיות שקשורות להחזרת נתונים, כמו
scheduler.postTask()
ו-scheduler.yield()
.
סיכום
ניהול משימות הוא דבר מאתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת לניהול ולתעדוף משימות, אלא מספר טכניקות שונות. לסיכום, אלה הדברים העיקריים שכדאי להביא בחשבון כשמנהלים משימות:
- העברת השליטה ל-thread הראשי למשימות קריטיות שמשפיעות על המשתמש.
- שימוש ב-
scheduler.yield()
(עם חזרה לדפדפן) כדי להשיג המשכים עם עדיפות גבוהה - לבסוף, כדאי לבצע כמה שפחות פעולות בפונקציות.
מידע נוסף על scheduler.yield()
, על תזמון משימות מפורש יחסי scheduler.postTask()
ועל תעדוף משימות זמין במסמכי ה-API בנושא תזמון משימות לפי סדר עדיפות.
בעזרת אחד או יותר מהכלים האלה, תוכלו לבנות את העבודה באפליקציה כך שהיא תתעדף את הצרכים של המשתמשים, ועדיין תבטיח שהעבודה שהיא פחות קריטית תתבצע. כך נוכל ליצור חוויית משתמש טובה יותר, שתהיה רספונסיבית יותר ומהנה יותר לשימוש.
תודה מיוחדת לפיליפ וולטון (Philip Walton) על הבדיקה הטכנית של המדריך הזה.
התמונה הממוזערת נלקחה מ-Unsplash, באדיבות Amirali Mirhashemian.