בין העצות הנפוצות להרצת אפליקציות JavaScript מהר יותר: "אין לחסום את ה-thread הראשי" ו"לפצל את המשימות הארוכות". בדף הזה מוסבר מה המשמעות של העצה הזו, ולמה חשוב לבצע אופטימיזציה של משימות ב-JavaScript.
מהי משימה?
משימה היא כל עבודה נפרדת שהדפדפן מבצע. זה כולל עיבוד, ניתוח של HTML ו-CSS, הפעלת קוד JavaScript שאתם כותבים ודברים נוספים שייתכן שאין לכם שליטה ישירה עליהם. ה-JavaScript של הדפים שלכם הוא מקור עיקרי למשימות דפדפן.
משימות משפיעות על הביצועים בכמה דרכים. לדוגמה, כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מציג בתור משימות לניתוח ולהדר את קוד ה-JavaScript כדי שניתן יהיה להפעיל אותו. בשלב מאוחר יותר במחזור החיים של הדף, משימות אחרות מתחילות כשחשבון ה-JavaScript פועל, כמו יצירת אינטראקציות באמצעות גורמים מטפלים באירועים, אנימציות שמבוססות על JavaScript ופעילות ברקע כמו איסוף נתונים. כל הפעולות האלה מתרחשות ב-thread הראשי, מלבד עובדי אינטרנט וממשקי API דומים.
מה ה-thread הראשי?
ה-thread הראשי הוא המקום שבו רוב המשימות פועלות בדפדפן, ושבו מתבצעות כמעט כל ה-JavaScript שכותבים.
אפשר לעבד רק משימה אחת בכל פעם ב-thread הראשי. כל משימה שנמשכת יותר מ-50 אלפיות שנייה נחשבת כמשימה ארוכה. אם המשתמש מנסה לקיים אינטראקציה עם הדף במהלך משימה ארוכה או עדכון רינדור, הדפדפן צריך להמתין לטיפול באינטראקציה הזו, דבר שעלול לגרום לזמן אחזור.
כדי למנוע מצב כזה, כדאי לחלק כל משימה ארוכה למשימות קטנות יותר, שכל אחת מהן נמשכת פחות זמן. הפעולה הזו נקראת פיצול של משימות ארוכות.
פיצול משימות מספק לדפדפן יותר הזדמנויות להגיב למשימות בעדיפות גבוהה יותר, כולל אינטראקציות של משתמשים, בין משימות אחרות. כך האינטראקציות יכולות להתרחש הרבה יותר מהר, במצב שבו משתמשים היו שמים לב לאיחור בזמן שהדפדפן המתין להשלמת משימה ארוכה.
אסטרטגיות לניהול משימות
JavaScript מתייחס לכל פונקציה כמשימה יחידה, כי הוא משתמש במודל הרצה עד להשלמה של ביצוע המשימה. כלומר, פונקציה שקוראת למספר פונקציות אחרות, כמו בדוגמה הבאה, חייבת לרוץ עד שכל הפונקציות הנקראות יושלמו, דבר שיגרום להאטת הדפדפן:
function saveSettings () { //This is a long task.
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
אם הקוד מכיל פונקציות שקוראות למספר שיטות, צריך לפצל אותו למספר פונקציות. כך לדפדפן יש יותר הזדמנויות להגיב לאינטראקציה, אלא גם קל יותר לקרוא, לתחזק ולכתוב את הקוד. בקטעים הבאים מפורטות כמה אסטרטגיות לפיצול פונקציות ארוכות וקביעת סדר עדיפויות למשימות שמרכיבות אותן.
דחייה ידנית של ביצוע הקוד
כדי לדחות את הביצוע של משימות מסוימות, אפשר להעביר את הפונקציה הרלוונטית אל
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);
}
האפשרות הזו הכי מתאימה לסדרת פונקציות שצריכות לפעול לפי הסדר. לקוד שמאורגן אחרת נדרשת גישה שונה. הדוגמה הבאה היא פונקציה שמעבדת כמות גדולה של נתונים באמצעות לולאה. ככל שמערך הנתונים גדול יותר, כך התהליך נמשך זמן רב יותר ולא בהכרח יש מקום טוב בלולאה להוסיף setTimeout()
:
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
למרבה המזל, יש כמה ממשקי API אחרים שמאפשרים לדחות את ביצוע הקוד למשימה מאוחר יותר. מומלץ להשתמש ב-postMessage()
כדי לקצר את משך הזמן הקצוב לתפוגה.
אפשר גם לפצל את העבודה באמצעות requestIdleCallback()
, אבל היא מתזמנת משימות בעדיפות הנמוכה ביותר ורק בזמן שהדפדפן ללא פעילות. כלומר, אם ה-thread הראשי עמוס במיוחד, יכול להיות שמשימות שתוזמנו עם requestIdleCallback()
לא יפעלו.
שימוש ב-async
מתוך await
ליצירת נקודות תפוקה
כדי לוודא שמשימות חשובות שגלויות למשתמשים יתרחשו לפני משימות בעדיפות נמוכה יותר, צריך לעבור ל-thread הראשי על ידי הפרעה קצרה לתור המשימות, כדי לתת לדפדפן הזדמנויות להריץ משימות חשובות יותר.
הדרך הברורה ביותר לעשות זאת היא Promise
שמוביל לקריאה אל setTimeout()
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
בפונקציה saveSettings()
אפשר להציג את ה-thread הראשי אחרי כל שלב אם await
את הפונקציה 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:
await yieldToMain();
}
}
נקודה חשובה: לא צריך לוותר אחרי כל קריאה לפונקציה. לדוגמה, אם אתם מריצים שתי פונקציות שגורמות לעדכונים קריטיים בממשק המשתמש, סביר להניח שאתם לא רוצים להריץ ביניהן. אם אפשר, כדאי להריץ את העבודה קודם, ואז כדאי להריץ אותה בין פונקציות שמבצעות רקע או עבודה פחות חיונית שהמשתמש לא רואה.
ממשק API ייעודי של מתזמן
ממשקי ה-API שהוזכרו עד עכשיו יכולים לעזור לחלק משימות לחלקים, אבל יש להם חסרונות משמעותי: כשמעבירים את הקוד ל-thread הראשי על ידי דחיית הקוד להפעלה במשימה מאוחרת יותר, הקוד מתווסף בסוף תור המשימות.
אם יש לכם שליטה על כל הקוד שבדף, תוכלו ליצור מתזמן משלכם כדי לתעדף משימות. עם זאת, סקריפטים של צד שלישי לא ישתמשו במתזמן המשימות, ולכן לא תוכלו לתעדף את העבודה במקרה הזה. תוכלו לפלח אותו או להגדיל את מספר האינטראקציות של המשתמשים.
ה-API של מתזמן המשימות כולל את הפונקציה postTask()
, שמאפשרת תזמון מדויק יותר של משימות, ויכול לעזור לדפדפן לתעדף לעבוד כך שמשימות בעדיפות נמוכה יועברו ל-thread הראשי. ב-postTask()
נעשה שימוש בהבטחות
ומאשר את ההגדרה priority
.
ל-API של postTask()
יש שלוש עדיפויות זמינות:
'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'});
};
כאן, העדיפות של המשימות מתוזמנת כך שמשימות בעדיפות הדפדפן, כמו אינטראקציות של משתמשים, יכולות להתפתח.
אפשר גם ליצור אובייקטים TaskController
שונים שיש להם עדיפויות בין משימות, כולל האפשרות לשנות את סדר העדיפויות של מכונות TaskController
שונות לפי הצורך.
תפוקה מובנית עם המשך שימוש ב-API הבא של scheduler.yield()
נקודה חשובה: להסבר מפורט יותר על scheduler.yield()
, כדאי לקרוא על גרסת המקור לניסיון (מאז שהסתיימה) וכן בהסבר שלה.
אחת מהתוספות שמוצעות ל-Scheduler API היא 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()
הוא המשך, כלומר, אם נותנים באמצע קבוצת משימות, שאר המשימות המתוזמנות ימשיכו באותו סדר אחרי נקודת התפוקה. כך סקריפטים של צד שלישי לא יכולים לשלוט בסדר שבו הקוד שלכם מבוצע.
לשימוש ב-scheduler.postTask()
עם priority: 'user-blocking'
יש גם סיכוי גבוה להמשיך בגלל העדיפות הגבוהה של user-blocking
, כך שאפשר להשתמש בו כחלופה עד ש-scheduler.yield()
יהיה זמין באופן נרחב יותר.
שימוש ב-setTimeout()
(או ב-scheduler.postTask()
עם priority: 'user-visible'
או עם priority
ללא מפורשות) מתזמנ את המשימה בחלק האחורי של התור, וכך מאפשר למשימות אחרות שנמצאות בהמתנה לפני ההמשך.
תפוקה על קלט עם isInputPending()
תמיכה בדפדפן
- 87
- 87
- x
- x
ה-API של isInputPending()
מאפשר לבדוק אם משתמש ניסה לקיים אינטראקציה עם דף ולהפיק ממנו רק אם הקלט נמצא בהמתנה.
הפעולה הזו מאפשרת ל-JavaScript להמשיך אם אין מקורות קלט בהמתנה, במקום להתחיל ומגיעים בחלק האחורי של תור המשימות. התוצאה יכולה להיות שיפורים מרשימים בביצועים, כפי שמפורט בכוונה למשלוח, באתרים שעלולים לא להחזיר אותם ל-thread הראשי בדרך אחרת.
עם זאת, מאז השקת ה-API הזה, השתפרו בהבנת התפוקה שלנו, במיוחד אחרי השקת ה-INP. אנחנו לא ממליצים יותר להשתמש ב-API הזה, ובמקום זאת ממליצים להניב לא משנה אם הקלט בהמתנה או לא. יש כמה סיבות לשינוי בהמלצות:
- במקרים מסוימים שבהם משתמש ניהל אינטראקציה, ה-API עשוי להחזיר את הערך
false
באופן שגוי. - קלט הוא לא המקרה היחיד שבו המשימות אמורות להתקבל. גם לאנימציות ולעדכונים רגילים אחרים בממשק המשתמש יש חשיבות זהה ליצירת דף אינטרנט רספונסיבי.
- מאז הוספנו ממשקי API מקיפים יותר, כמו
scheduler.postTask()
ו-scheduler.yield()
, כדי לטפל בבעיות שגורמות לבעיות.
סיכום
ניהול משימות הוא מאתגר, אבל הפעולה הזו עוזרת לדף להגיב מהר יותר לאינטראקציות של משתמשים. יש מגוון שיטות לניהול ולתעדוף של משימות, בהתאם לתרחיש לדוגמה שלכם. נחזור על הדברים העיקריים שחשוב לקחת בחשבון כשמנהלים משימות:
- העברה ל-thread הראשי עבור משימות קריטיות למשתמשים.
- מומלץ להתנסות ב-
scheduler.yield()
. - לתעדף משימות בעזרת
postTask()
. - לבסוף, צריך לבצע כמה שפחות עבודה בפונקציות.
באמצעות אחד או יותר מהכלים האלה צריכה להיות לכם אפשרות לבנות את העבודה באפליקציה כך שתינתן עדיפות לצרכים של המשתמשים, תוך הקפדה על משימות פחות קריטיות. זה משפר את חוויית המשתמש כי הוא מגיב מהר יותר ומהנה יותר לשימוש.
תודה מיוחדת לפיליפ וולטון על הבדיקה הטכנית שערך המסמך הזה.
התמונה הממוזערת מגיעה מ-UnFlood, באדיבות Amirali Mirhashemian.