שמעתם "לא לחסום את ה-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()
. הפעולה הזו תדחה את ביצוע הקריאה החוזרת למשימה נפרדת, גם אם צוין זמן קצוב לתפוגה של 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 יכול להמשיך אם אין קלט בהמתנה, במקום להעביר את השליטה ולהיכנס לתור המשימות. התוצאה יכולה להיות שיפורי ביצועים מרשימים, כפי שמפורט בכוונת משלוח, באתרים שייתכן שלא יניבו חזרה ל-thread הראשי.
עם זאת, מאז ההשקה של ה-API הזה, ההבנה שלנו לגבי התפוקה גדלה, במיוחד עם השקת ה-INP. אנחנו לא ממליצים יותר להשתמש ב-API הזה, ובמקום זאת מומלץ להשתמש ב-yield ללא קשר לסטטוס של הקלט, מכמה סיבות:
- יכול להיות ש-
isInputPending()
יחזיר באופן שגוי את הערךfalse
למרות שמשתמש קיים אינטראקציה בנסיבות מסוימות. - קלט הוא לא המקרה היחיד שבו משימות צריכות להניב תוצאה. אנימציות ועדכונים קבועים אחרים של ממשק המשתמש יכולים להיות חשובים באותה מידה כדי לספק דף אינטרנט רספונסיבי.
- מאז הוספנו ממשקי API מקיפים יותר ליצירת הכנסות, שמטפלים בבעיות שקשורות ליצירת הכנסות, כמו
scheduler.postTask()
ו-scheduler.yield()
.
סיכום
ניהול המשימות הוא אתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת בלבד לניהול משימות ולסידור שלהן לפי סדר עדיפויות, אלא כמה שיטות שונות. אלה הדברים העיקריים שחשוב להביא בחשבון כשמנהלים משימות:
- להעביר את הבעלות ל-thread הראשי עבור משימות קריטיות שמוצגות למשתמש.
- מתעדפים משימות באמצעות
postTask()
. - כדאי להתנסות ב-
scheduler.yield()
. - לסיום, הקפידו לבצע כמה שפחות עבודה בפונקציות.
בעזרת אחד או יותר מהכלים האלה תוכלו לבנות את העבודה באפליקציה שלכם כך שתתעדף את צורכי המשתמשים, ובמקביל להבטיח שתבוצע עבודה פחות חיונית. כך נוכל ליצור חוויית משתמש טובה יותר, עם תגובה מהירה יותר ושימוש מהנה יותר.
תודה מיוחדת לPhilip Walton על בדיקת התאימות הטכנית של המדריך הזה.
התמונה הממוזערת מגיעה מ-Unbounce, באדיבות Amirali Mirhashemian.