בטח אמרו לכם "אל תחסמו את ה-thread הראשי" ו"פצלו את המשימות שנמשכות הרבה זמן", אבל מה זה אומר בפועל?
תאריך פרסום: 30 בספטמבר 2022, תאריך עדכון אחרון: 19 בדצמבר 2024
העצות הנפוצות לשמירה על מהירות האפליקציות של JavaScript מסתכמות בדרך כלל בעצות הבאות:
- "אל תחסמו את ה-thread הראשי".
- "פצלו את המשימות שנמשכות הרבה זמן".
זו עצה מצוינת, אבל מה צריך לעשות כדי ליישם אותה? העברת פחות JavaScript היא דבר טוב, אבל האם זה אומר באופן אוטומטי שממשקי המשתמש יהיו רספונסיביים יותר? אולי כן, אבל אולי לא.
כדי להבין איך לבצע אופטימיזציה של משימות ב-JavaScript, קודם צריך לדעת מהן משימות ואיך הדפדפן מטפל בהן.
מהי משימה?
משימה היא כל פעולה נפרדת שהדפדפן מבצע. העבודה הזו כוללת עיבוד, ניתוח של HTML ו-CSS, הפעלת JavaScript וסוגים אחרים של עבודה שאולי אין לכם שליטה ישירה עליהם. מכל אלה, קוד ה-JavaScript שאתם כותבים הוא כנראה המקור הגדול ביותר למשימות.
click, שמוצגת בכלי ליצירת פרופיל ביצועים של כלי הפיתוח ל-Chrome.
משימות שקשורות ל-JavaScript משפיעות על הביצועים בכמה דרכים:
- כשדפדפן מוריד קובץ JavaScript במהלך ההפעלה, הוא מוסיף לתור משימות לניתוח ולקומפילציה של קובץ ה-JavaScript הזה כדי שיוכל להפעיל אותו מאוחר יותר.
- בזמנים אחרים במהלך חיי הדף, משימות נכנסות לתור כש-JavaScript מבצע פעולות כמו תגובה לאינטראקציות באמצעות גורמים מטפלים באירועים, אנימציות מבוססות-JavaScript ופעילות ברקע כמו איסוף נתוני ניתוח.
כל הדברים האלה – מלבד web workers וממשקי API דומים – מתרחשים ב-thread הראשי.
מהו ה-thread הראשי?
השרשור הראשי הוא המקום שבו רוב המשימות פועלות בדפדפן, וכמעט כל קוד ה-JavaScript שאתם כותבים מופעל.
ה-thread הראשי יכול לעבד רק משימה אחת בכל פעם. כל משימה שנמשכת יותר מ-50 אלפיות השנייה היא משימה שנמשכת הרבה זמן. במשימות שנמשכות יותר מ-50 אלפיות השנייה, משך הזמן הכולל של המשימה פחות 50 אלפיות השנייה נקרא תקופת החסימה של המשימה.
הדפדפן חוסם אינטראקציות בזמן שמשימה בכל אורך פועלת, אבל המשתמש לא מבחין בכך כל עוד המשימות לא פועלות במשך זמן רב מדי. עם זאת, כשמשתמש מנסה לבצע פעולה בדף שיש בו הרבה משימות שנמשכות הרבה זמן, ממשק המשתמש לא יגיב, ואולי אפילו ייראה מקולקל אם ה-thread הראשי חסום לפרקי זמן ארוכים מאוד.
כדי למנוע חסימה של ה-thread הראשי למשך זמן רב מדי, אפשר לפצל משימה ארוכה לכמה משימות קטנות יותר.
זה חשוב כי כשהמשימות מפוצלות, הדפדפן יכול להגיב מהר יותר לעבודה בעדיפות גבוהה יותר, כולל אינטראקציות של משתמשים. לאחר מכן, המשימות שנותרו פועלות עד לסיום, כדי להבטיח שהעבודה שהוספתם לתור בהתחלה תתבצע.
בחלק העליון של האיור הקודם, גורם מטפל באירועים שהתווסף לתור על ידי אינטראקציה של משתמש היה צריך להמתין למשימה אחת שנמשכת הרבה זמן לפני שהוא יכול להתחיל. זה מעכב את האינטראקציה. בתרחיש הזה, יכול להיות שהמשתמש הבחין בפיגור. בתרשים התחתון, הגורם המטפל באירועים יכול להתחיל לפעול מוקדם יותר, והאינטראקציה הייתה יכולה להרגיש מיידית.
עכשיו, אחרי שהבנתם למה חשוב לפצל משימות, אתם יכולים ללמוד איך לעשות את זה ב-JavaScript.
אסטרטגיות לניהול משימות
עצה נפוצה בארכיטקטורת תוכנה היא לפצל את העבודה לפונקציות קטנות יותר:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
בדוגמה הזו, יש פונקציה בשם saveSettings() שקוראת לחמש פונקציות כדי לאמת טופס, להציג ספינר, לשלוח נתונים לקצה העורפי של האפליקציה, לעדכן את ממשק המשתמש ולשלוח ניתוח נתונים.
מבחינה רעיונית, saveSettings() בנוי היטב. אם אתם צריכים לנפות באגים באחת מהפונקציות האלה, אתם יכולים לעבור על עץ הפרויקט כדי להבין מה כל פונקציה עושה. כשמחלקים את העבודה בצורה כזו, קל יותר לנווט בפרויקטים ולתחזק אותם.
אבל יש כאן בעיה פוטנציאלית: JavaScript לא מריץ כל אחת מהפונקציות האלה כמשימות נפרדות, כי הן מופעלות בתוך הפונקציה saveSettings(). זה אומר שכל חמש הפונקציות יפעלו כמשימה אחת.
saveSettings() שקוראת לחמש פונקציות. העבודה מתבצעת כחלק ממשימה מונוליתית ארוכה אחת, שחוסמת כל תגובה חזותית עד שכל חמש הפונקציות מסתיימות.
במקרה הטוב, אפילו אחת מהפונקציות האלה יכולה להוסיף 50 אלפיות השנייה או יותר למשך הכולל של המשימה. במקרה הגרוע ביותר, יותר משימות כאלה יכולות לפעול הרבה יותר זמן, במיוחד במכשירים עם משאבים מוגבלים.
במקרה הזה, הפונקציה saveSettings() מופעלת על ידי קליק של משתמש, ומכיוון שהדפדפן לא יכול להציג תגובה עד שהפונקציה כולה מסיימת לפעול, התוצאה של המשימה הארוכה הזו היא ממשק משתמש איטי ולא מגיב, והיא תימדד כמהירות תגובה לאינטראקציה באתר (INP) נמוכה.
דחייה ידנית של הרצת הקוד
כדי לוודא שמשימות חשובות שגלויות למשתמשים ותגובות של ממשק המשתמש מתרחשות לפני משימות בעדיפות נמוכה יותר, אתם יכולים להעביר את השליטה לשרשור הראשי על ידי הפסקה קצרה של העבודה כדי לתת לדפדפן הזדמנויות להריץ משימות חשובות יותר.
אחת השיטות שבהן מפתחים משתמשים כדי לפצל משימות למשימות קטנות יותר כוללת את 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() בתצוגת עץ, הדפדפן יתחיל להטיל עיכוב של 5 אלפיות השנייה לפחות לכל setTimeout() נוסף.
ל-setTimeout יש גם חיסרון נוסף כשמדובר בהעברת השליטה: כשמעבירים את השליטה ל-thread הראשי על ידי דחיית הקוד להרצה במשימה הבאה באמצעות setTimeout, המשימה הזו מתווספת לסוף התור. אם יש משימות אחרות שממתינות, הן יופעלו לפני הקוד המושהה.
API ייעודי להשהיה: scheduler.yield()
scheduler.yield() הוא API שתוכנן במיוחד כדי להעביר את השליטה ל-main thread בדפדפן.
זה לא תחביר ברמת השפה או מבנה מיוחד. scheduler.yield() היא פונקציה שמחזירה Promise שיטופל במשימה עתידית. כל קוד שמשורשר להפעלה אחרי שה-Promise נפתר (בשרשרת .then() מפורשת או אחרי await בפונקציה אסינכרונית) יופעל במשימה העתידית הזו.
בפועל: מוסיפים await scheduler.yield() והפונקציה תשהה את הביצוע בנקודה הזו ותעביר את השליטה ל-thread הראשי. הביצוע של שאר הפונקציה – שנקרא המשך הפונקציה – יתוזמן לפעול במשימה חדשה של לולאת אירוע. כשהמשימה הזו תתחיל, ה-promise הממתין יתממש, והפונקציה תמשיך לפעול מהמקום שבו היא הפסיקה.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
saveSettings() מפוצלת עכשיו לשתי משימות. כתוצאה מכך, הפריסה והצביעה יכולות לפעול בין המשימות, וכך המשתמש מקבל תגובה חזותית מהירה יותר, כפי שנמדד על ידי האינטראקציה הקצרה יותר של המצביע.
אבל היתרון האמיתי של scheduler.yield() על פני גישות אחרות של השהיה הוא שההמשך שלו מקבל עדיפות. כלומר, אם אתם משהים באמצע משימה, ההמשך של המשימה הנוכחית יפעל לפני שיתחילו משימות דומות אחרות.
כך קוד ממקורות אחרים של משימות לא יפריע לסדר ההרצה של הקוד שלכם, למשל משימות מסקריפטים של צד שלישי.
scheduler.yield(), ההמשך מתחיל מהמקום שבו הוא הפסיק לפני שעבר למשימות אחרות.
תמיכה בדפדפנים שונים
התכונה scheduler.yield() עדיין לא נתמכת בכל הדפדפנים, ולכן צריך להגדיר חלופה.
פתרון אחד הוא להוסיף את scheduler-polyfill ל-build, ואז אפשר להשתמש ב-scheduler.yield() ישירות. ה-polyfill יטפל בחזרה לפונקציות אחרות של תזמון משימות, כך שהוא יפעל באופן דומה בדפדפנים שונים.
לחלופין, אפשר לכתוב גרסה פחות מתוחכמת בכמה שורות, באמצעות setTimeout בלבד שעטוף ב-Promise כחלופה אם scheduler.yield() לא זמין.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
דפדפנים שלא תומכים ב-scheduler.yield() לא יקבלו את ההמשך בעדיפות גבוהה, אבל הם עדיין יניבו תוצאות כדי שהדפדפן יישאר רספונסיבי.
לבסוף, יכולים להיות מקרים שבהם הקוד לא יכול להניב ל-main thread אם ההמשך שלו לא מקבל עדיפות (לדוגמה, דף שידוע שהוא עמוס, שבו הניבה עלולה לגרום לכך שהעבודה לא תושלם במשך זמן מה). במקרה כזה, אפשר להתייחס ל-scheduler.yield() כסוג של שיפור הדרגתי (progressive enhancement): להניב בדפדפנים שבהם scheduler.yield() זמין, אחרת להמשיך.
אפשר לעשות את זה גם באמצעות זיהוי תכונות וגם באמצעות חזרה להמתנה למיקרו-משימה אחת בשורה אחת שימושית:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
פיצול של עבודה ארוכה באמצעות scheduler.yield()
היתרון בשימוש בכל אחת מהשיטות האלה לשימוש ב-scheduler.yield() הוא שאפשר await אותו בכל פונקציה של async.
לדוגמה, אם יש לכם מערך של משימות להפעלה שלעיתים קרובות מצטברות למשימה שנמשכת הרבה זמן, אתם יכולים להוסיף נקודות השהיה כדי לפצל את המשימה.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
המערכת תתעדף את המשך הפעולה של runJobs(), אבל עדיין תאפשר להריץ עבודה בעדיפות גבוהה יותר, כמו תגובה חזותית לקלט של המשתמש, בלי לחכות לסיום של רשימת המשימות, שיכולה להיות ארוכה.
עם זאת, זה לא שימוש יעיל ב-yielding. scheduler.yield() היא שיטה מהירה ויעילה, אבל יש לה כמה עלויות תקורה. אם חלק מהמשימות ב-jobQueue קצרות מאוד, התקורה יכולה להצטבר במהירות ליותר זמן שמוקדש להשהיה ולחידוש מאשר לביצוע העבודה בפועל.
גישה אחת היא לאסוף את המשימות, ולוותר ביניהן רק אם עבר מספיק זמן מאז הוויתור האחרון. מועד אחרון נפוץ הוא 50 אלפיות השנייה כדי לנסות למנוע ממשימות להפוך למשימות שנמשכות הרבה זמן, אבל אפשר לשנות אותו כפשרה בין מהירות התגובה לבין הזמן להשלמת תור המשימות.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
כתוצאה מכך, המשימות מפוצלות כך שהן אף פעם לא נמשכות יותר מדי זמן, אבל ה-runner מניב תוצאות ל-thread הראשי רק כל 50 אלפיות השנייה בערך.
לא להשתמש ב-isInputPending()
isInputPending() API מאפשר לבדוק אם משתמש ניסה לבצע אינטראקציה עם דף, ולהניב תוצאה רק אם יש קלט בהמתנה.
כך, אם אין קלט בהמתנה, JavaScript יכול להמשיך במקום להניב תוצאה ולהגיע לסוף תור המשימות. הפעולה הזו יכולה להוביל לשיפורים מרשימים בביצועים, כפי שמפורט ב-Intent to Ship, באתרים שאחרת לא היו מחזירים את השליטה ל-main thread.
עם זאת, מאז ההשקה של ה-API הזה, ההבנה שלנו לגבי ויתור על השליטה גדלה, במיוחד עם השקת INP. אנחנו כבר לא ממליצים להשתמש ב-API הזה, ובמקום זאת ממליצים להניב ללא קשר לשאלה אם הקלט ממתין או לא מכמה סיבות:
- יכול להיות שהפונקציה
isInputPending()תחזיר בטעות את הערךfalseלמרות שהמשתמש ביצע אינטראקציה בנסיבות מסוימות. - קלט הוא לא המקרה היחיד שבו משימות צריכות להניב. אנימציות ועדכונים קבועים אחרים של ממשק המשתמש יכולים להיות חשובים באותה מידה כדי לספק דף אינטרנט רספונסיבי.
- מאז נוספו ממשקי API מקיפים יותר לוויתור על משאבים, כמו
scheduler.postTask()ו-scheduler.yield(), שנותנים מענה לבעיות שקשורות לוויתור על משאבים.
סיכום
ניהול המשימות הוא מאתגר, אבל הוא מבטיח שהדף יגיב מהר יותר לאינטראקציות של המשתמשים. אין עצה אחת לניהול משימות וקביעת סדרי עדיפויות, אלא כמה שיטות שונות. לסיכום, אלה הדברים העיקריים שצריך להביא בחשבון כשמנהלים משימות:
- העבירו את המשימות הקריטיות שגלויות למשתמשים ל-thread הראשי.
- שימוש ב-
scheduler.yield()(עם חלופה בדפדפנים שונים) כדי להניב באופן ארגונומי ולקבל המשכים בעדיפות גבוהה - לסיום, הפונקציות צריכות לבצע כמה שפחות פעולות.
מידע נוסף על scheduler.yield(), על scheduler.postTask() (הפונקציה המקבילה לתזמון משימות מפורש) ועל תעדוף משימות זמין במסמכי התיעוד של Prioritized Task Scheduling API.
בעזרת אחד או יותר מהכלים האלה, תוכלו לבנות את העבודה באפליקציה כך שהיא תתעדף את צורכי המשתמש, ועדיין תבטיח שהעבודה הפחות קריטית תתבצע. כך חוויית המשתמש תהיה טובה יותר, מהירה יותר ומהנה יותר.
תודה מיוחדת לפיליפ וולטון על הבדיקה הטכנית של המדריך הזה.