ביצועים משופרים לטעינת דפים Next.js ו-Gatsby עם פיצול פרטני

שיטת חלוקה חדשה יותר של webpack ב-Next.js וב-Gatsby מצמצמת את כפילות הקוד כדי לשפר את ביצועי טעינת הדף.

‫Chrome משתף פעולה עם כלים ומסגרות בסביבה העסקית של JavaScript בקוד פתוח. לאחרונה הוספנו מספר אופטימיזציות חדשות כדי לשפר את ביצועי הטעינה של Next.js ושל Gatsby. במאמר הזה נסביר על שיטת חלוקה משופרת ומפורטת יותר, שמופעלת עכשיו כברירת מחדל בשני המסגרות.

מבוא

בדומה למסגרות אינטרנט רבות, Next.js ו-Gatsby משתמשות ב-webpack ככלי הליבה שלהן לאיגוד קבצים. ב-webpack גרסה 3 הוצג CommonsChunkPlugin כדי לאפשר פלט של מודולים שמשותפים בין נקודות כניסה שונות בחלק אחד (או בכמה) של 'commons' (או של 'chunks'). אפשר להוריד קוד משותף בנפרד ולאחסן אותו במטמון של הדפדפן בשלב מוקדם, וכך לשפר את ביצועי הטעינה.

הדפוס הזה הפך לפופולרי בקרב הרבה מסגרות של אפליקציות של דף יחיד, שאימצו נקודת כניסה והגדרת חבילה שנראות כך:

הגדרה נפוצה של נקודת כניסה וחבילה

למרות שזה נוח, יש מגבלות לשיטה של איגוד כל קוד המודול המשותף לחלק אחד. אפשר להוריד מודולים שלא משותפים בכל נקודת כניסה עבור מסלולים שלא משתמשים בהם, וכך מורידים יותר קוד מהנדרש. לדוגמה, כש-page1 טוען את המקטע common, הוא טוען את הקוד של moduleC גם אם page1 לא משתמש ב-moduleC. לכן, בין היתר, בגרסה 4 של webpack הוסר הפלאגין ובמקומו נוסף פלאגין חדש: SplitChunksPlugin.

שיפור בחלוקה לחלקים

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

עם זאת, הרבה מסגרות אינטרנט שמשתמשות בתוסף הזה עדיין פועלות לפי גישה של 'single-commons' לפיצול של מקטעי קוד. לדוגמה, ב-Next.js ייווצר חבילת commons שתכיל כל מודול שנעשה בו שימוש ביותר מ-50% מהדפים וכל התלויות של המסגרת (react,‏ react-dom וכן הלאה).

const splitChunksConfigs = {
  
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

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

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

כדי לפתור את הבעיה, ב-Next.js אימצו הגדרה שונה ל-SplitChunksPlugin שמצמצמת את הקוד המיותר לכל נתיב.

  • כל מודול גדול מספיק של צד שלישי (גדול מ-160KB) מחולק לחלקים נפרדים משלו
  • נוצר צ'אנק frameworks נפרד לתלות במסגרת (react,‏ react-dom וכו')
  • נוצרים כמה נתחים משותפים שצריך (עד 25)
  • הגודל המינימלי של נתח שייווצר השתנה ל-20KB

היתרונות של שיטת החלוקה הזו:

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

אפשר לראות את כל ההגדרה שאומצה ב-Next.js ב-webpack-config.ts.

בקשות HTTP נוספות

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

דפדפנים יכולים לפתוח רק מספר מוגבל של חיבורי TCP למקור יחיד (6 ב-Chrome), ולכן צמצום מספר החלקים שנוצרים על ידי כלי לאיגוד חבילות יכול להבטיח שמספר הבקשות הכולל יישאר מתחת לסף הזה. עם זאת, זה נכון רק ל-HTTP/1.1. ריבוב ב-HTTP/2 מאפשר להזרים כמה בקשות במקביל באמצעות חיבור יחיד דרך מקור יחיד. במילים אחרות, בדרך כלל אין צורך להגביל את מספר החלקים שמופקים על ידי הכלי לאיגוד חבילות.

כל הדפדפנים העיקריים תומכים ב-HTTP/2. הצוותים של Chrome ו-Next.js רצו לבדוק אם הגדלת מספר הבקשות על ידי פיצול חבילת ה-commons היחידה של Next.js לכמה חבילות משותפות תשפיע על ביצועי הטעינה. הם התחילו במדידת הביצועים של אתר יחיד, תוך שינוי המספר המקסימלי של בקשות מקבילות באמצעות המאפיין maxInitialRequests.

ביצועי טעינת הדף עם מספר מוגדל של בקשות

בממוצע של שלוש הרצות של כמה ניסויים בדף אינטרנט יחיד, הזמנים של load,‏ start-render ו-First Contentful Paint נשארו כמעט זהים כששינו את המספר המקסימלי של בקשות ראשוניות (מ-5 עד 15). מעניין לציין שזיהינו תקורה קלה בביצועים רק אחרי שפיצלנו את הבקשות בצורה אגרסיבית למאות בקשות.

ביצועי טעינת דפים עם מאות בקשות

התוצאה הזו הראתה ששמירה על מספר בקשות מתחת לסף אמין (20 עד 25 בקשות) יוצרת איזון נכון בין ביצועי הטעינה ליעילות השמירה במטמון. אחרי בדיקות בסיסיות, נבחר הערך 25 בתור מספר maxInitialRequest.

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

הפחתת מטען ייעודי (payload) של JavaScript באמצעות הגדלת החלוקה לחלקים

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

‫webpack משתמש ב-30KB כגודל מינימלי ברירת מחדל ליצירת מקטע. עם זאת, שילוב של ערך maxInitialRequests של 25 עם גודל מינימלי של 20KB‎ הניב תוצאות טובות יותר של שמירת נתונים במטמון.

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

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

‫Next.js משתמש בקובץ מניפסט של בנייה בצד השרת כדי לקבוע אילו נתונים פלט משמשים נקודות כניסה שונות. כדי לספק את המידע הזה גם ללקוח, נוצר קובץ מניפסט של build בצד הלקוח, שכולל רק את המידע הרלוונטי, כדי למפות את כל התלויות לכל נקודת כניסה.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
פלט של כמה נתחי קוד משותפים באפליקציית Next.js.

האסטרטגיה החדשה הזו של חלוקה לחלקים קטנים הושקה לראשונה ב-Next.js מאחורי דגל, שם היא נבדקה על ידי מספר משתמשים מוקדמים. רבים ראו ירידה משמעותית בכמות הכוללת של JavaScript שנעשה בה שימוש באתר כולו:

אתר שינוי כולל ב-JS הבדל באחוזים
https://www.barnebys.com/ ‫‎-238 KB ‎-23%
https://sumup.com/ ‫‎-220 KB ‫‎-30%
https://www.hashicorp.com/ ‫11-MB ‫‎-71%
הקטנת הגודל של JavaScript – בכל המסלולים (דחוס)

הגרסה הסופית נשלחה כברירת מחדל בגרסה 9.2.

גטסבי

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

config.optimization = {
  
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

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

אתר שינוי כולל ב-JS הבדל באחוזים
https://www.gatsbyjs.org/ ‫‎-680 KB ‫‎-22%
https://www.thirdandgrove.com/ ‫-390 KB -25%
https://ghost.org/ ‫‎-1.1 MB ‫‎-35%
https://reactjs.org/ ‎-80 Kb ‎-8%
הקטנת הגודל של JavaScript – בכל המסלולים (דחוס)

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

סיכום

המושג של משלוח נתחים גרנולריים לא ספציפי ל-Next.js, ל-Gatsby או אפילו ל-webpack. כולם צריכים לשקול לשפר את אסטרטגיית חלוקת הקוד של האפליקציה אם היא מבוססת על גישה של חבילת קוד גדולה מסוג 'commons', בלי קשר למסגרת או לכלי לאיגוד מודולים שבהם נעשה שימוש.

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