פיצול קוד JavaScript

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

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

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

הפחתת הניתוח והביצוע של JavaScript במהלך ההפעלה באמצעות פיצול קוד

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

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

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

פיצול קוד הוא שיטה שימושית שיכולה להפחית את טעינות ה-payload הראשוניות של JavaScript בדף. הוא מאפשר לפצל חבילת JavaScript לשני חלקים:

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

ניתן לפצל את הקוד באמצעות התחביר import() דינמי. התחביר הזה, שלא כמו רכיבי <script> שמבקש משאב JavaScript מסוים במהלך ההפעלה, יוצר בקשה למשאב JavaScript בשלב מאוחר יותר במחזור החיים של הדף.

document.querySelectorAll('#myForm input').addEventListener('blur', async () => {
  // Get the form validation named export from the module through destructuring:
  const { validateForm } = await import('/validate-form.mjs');

  // Validate the form:
  validateForm();
}, { once: true });

בקטע קוד ה-JavaScript הקודם, המערכת מורידה את המודול validate-form.mjs, מנתחת אותו ומבצעת אותו רק כשהמשתמש מטשטש שדה <input> כלשהו בטופס. במצב כזה, משאב ה-JavaScript שאחראי להנעת לוגיקת האימות של הטופס מעורב בדף רק כשיש סבירות גבוהה שייעשה בו שימוש בפועל.

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

הערות מועילות לגבי פיצול קוד

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

אם אפשר, השתמשו ב-bundler

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

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

חבילות סלולר גם מונעות את הבעיה של שליחת מספר גדול של מודולים לא מקובצים ברשת. בארכיטקטורות שמשתמשות במודולים של JavaScript, בדרך כלל יש עצי מודולים גדולים ומורכבים. כשעצי המודולים לא מקובצים, כל מודול מייצג בקשת HTTP נפרדת, ויכול להיות שהאינטראקטיביות באפליקציית האינטרנט תתעכב אם לא מקבצים מודולים. אפשר להשתמש בהינט למשאב <link rel="modulepreload"> כדי לטעון עצי מודול גדולים מוקדם ככל האפשר, אבל עדיין עדיף להשתמש בחבילות JavaScript מנקודת מבט של ביצועי טעינה.

אין להשבית בטעות את הידור הסטרימינג

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

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

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

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

הדגמה של ייבוא דינמי

חבילת אינטרנט

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

  • chunks: async הוא ערך ברירת המחדל והוא מתייחס לקריאות דינמיות ל-import().
  • chunks: initial מפנה לקריאות סטטיות של import.
  • הייבוא של chunks: all כולל ייבוא דינמי של import() וגם ייבוא סטטי, כך שאפשר לשתף מקטעים בין ייבוא של async ל-initial.

כברירת מחדל, בכל פעם ש-webpack נתקל בהצהרת import() דינמית, הוא יוצר מקטע נפרד למודול הזה:

/* main.js */

// An application-specific chunk required during the initial page load:
import myFunction from './my-function.js';

myFunction('Hello world!');

// If a specific condition is met, a separate chunk is downloaded on demand,
// rather than being bundled with the initial chunk:
if (condition) {
  // Assumes top-level await is available. More info:
  // https://v8.dev/features/top-level-await
  await import('/form-validation.js');
}

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

  • קבוצת הנתונים main.js – שחבילת Webpack מסווגת בתור מקטע initial – כוללת את המודול main.js ו-./my-function.js.
  • קבוצת הנתונים async, שכוללת רק את form-validation.js (שמכילה גיבוב (hash) של קובץ בשם המשאב, אם הוגדר). אפשר להוריד את קבוצת הנתונים הזו רק אם וכאשר condition הוא truthy.

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

מצד שני, שינוי ההגדרות של SplitChunksPlugin לציון chunks: initial מבטיח שהקוד יפוצל רק במקטעים הראשוניים. אלו מקטעים, כמו אלה שיובאו באופן סטטי, או שרשומים במאפיין entry של חבילת Webpack. בדוגמה הקודמת, המקטע שנוצר הוא שילוב של form-validation.js ו- main.js בקובץ סקריפט יחיד, מה שעלול להוביל לביצועים נמוכים יותר של טעינת הדף הראשונית.

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

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

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

הדגמה של חבילת אינטרנט

הדגמת SplitChunksPlugin של חבילת אינטרנט.

בוחנים את הידע

איזה סוג של משפט import משמש לביצוע פיצול קוד?

import() דינמי.
נכון!
סטטי import.
אפשר לנסות שוב.

איזה סוג של הצהרה import חייב להופיע בחלק העליון של מודול JavaScript, ובשום מיקום אחר?

import() דינמי.
אפשר לנסות שוב.
סטטי import.
נכון!

כשמשתמשים ב-SplitChunksPlugin בחבילה באינטרנט, מה ההבדל בין קבוצת נתונים של async לבין קבוצת נתונים של initial?

async מקטעים נטענים באמצעות import() דינמי ו-initial מקטעים נטענים באמצעות import סטטי.
נכון!
מקטעים של async נטענים באמצעות import סטטיות ו-initial מקטעים נטענים באמצעות import() דינמי.
אפשר לנסות שוב.

השלב הבא: תמונות בטעינה מדורגת ורכיבי <iframe>

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