שמעתם "לא לחסום את ה-thread הראשי" ו"לפצל משימות ארוכות", אבל מה המשמעות של הפעולות האלה?
ההמלצות הנפוצות לשמירה על מהירות של אפליקציות JavaScript מסתכמות בטיפים הבאים:
- "אל תחסמו את השרשור הראשי".
- "Break up your long tasks"
זו עצה נהדרת, אבל מה העבודה שצריך לעשות כדי להשתמש בה? משלוח פחות JavaScript הוא דבר טוב, אבל האם זה מבטא באופן אוטומטי ממשקי משתמש עם תגובה מהירה יותר? אולי, אבל אולי גם לא.
כדי להבין איך לבצע אופטימיזציה של משימות ב-JavaScript, קודם צריך להבין מהן משימות ואיך הדפדפן מטפל בהן.
מהי משימה?
משימה היא כל פעולה נפרדת שהדפדפן מבצע. העבודה הזו כוללת רינדור, ניתוח HTML ו-CSS, הפעלת JavaScript וסוגים אחרים של עבודה שאולי אין לכם עליה שליטה ישירה. מכל הגורמים האלה, קוד ה-JavaScript שאתם כותבים הוא אולי המקור הגדול ביותר למשימות.
משימות שמשויכות ל-JavaScript משפיעות על הביצועים בכמה דרכים:
- כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מכניס משימות לתור כדי לנתח ולקמפל את ה-JavaScript הזה, כדי שניתן יהיה להריץ אותו מאוחר יותר.
- בזמנים אחרים במהלך החיים של הדף, משימות נכנסות לתור כש-JavaScript מבצע משימות כמו יצירת אינטראקציות באמצעות פונקציות טיפול באירועים, אנימציות שמבוססות על JavaScript ופעילות ברקע כמו איסוף נתוני ניתוח.
כל הדברים האלה – מלבד web workers וממשקי API דומים – מתרחשים ב-thread הראשי.
מהו השרשור הראשי?
בשרשור הראשי פועלות רוב המשימות בדפדפן, ושם מתבצע ביצוע של כמעט כל קוד ה-JavaScript שאתם כותבים.
ה-thread הראשי יכול לעבד רק משימה אחת בכל פעם. כל משימה שנמשכת יותר מ-50 אלפיות השנייה נחשבת למשימה ארוכה. במשימות שנמשכות יותר מ-50 אלפיות שנייה, הזמן הכולל של המשימה בניכוי 50 אלפיות שנייה נקרא תקופת החסימה של המשימה.
הדפדפן חוסם אינטראקציות בזמן שהמשימה פועלת, אבל המשתמש לא מרגיש את זה כל עוד המשימות לא פועלות זמן רב מדי. עם זאת, כשמשתמש מנסה לבצע פעולה בדף שיש בו הרבה משימות ארוכות, ממשק המשתמש ייראה לא תגובה, ואולי אפילו לא יפעל אם השרשור הראשי חסום למשך פרקי זמן ארוכים מאוד.
כדי למנוע את החסימה של ה-thread הראשי למשך זמן רב מדי, אפשר לפצל משימה ארוכה לכמה משימות קצרות יותר.
זה חשוב כי כשמשימות מחולקות, הדפדפן יכול להגיב לעבודה בעדיפות גבוהה יותר הרבה יותר מהר – כולל אינטראקציות של משתמשים. לאחר מכן, המערכת משלימה את שאר המשימות כדי לוודא שהעבודה שהוסיפה לתור בהתחלה תתבצע.
בחלק העליון של התרשים הקודם, טיפול באירוע שהצטבר בתור בעקבות אינטראקציה של משתמש נאלץ להמתין למשימה ארוכה אחת לפני שהוא יכול להתחיל. כתוצאה מכך, האינטראקציה מתעכבת. בתרחיש כזה, יכול להיות שהמשתמש הבחין בזמן אחזור ארוך. בתחתית התמונה, טיפול האירוע יכול להתחיל לפעול מוקדם יותר, והאינטראקציה עשויה להיראות מיידית.
עכשיו, אחרי שסיפרנו לכם למה חשוב לפצל משימות, נסביר איך עושים את זה ב-JavaScript.
שיטות לניהול משימות
אחת מהעצות הנפוצות בארכיטקטורת תוכנה היא לפרק את העבודה לפונקציות קטנות יותר:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
בדוגמה הזו, יש פונקציה בשם saveSettings()
שמפעילה חמש פונקציות כדי לאמת טופס, להציג גלגל מסתובב, לשלוח נתונים לקצה העורפי של האפליקציה, לעדכן את ממשק המשתמש ולשלוח ניתוח נתונים.
מבחינה רעיונית, הארכיטקטורה של saveSettings()
טובה. אם אתם צריכים לנפות באגים באחת מהפונקציות האלה, אתם יכולים לעבור על עץ הפרויקט כדי להבין מה כל פונקציה עושה. כשמחלקים את העבודה כך, קל יותר לנווט בפרויקטים ולנהל אותם.
עם זאת, הבעיה האפשרית היא ש-JavaScript לא מפעיל כל אחת מהפונקציות האלה כמשימות נפרדות, כי הן מתבצעות בתוך הפונקציה saveSettings()
. כלומר, כל חמש הפונקציות יפעלו כמשימה אחת.
בתרחיש הטוב ביותר, גם רק אחת מהפונקציות האלה יכולה להוסיף 50 אלפיות השנייה או יותר למשך הזמן הכולל של המשימה. במקרה הגרוע ביותר, יותר מהמשימות האלה יכולות לפעול הרבה יותר זמן – במיוחד במכשירים עם מחסור במשאבים.
דחיית ביצוע הקוד באופן ידני
אחת השיטות שבהן מפתחים משתמשים כדי לפצל משימות למשימות קטנות יותר היא setTimeout()
. בשיטה הזו, מעבירים את הפונקציה אל setTimeout()
. הפעולה הזו דוחה את הביצוע של פונקציית ה-callback למשימה נפרדת, גם אם מציינים זמן קצוב לתפוגה של 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()
הוא לא הכלי המתאים למשימה – לפחות לא כשמשתמשים בו בצורה הזו.
שימוש ב-async
/await
כדי ליצור נקודות תשואה
כדי לוודא שמשימות חשובות שמתבצעות מול המשתמש יתבצעו לפני משימות עם עדיפות נמוכה יותר, אפשר להעביר את העדיפות לשרשור הראשי על ידי השהיה קצרה של תור המשימות כדי לתת לדפדפן הזדמנויות להריץ משימות חשובות יותר.
כפי שהוסבר קודם, אפשר להשתמש ב-setTimeout
כדי להעביר את הבעלות לשרשור הראשי. עם זאת, למען הנוחות והקריאוּת, אפשר להפעיל את setTimeout
בתוך Promise
ולהעביר את השיטה resolve
שלו כקריאה החוזרת.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
היתרון של הפונקציה yieldToMain()
הוא שאפשר await
אותה בכל פונקציית async
. בהמשך לדוגמה הקודמת, אפשר ליצור מערך של פונקציות להרצה ולהעביר את השליטה לשרשור הראשי אחרי שכל אחת מהן מופעלת:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
כתוצאה מכך, המשימה שהייתה מונוליתית בעבר מחולקת עכשיו למשימות נפרדות.
API ייעודי לתזמון
setTimeout
היא דרך יעילה לפצל משימות, אבל יכול להיות לה חיסרון: כשמשאירים את הקוד לפעולה במשימת המשך כדי להעביר את הבעלות על הממשק למסוף הראשי, המשימה הזו מתווספת לסוף התור.
אם אתם שולטים בכל הקוד בדף, תוכלו ליצור מתזמן משלכם עם אפשרות לתעדף משימות – אבל סקריפטים של צד שלישי לא ישתמשו במתזמן שלכם. במילים אחרות, אי אפשר לתעדף משימות בסביבות כאלה. אפשר רק לפצל אותו או להעביר אותו באופן מפורש לאינטראקציות של משתמשים.
ב-Scheduler API יש את הפונקציה postTask()
שמאפשרת תזמון מדויק יותר של משימות, והיא אחת הדרכים לעזור לדפדפן לקבוע סדרי עדיפויות לעבודה, כך שמשימות בעדיפות נמוכה יועברו לשרשור הראשי. postTask()
משתמש בהבטחות ומקבל אחת משלוש ההגדרות של priority
:
'background'
למשימות עם העדיפות הנמוכה ביותר.'user-visible'
למשימות עם עדיפות בינונית. זוהי ברירת המחדל אם לא מוגדרpriority
.'user-blocking'
למשימות קריטיות שצריך להריץ בעדיפות גבוהה.
בדוגמה הבאה נעשה שימוש ב-API של postTask()
כדי להריץ שלוש משימות בעדיפות הגבוהה ביותר, ושני המשימות הנותרות בעדיפות הנמוכה ביותר.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
כאן, תזמון המשימות מתבצע כך שמשימות שקיבלו קדימות בדפדפן – כמו אינטראקציות של משתמשים – יכולות להתבצע ביניהם לפי הצורך.
זוהי דוגמה פשוטה לאופן שבו אפשר להשתמש ב-postTask()
. אפשר ליצור אובייקטים שונים של TaskController
שיכולים לשתף את סדר העדיפויות בין משימות, כולל היכולת לשנות את סדר העדיפויות של מכונות TaskController
שונות לפי הצורך.
תשואה מובנית עם המשך באמצעות ה-API של scheduler.yield()
scheduler.yield()
הוא ממשק API שמיועד במיוחד להעברת הבעלות ל-thread הראשי בדפדפן. השימוש בה דומה לשימוש בפונקציה yieldToMain()
שמוצגת קודם במדריך:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
הקוד הזה מוכר בעיקר, אבל במקום להשתמש ב-yieldToMain()
, הוא משתמש ב-await scheduler.yield()
.
היתרון של scheduler.yield()
הוא המשך העבודה. כלומר, אם תשתמשו ב-yield באמצע קבוצה של משימות, שאר המשימות המתוזמנות ימשיכו באותו סדר אחרי נקודת ה-yield. כך מונעים מקוד של סקריפטים של צד שלישי להפריע לסדר הביצוע של הקוד.
אין להשתמש ב-isInputPending()
ממשק ה-API isInputPending()
מאפשר לבדוק אם משתמש ניסה לבצע אינטראקציה עם דף, והוא מחזיר נתונים רק אם יש קלט בהמתנה.
כך JavaScript יכול להמשיך אם אין קלט בהמתנה, במקום להעביר את השליטה ולהיכנס לתור המשימות. הפעולה הזו יכולה להוביל לשיפורים מרשימים בביצועים, כפי שמפורט בכוונת השקה, באתרים שאחרת לא היו חוזרים לשרשור הראשי.
עם זאת, מאז ההשקה של ממשק ה-API הזה, הבנתנו את הנושא של הגדלת התשואה הכוללת (yield) התרחבה, במיוחד עם ההשקה של INP. אנחנו לא ממליצים יותר להשתמש ב-API הזה, ובמקום זאת מומלץ להשתמש ב-yield ללא קשר לסטטוס של הקלט, מכמה סיבות:
isInputPending()
עשוי להחזיר באופן שגוי את הערךfalse
למרות שמשתמש קיים אינטראקציה בנסיבות מסוימות.- קלט הוא לא המקרה היחיד שבו משימות צריכות להניב תוצאה. אנימציות ועדכונים רגילים אחרים של ממשק המשתמש יכולים להיות חשובים באותה מידה ליצירת דף אינטרנט רספונסיבי.
- מאז הושקנו ממשקי API מקיפים יותר ליצירת הכנסות, שמטפלים בבעיות שקשורות ליצירת הכנסות, כמו
scheduler.postTask()
ו-scheduler.yield()
.
סיכום
ניהול המשימות הוא אתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת לניהול משימות ולתעדוף שלהן, אלא כמה שיטות שונות. אלה הדברים העיקריים שחשוב להביא בחשבון כשמנהלים משימות:
- להעביר את הבעלות ל-thread הראשי למשימות קריטיות שמוצגות למשתמש.
- מתעדפים משימות באמצעות
postTask()
. - כדאי לנסות את
scheduler.yield()
. - לבסוף, כדאי לבצע כמה שפחות עבודה בפונקציות.
בעזרת אחד או יותר מהכלים האלה, תוכלו לבנות את העבודה באפליקציה כך שתתעדף את הצרכים של המשתמשים, תוך הבטחה שהעבודה פחות קריטית עדיין תתבצע. כך נוכל ליצור חוויית משתמש טובה יותר, עם תגובה מהירה יותר ושימוש מהנה יותר.
תודה מיוחדת ל-Philip Walton על בדיקת התאימות הטכנית של המדריך הזה.
התמונה הממוזערת מגיע מ-Unsplash, באדיבות Amirali Mirhashemian.