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

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

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

מבוא

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

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

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

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

חלוקה לקטעים משופרת

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

עם זאת, הרבה מסגרות אינטרנט שמשתמשות בפלאגין הזה עדיין פועלות לפי הגישה 'common-one' (משותף אחד) לחלוקת קטעים. לדוגמה, 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

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

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

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

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

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

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

כל הדפדפנים העיקריים תומכים ב-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 בדף הבדיקה הייתה אופטימלית כי היא צמצמה את גודל עומס העבודה של JavaScript בלי להאט את הדף. כמות ה-JavaScript הכוללת שנדרשה כדי להוסיף תוכן לדף נותרה בערך זהה, ולכן ביצועי הטעינה של הדף לא השתפרו בהכרח עם הפחתת כמות הקוד.

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

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

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

ב-Next.js נעשה שימוש בקובץ מניפסט של build בצד השרת כדי לקבוע אילו קטעי פלט משמשים לנקודות כניסה שונות. כדי לספק את המידע הזה גם ללקוח, נוצר קובץ מניפסט מקוצר של 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/ -238KB ‎-23%
https://sumup.com/ -220KB ‎-30%
https://www.hashicorp.com/ ‎-11MB ‎-71%
צמצום הגודל של JavaScript – בכל המסלולים (דחוס)

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

Gatsby

בעבר נעשה שימוש ב-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/ -390KB -25%
https://ghost.org/ -1.1 MB 35%-
https://reactjs.org/ -80 Kb ‎-8%
צמצום הגודל של JavaScript – בכל המסלולים (דחוס)

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

סיכום

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

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