הערכת סקריפטים ומשימות ארוכות

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

כשמדובר באופטימיזציה של Interaction to Next Paint (INP), ניתן ברוב המקרים לבצע אופטימיזציה של האינטראקציות בעצמם. לדוגמה, במדריך לביצוע אופטימיזציה למשימות ארוכות, נדון בטכניקות כמו תפוקה עם setTimeout, isInputPending וכן הלאה. השיטות האלה מועילות, כי הן מאפשרות לשרשור הראשי מרווח נשימה מאחר שהן נמנעות ממשימות ארוכות, וכך נוצרות הזדמנויות נוספות לאינטראקציות ולפעילויות אחרות, שמתרחשות מהר יותר במקום להמתין למשימה ארוכה אחת.

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

מהי הערכת סקריפטים?

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

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

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

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

הקשר בין סקריפטים לבין המשימות שמעריכות אותם

האופן שבו משימות שאחראיות להערכת הסקריפט יתחילו לפעול תלוי בשאלה אם הסקריפט שאתם טוענים נטען דרך אלמנט <script> רגיל, או אם סקריפט הוא מודול שנטען עם type=module. מכיוון שלדפדפנים יש נטייה לטפל בדברים אחרת, תהיה לכך השפעה על האופן שבו מנועי הדפדפנים המובילים מטפלים בהערכת סקריפטים יתייחסו להבדלים בהתנהגויות של הערכת הסקריפטים.

מתבצעת טעינה של סקריפטים עם הרכיב <script>

מספר המשימות שנשלחו לצורך הערכת סקריפטים בדרך כלל קשור ישירות למספר הרכיבים של <script> בדף. כל רכיב <script> מתחיל משימה כדי להעריך את הסקריפט המבוקש כדי שאפשר יהיה לנתח, להדר ולהפעיל אותו. זהו המצב לדפדפנים המבוססים על Chromium , Safari ו-Firefox.

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

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

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

יש כמה משימות שכוללות הערכה של הסקריפטים, כפי שמוצג בכלי ליצירת פרופיל ביצועים (Performance profiler) של כלי הפיתוח ל-Chrome. מכיוון שכמה סקריפטים קטנים יותר נטענים במקום פחות סקריפטים גדולים, יש סיכוי נמוך יותר שמשימות יהפכו למשימות ארוכות, וה-thread הראשי יכול להגיב מהר יותר לקלט של משתמשים.
מספר משימות נוצרו לצורך הערכת סקריפטים כתוצאה ממספר רכיבים של <script> שקיימים ב-HTML של הדף. עדיף לשלוח חבילת סקריפטים אחת גדולה למשתמשים, כי יש סיכוי גבוה יותר שה-thread הראשי ייחסם.

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

מתבצעת טעינה של סקריפטים עם הרכיב <script> ועם המאפיין type=module

עכשיו אפשר לטעון מודולים של ES באופן מקומי בדפדפן באמצעות המאפיין type=module ברכיב <script>. לגישה הזו לטעינת סקריפטים יש כמה יתרונות לחוויית המפתח, כמו למשל שאין צורך בטרנספורמציה של קוד לשימוש בסביבת ייצור – במיוחד בשילוב עם ייבוא מפות. עם זאת, טעינת סקריפטים באופן הזה מתזמנת משימות שונות מדפדפן לדפדפן.

דפדפנים המבוססים על Chromium

בדפדפנים כמו Chrome – או באלה שנובעים ממנו – טעינה של מודולים של ES באמצעות המאפיין type=module יוצרת סוגי משימות שונים מאלו שבדרך כלל אפשר לראות כשלא משתמשים ב-type=module. לדוגמה, בכל סקריפט של מודול ירוצו משימה שקשורה לפעילות שמסומנת כהידור של המודול.

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

אחרי הידור של המודולים, כל קוד שירוץ בהם יפעיל את הפעילות שמסומנת כהערכה של המודול.

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

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

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

Safari ו-Firefox

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

מתבצעת טעינה של סקריפטים עם import() דינמי

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

כשמדובר בשיפור INP, יש שני יתרונות של import() דינמי:

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

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

טעינת סקריפטים ב-Web worker

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

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

פשרות ושיקולים

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

יעילות הדחיסה

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

קובצי Bundle הם כלים אידיאליים לניהול גודל הפלט של הסקריפטים שבהם האתר שלכם תלוי:

  • כשמדובר ב-Webpack, הפלאגין SplitChunksPlugin שלו יכול לעזור. במשאבי העזרה בנושא SplitChunksPlugin מפורטות אפשרויות להגדרת נכסים כדי לנהל את הגדלים של הנכסים.
  • לרכיבי Bundle אחרים, כמו אוסף ו-esbuild, אפשר לנהל את הגדלים של קובצי סקריפט על ידי שימוש בקריאות import() דינמיות בקוד. חבילות ה-Bundle, וגם ה-Webpack שלהן, ינתקו באופן אוטומטי את הנכס שמיובא באופן דינמי לקובץ משלו, וכך יימנעו מחבילות גדולות יותר בהתחלה.

נקה מטמון

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

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

ביצועי טעינה ומודולים מקוננים

אם אתם שולחים מודולים של ES לסביבת הייצור וטוענים אותם עם המאפיין type=module, חשוב שתשימו לב לאופן שבו קינון של מודולים יכול להשפיע על זמן ההפעלה. המונח 'קינון מודול' מתייחס למצב שבו מודול ES מייבא באופן סטטי מודול ES אחר שמייבא באופן סטטי מודול ES אחר:

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

אם המודולים של ה-ES לא מקובצים יחד, הקוד הקודם גורם לשרשרת של בקשות רשת: כשנשלחת בקשה ל-a.js מרכיב <script>, נשלחת בקשת רשת נוספת עבור b.js. לאחר מכן, נשלחת בקשה אחרת עבור c.js. דרך אחת להימנע מכך היא להשתמש ב-bundler, אבל חשוב לוודא שאתם מגדירים את ה-bundler כך שיפצל סקריפטים כדי לפזר את עבודת ההערכה של הסקריפטים.

אם לא רוצים להשתמש ב-bundler, אפשר להשתמש ב-modulepreload resource Hint כדי לעקוף קריאות למודול שמוצגות ב-Bundler. בשיטה הזו, המערכת טוענת מראש את המודולים של ES כדי להימנע משרשראות של בקשות רשת.

סיכום

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

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

  • כשטוענים סקריפטים באמצעות האלמנט <script> ללא המאפיין type=module, חשוב להימנע מטעינת סקריפטים גדולים מאוד, כי הם יגרמו למשימות של הערכת סקריפטים עתירות משאבים שחוסמות את ה-thread הראשי. פזר את הסקריפטים על פני אלמנטים נוספים של <script> כדי להפריד את העבודה הזו.
  • שימוש במאפיין type=module לטעינה של מודולים של ES באופן מקומי בדפדפן יגרום להפעלת משימות נפרדות להערכת כל סקריפט של מודול בנפרד.
  • אפשר להפחית את גודל החבילות הראשוניות באמצעות קריאות import() דינמיות. התכונה הזו פועלת גם ב-bundlers, מאחר שה-bundleers יתייחסו לכל מודול שמיובא באופן דינמי כ'נקודת פיצול'. כתוצאה מכך ייווצר סקריפט נפרד לכל מודול שמיובא באופן דינמי.
  • חשוב לשקול חלופות כמו יעילות הדחיסה וביטול התוקף של המטמון. סקריפטים גדולים יותר יידחסו טוב יותר, אבל יש סיכוי גבוה יותר שהם יכללו עבודה יקרה יותר של הערכת סקריפטים בפחות משימות, ויגרמו לביטול התוקף של המטמון של הדפדפן, מה שיוביל לירידה כוללת ביעילות השמירה במטמון.
  • אם משתמשים במודולים של ES באופן מקומי ללא קיבוץ, צריך להשתמש בהינט המשאב modulepreload כדי לייעל את הטעינה שלהם במהלך ההפעלה.
  • כמו תמיד, מומלץ לספק כמה שפחות JavaScript.

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

תמונה ראשית (Hero) מ-UnFlood, מאת Markus Spiske.