Core Web Vitals: מה INP באמת מודד, ואיך scheduler.yield() מציל את התהליך הראשי
Core Web Vitals: מה INP באמת מודד, ואיך scheduler.yield() מציל את התהליך הראשי
במאמר על מיקרו-אינטראקציות הראיתי למה transform ו-opacity מונפשים בצורה חלקה גם בטלפון חלש – כי הדפדפן מטפל בהם אך ורק בשלב הקומפוזיציה, בתהליך GPU נפרד, בלי לגעת ב-layout. זה עדיין נכון, אבל מאחורי זה מסתתרת הנחה ששווה עכשיו לחשוף: כדי שהאנימציה הזו בכלל תתחיל, הדפדפן חייב קודם לטפל באירוע הלחיצה על התהליך הראשי – אותו תהליך בדיוק שבו מתבצע כל ה-JavaScript שלכם. אם ברגע הלחיצה התהליך הראשי עסוק בביצוע משימה סינכרונית ארוכה – סינון מערך גדול, פענוח תשובת API, רינדור של עץ רכיבים מורכב – אירוע הלחיצה לא יטופל אפילו רגע לפני שהמשימה הזו מסתיימת. האנימציה עצמה יכולה להיות זולה כמו כלום. זה לא משנה, אם אף אחד לא הספיק עדיין להפעיל אותה.
Interaction to Next Paint (INP) הוא מדד Core Web Vitals שמודד בדיוק את הפער הזה – לא זמן לבייט הראשון, לא זמן טעינת העמוד, אלא הזמן בין לחיצה של המשתמש על משהו לבין הרגע שבו הדפדפן באמת מצייר על המסך את האפקט של האינטראקציה הזו. הוא החליף את המדד הישן FID (First Input Delay) במרץ 2024 מסיבה משמעותית אחת: FID מדד רק את האינטראקציה הראשונה בעמוד, כך שלעמוד יכול היה להיות ציון מצוין גם אם כל לחיצה אחרי הראשונה נתקעה לשנייה שלמה. INP לוקח בחשבון את כל האינטראקציות לאורך כל מחזור החיים של העמוד ומדווח בפועל את המקרה הגרוע ביותר החוזר על עצמו – אי אפשר "לתקן" אותו עם רושם ראשוני טוב.
למה JavaScript אי אפשר להפריע באמצע
המנגנון המרכזי שבלעדיו אין ל-INP משמעות: JavaScript בדפדפן פועל במודל run-to-completion – משימה שהופעלה פעם אחת מתבצעת מההתחלה עד הסוף, בלי הפרעה, לא משנה מה המשתמש מנסה לעשות בינתיים. לחיצה בזמן ביצוע משימה כזו לא מתעלמים ממנה – היא נכנסת לתור וממתינה עד שהתהליך יתפנה. משימה שנמשכת יותר מ-50ms מסווגת רשמית כ-long task (Long Tasks API) – זה לא מספר שרירותי, אלא קירוב לגבול שמעליו המשתמש מתחיל להרגיש סובייקטיבית ש"תקוע" משהו, קרוב ברוחו לסף דוהרטי מהמאמר על מיקרו-אינטראקציות, רק שנמדד מצד עלות החישוב, לא מצד הרשת.
INP מתפרק לשלושה מרכיבים, ומשימות ארוכות בדרך כלל מנפחות את הראשון שבהם:
- עיכוב קלט (input delay) – הזמן מהלחיצה ועד לרגע שבו התהליך הראשי בכלל מתחיל לטפל באירוע. בדיוק כאן משימה ארוכה חוסמת הכול.
- זמן עיבוד (processing time) – כמה זמן לוקח בפועל לבצע את ה-handler של האירוע שלכם.
- עיכוב הצגה (presentation delay) – הזמן מסיום ה-handler ועד הציור האמיתי של הפריים עם האפקט.
Yielding: איך מוותרים על התהליך לטובת הדפדפן באמצע העבודה
מכיוון שלא ניתן להפריע למשימה מבחוץ, הדרך היחידה היא להפריע לה במודע מבפנים – לחלק עבודה סינכרונית ארוכה לחתיכות קטנות יותר ולהחזיר שליטה לדפדפן ביניהן, כדי שיספיק לטפל בקלט שממתין ולצייר פריים, לפני שתמשיכו לחתיכת העבודה הבאה.
javascript
async function processInChunks(items, processItem) {const results = [];for (let i = 0; i < items.length; i++) {results.push(processItem(items[i]));if (i % 50 === 0) {if ("scheduler" in window && "yield" in scheduler) {await scheduler.yield();} else {// גיבוי: setTimeout(fn, 0), לא Promise.resolve().then()await new Promise((resolve) => setTimeout(resolve, 0));}}}return results;}
פרט שקל לפספס ושבפועל הורס את האפקט של הטכניקה הזו: await Promise.resolve() או queueMicrotask() לא מוותרים על השליטה לטובת הדפדפן. אלה מיקרו-משימות (microtasks) – תור המיקרו-משימות מתרוקן במלואו, לפני שהדפדפן בכלל שוקל לטפל בקלט שממתין או לצייר פריים. אם בלולאה אסינכרונית אתם משתמשים רק במיקרו-משימות כדי "לחלק" עבודה, מנקודת המבט של הדפדפן זו בכל זאת משימה אחת רציפה – רק שהיא מפורקת ל-then() במקום לשורות קוד פשוטות. כדי באמת לוותר על התהליך אתם זקוקים לגבול של מאקרו-משימה – setTimeout, MessageChannel (מהיר יותר מ-setTimeout(0), כי הוא עוקף את העיכוב המינימלי שדפדפנים כופים על timeouts מקוננים) או scheduler.yield() החדש, שתוכנן ייעודית לזה מתוך ה-Scheduler API, שבנוסף מתעדף בחוכמה את ההמשך ביחס למשימות אחרות שממתינות, במקום להגיע לסוף תור רגיל כמו setTimeout.
isInputPending(): אל תוותרו על התהליך אם אף אחד לא ממתין לו
לוויתור על השליטה אחרי כל איבר בודד בלולאה יש מחיר משלו – כל מעבר דרך לולאת האירועים הוא תקורה. אם ברגע נתון אף אחד לא לחץ על כלום, הפרעת העבודה כל 50 איטרציות היא בזבוז זמן טהור, שמאריך את זמן העיבוד הכולל בלי שום תועלת למשתמש. navigator.scheduling.isInputPending() פותר את זה בשאלה ישירה: האם באמת ממתין בתור אירוע קלט שלא טופל, לפני שאתם מחליטים להפריע לעבודה.
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // מישהו באמת ממתין – ותרו על התהליך עכשיו}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
זה הופך את ההיגיון ביחס ל"כל 50 איברים" הנוקשה: במקום לנחש מראש כמה עבודה "בטוחה" בין yield אחד לשני, אתם שואלים את הדפדפן בזמן אמת אם באמת יש בכך צורך. כשתור הקלט ריק, הלולאה יכולה להמשיך בלי הפרעות הרבה מעבר ל-50 איטרציות – בלחיצה אמיתית באמצע העיבוד, היא תוותר על השליטה מיד, בדיוק במקום שבו יש לזה משמעות.
מלכודות ושיטות עבודה טובות
- מיקרו-משימות לא נחשבות כ-yield. זו הטעות הנפוצה ביותר בניסיון "לתקן" INP – הקוד נראה אסינכרוני כי יש בו
await, אבל אם מאחורי אותוawaitעומד רקPromise.resolve(), הדפדפן עדיין מתייחס לכל הפעולה כמשימה אחת שאי אפשר להפריע לה. scheduler.yield()עדיין דורש נפילה חזרה (fallback). התמיכה מוגבלת בעיקר לדפדפנים מבוססי Chromium – התייחסו אליו כשיפור פרוגרסיבי עםsetTimeoutכאפשרות גיבוי בטוחה, בדיוק כמו ה-Paint API מהמאמר על CSS Houdini.- גם משימות ארוכות מסקריפטים של צד שלישי נחשבות לציון ה-INP שלכם. אנליטיקס, פרסומות, ווידג'טים של צ'אט – חיים על אותו תהליך ראשי וחוסמים אותו בדיוק כמו הקוד שלכם.
PerformanceObserverשמאזין לרשומות מסוגlongtaskיראה את כולן, לא משנה מי יצר אותן – כדאי למדוד תעבורת ייצור אמיתית (RUM), לא רק בדיקות מקומיות בסביבת פיתוח ריקה ומ-cache. - INP מודד את המקרה הגרוע ביותר החוזר על עצמו לאורך כל מחזור החיים של העמוד, לא את הרושם הראשוני. אינטראקציה שמתקעת רק אחרי עשר דקות שימוש באפליקציה, כשבמצב הלקוח הצטבר הרבה מידע, עדיין נחשבת לציון – בדיקה של רק כמה הלחיצות הראשונות בעמוד שנטען טרי מקטינה באופן שיטתי את הבעיה האמיתית.
סיכום
המאמר על מיקרו-אינטראקציות הראה איך לגרום לכך שהאנימציה עצמה לא תעלה כלום לתהליך הראשי. INP מראה את החצי השני של אותה משוואה: אפילו האנימציה הזולה בעולם לא תעזור, אם התהליך הראשי עסוק במשימת JavaScript ארוכה ולא ניתנת להפרעה ברגע שהמשתמש לחץ. מודל run-to-completion הופך את החלוקה המודעת של העבודה הכבדה שלכם לחתיכות, עם החזרת שליטה לדפדפן ביניהן, לדרך היחידה להשיג רספונסיביות – דרך מאקרו-משימה אמיתית, לא מיקרו-משימה, ורצוי כשה-isInputPending() באמת מאשר שמישהו ממתין לזה. כל שרשרת הביצועים הנתפסים, מסף דוהרטי ועד INP, חזקה כמו החוליה החלשה בה – והחוליה הזו הופכת יותר ויותר להיות לא הרשת ולא השרת, אלא ה-JavaScript שלכם, כשלא חילקתם אותו כמו שצריך.