אופטימיזציה למשימות ארוכות

שמעתם "לא לחסום את ה-thread הראשי" ו"לפצל משימות ארוכות", אבל מה המשמעות של הפעולות האלה?

ההמלצות הנפוצות לשמירה על מהירות של אפליקציות JavaScript מסתכמות בטיפים הבאים:

  • "אל תחסמו את השרשור הראשי".
  • "Break up your long tasks"

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

כדי להבין איך לבצע אופטימיזציה של משימות ב-JavaScript, קודם צריך להבין מהן משימות ואיך הדפדפן מטפל בהן.

מהי משימה?

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

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

משימות שמשויכות ל-JavaScript משפיעות על הביצועים בכמה דרכים:

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

כל הדברים האלה – מלבד web workers וממשקי API דומים – מתרחשים ב-thread הראשי.

מהו השרשור הראשי?

בשרשור הראשי פועלות רוב המשימות בדפדפן, ושם מתבצע ביצוע של כמעט כל קוד ה-JavaScript שאתם כותבים.

ה-thread הראשי יכול לעבד רק משימה אחת בכל פעם. כל משימה שנמשכת יותר מ-50 אלפיות השנייה נחשבת למשימה ארוכה. במשימות שנמשכות יותר מ-50 אלפיות שנייה, הזמן הכולל של המשימה בניכוי 50 אלפיות שנייה נקרא תקופת החסימה של המשימה.

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

משימה ארוכה בפרופיל הביצועים של כלי הפיתוח של Chrome. החלק החוסם של המשימה (יותר מ-50 אלפיות השנייה) מוצג בתבנית של פסים אדומים אלכסוניים.
משימה ארוכה כפי שמופיעה בכלי לניתוחי ביצועים של Chrome. משימות ארוכות מסומנות בסימן משולש אדום בפינה של המשימה, והחלק של המשימה שחוסם את הרשימה ממולא בדפוס של פסים אדומים אלכסוניים.

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

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

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

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

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

עכשיו, אחרי שסיפרנו לכם למה חשוב לפצל משימות, נסביר איך עושים את זה ב-JavaScript.

שיטות לניהול משימות

אחת מהעצות הנפוצות בארכיטקטורת תוכנה היא לפרק את העבודה לפונקציות קטנות יותר:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

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

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

עם זאת, הבעיה האפשרית היא ש-JavaScript לא מפעיל כל אחת מהפונקציות האלה כמשימות נפרדות, כי הן מתבצעות בתוך הפונקציה saveSettings(). כלומר, כל חמש הפונקציות יפעלו כמשימה אחת.

הפונקציה saveSettings כפי שמופיעה בכלי לניתוחי ביצועים של Chrome. הפונקציה ברמה העליונה קוראת לחמש פונקציות אחרות, אבל כל העבודה מתבצעת במשימה ארוכה אחת שוחסמת את השרשור הראשי.
פונקציה אחת, 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();
  }
}

כתוצאה מכך, המשימה שהייתה מונוליתית בעבר מחולקת עכשיו למשימות נפרדות.

אותה פונקציית saveSettings שמוצגת בפרופיל הביצועים של Chrome, רק עם יצירת זמן השהיה. התוצאה היא שהמשימה שהייתה מונוליתית מחולקת עכשיו לחמש משימות נפרדות – אחת לכל פונקציה.
פונקציית saveSettings() מבצעת עכשיו את הפונקציות הצאצא שלה כמשימות נפרדות.

API ייעודי לתזמון

setTimeout היא דרך יעילה לפצל משימות, אבל יכול להיות לה חיסרון: כשמשאירים את הקוד לפעולה במשימת המשך כדי להעביר את הבעלות על הממשק למסוף הראשי, המשימה הזו מתווספת לסוף התור.

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

תמיכה בדפדפנים

  • Chrome: 94.
  • Edge: ‏ 94.
  • Firefox: מאחורי דגל.
  • Safari: לא נתמך.

מקור

ב-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'});
};

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

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

זוהי דוגמה פשוטה לאופן שבו אפשר להשתמש ב-postTask(). אפשר ליצור אובייקטים שונים של TaskController שיכולים לשתף את סדר העדיפויות בין משימות, כולל היכולת לשנות את סדר העדיפויות של מכונות TaskController שונות לפי הצורך.

תשואה מובנית עם המשך באמצעות ה-API של scheduler.yield()

תמיכה בדפדפנים

  • Chrome: ‏ 129.
  • Edge: ‏ 129.
  • Firefox: לא נתמך.
  • Safari: לא נתמך.

מקור

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().

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

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

אין להשתמש ב-isInputPending()

תמיכה בדפדפנים

  • Chrome: 87.
  • Edge: ‏ 87.
  • Firefox: לא נתמך.
  • Safari: לא נתמך.

מקור

ממשק ה-API isInputPending() מאפשר לבדוק אם משתמש ניסה לבצע אינטראקציה עם דף, והוא מחזיר נתונים רק אם יש קלט בהמתנה.

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

עם זאת, מאז ההשקה של ממשק ה-API הזה, הבנתנו את הנושא של הגדלת התשואה הכוללת (yield) התרחבה, במיוחד עם ההשקה של INP. אנחנו לא ממליצים יותר להשתמש ב-API הזה, ובמקום זאת מומלץ להשתמש ב-yield ללא קשר לסטטוס של הקלט, מכמה סיבות:

  • isInputPending() עשוי להחזיר באופן שגוי את הערך false למרות שמשתמש קיים אינטראקציה בנסיבות מסוימות.
  • קלט הוא לא המקרה היחיד שבו משימות צריכות להניב תוצאה. אנימציות ועדכונים רגילים אחרים של ממשק המשתמש יכולים להיות חשובים באותה מידה ליצירת דף אינטרנט רספונסיבי.
  • מאז הושקנו ממשקי API מקיפים יותר ליצירת הכנסות, שמטפלים בבעיות שקשורות ליצירת הכנסות, כמו scheduler.postTask() ו-scheduler.yield().

סיכום

ניהול המשימות הוא אתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת לניהול משימות ולתעדוף שלהן, אלא כמה שיטות שונות. אלה הדברים העיקריים שחשוב להביא בחשבון כשמנהלים משימות:

  • להעביר את הבעלות ל-thread הראשי למשימות קריטיות שמוצגות למשתמש.
  • מתעדפים משימות באמצעות postTask().
  • כדאי לנסות את scheduler.yield().
  • לבסוף, כדאי לבצע כמה שפחות עבודה בפונקציות.

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

תודה מיוחדת ל-Philip Walton על בדיקת התאימות הטכנית של המדריך הזה.

התמונה הממוזערת מגיע מ-Unsplash, באדיבות Amirali Mirhashemian.