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

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

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

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

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

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

למרות שזהו פתרון מעשי, לקונספט של קיבוץ כל קוד המודול המשותף בחלק אחד יש מגבלות. מודולים שלא משותפים בכל נקודת כניסה יכולים להוריד למסלולים שלא משתמשים בהם, וכתוצאה מכך מתבצע הורדה של יותר קוד מהנדרש. לדוגמה, כשה-chunk‏ common נטען ב-page1, הקוד של moduleC נטען גם אם page1 לא משתמש ב-moduleC. לכן, יחד עם כמה אחרים, ה-Plugin הוסר מ-webpack v4 לטובת אחד חדש: 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

מסגרות רבות, כולל 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/ -220 KB ‎-30%
https://www.hashicorp.com/ ‎-11MB ‎-71%
צמצום הגודל של JavaScript – בכל המסלולים (דחוס)

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

Gatsby

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

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/ ‎-680KB ‎-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. כדאי לכל אחד לשקול לשפר את אסטרטגיית הפילוח של האפליקציה אם היא מבוססת על חבילה גדולה של 'משאבים נפוצים', ללא קשר למסגרת או למאגר החבילות של המודולים שבהם נעשה שימוש.

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