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

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

מהי משימה?

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

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

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

מה ה-thread הראשי?

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

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

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

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

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

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

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

אסטרטגיות לניהול משימות

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

function saveSettings () { //This is a long task.
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}
הפונקציה SaveSettings מוצגת בכלי לניתוח הביצועים של Chrome. הפונקציה ברמה העליונה קוראת לחמש פונקציות אחרות, אבל כל העבודה מתבצעת במשימה ארוכה אחת שחוסמת את ה-thread הראשי.
פונקציה יחידה saveSettings() שקוראת לחמש פונקציות. העבודה מתבצעת כחלק ממשימה מונוליתית ארוכה אחת.

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

דחייה ידנית של ביצוע הקוד

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

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

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

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

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

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

תמיכה בדפדפן

  • 94
  • 94
  • x

מקור

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

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

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

אפשר גם ליצור אובייקטים 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.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.