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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

סיכום

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

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

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

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

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