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

אמרת "אין לחסום את השרשור הראשי" ולפרק את המשימות הארוכות, אבל מה המשמעות של לעשות את הדברים האלה?

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

  • "לא לחסום את השרשור הראשי"
  • "מפרידים בין המשימות הארוכות".

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

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

מהי משימה?

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

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

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

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

כל הדברים האלה, חוץ מעובדי אינטרנט וממשקי API דומים, מתרחשים ב-thread הראשי.

מה ה-thread הראשי?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

הפונקציה SaveSettings כפי שהיא מתוארת ב'פרופיל הביצועים של Chrome'. הפונקציה ברמה העליונה מפעילה חמש פונקציות אחרות, אבל כל העבודה מתבצעת במשימה ארוכה אחת שחוסמת את ה-thread הראשי.
פונקציה אחת 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 כדי להגיע ל-thread הראשי. עם זאת, כדי לשפר את הנוחות והקריאות, אפשר להפעיל את setTimeout בתוך Promise ולהעביר את שיטת resolve שלו כקריאה חוזרת.

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

היתרון של הפונקציה yieldToMain() הוא שאפשר await אותה בכל פונקציית async. בהמשך לדוגמה הקודמת, אפשר ליצור מערך של פונקציות שירוצו, ולהעביר אותן ל-thread הראשי אחרי כל הרצה של כל הרצה:

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 היא דרך יעילה לפצל משימות, אבל יש לה חסרונות: כשעוברים ל-thread הראשי על ידי דחיית קוד כדי שירוץ במשימה הבאה, המשימה מתווספת לסוף של התור.

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

תמיכה בדפדפן

  • 94
  • 94
  • x

מקור

ה-API של מתזמן המשימות כולל את הפונקציה postTask() שמאפשרת תזמון מדויק יותר של משימות. זו אחת מהדרכים לעשות זאת כדי לעזור לדפדפן לתעדף עבודה, כך שמשימות עם עדיפות נמוכה יועברו ל-thread הראשי. 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(), הפונקציה מתזמנת את הפונקציות הנפרדות באמצעות postTask(). העבודה הקריטית שמוצגת למשתמשים מתוזמנת בעדיפות גבוהה, ואילו העבודה שהמשתמש לא יודע עליה מתוזמנת לפעול ברקע. כך האינטראקציות של המשתמשים עובדות מהר יותר, כי הן מחולקות וגם לפי סדר עדיפות.

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

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

אחת מההצעות להוספה ל-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, לכן אפשר להשתמש בגישה הזו כחלופה בינתיים.

שימוש ב-setTimeout() (או ב-scheduler.postTask() עם priority: 'user-visibile' או ללא priority מפורש) מתזמן את המשימה בחלק האחורי של התור, וכך מאפשר למשימות אחרות בהמתנה לרוץ לפני ההמשך.

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

תמיכה בדפדפן

  • 87
  • 87
  • x
  • x

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

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

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

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

סיכום

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

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

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

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

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