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

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

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

מבוא

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

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

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

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

חלוקה משופרת

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

עם זאת, מסגרות אינטרנט רבות שמשתמשות בפלאגין זה עדיין פועלות לפי גישת 'Single-Commons' (משותף יחיד) לפיצול מקטעים. לדוגמה, Next.js ייצור חבילת commons שמכילה כל מודול שנמצא בשימוש ביותר מ-50% מהדפים ומכל יחסי התלות של ה-framework (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)[\\/]/,
      },
    },
  },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

צמצום המטען הייעודי (payload) של JavaScript עם הגדלה של קנה המידה

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

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

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

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

ב-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. כל אחד מהם צריך לשקול שיפור אסטרטגיית פיצול (chunking) של האפליקציה אם היא פועלת לפי גישה גדולה של חבילות "Commons", ללא קשר ל-framework או ל-bundler שבו השתמשו.

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