שימוש בשמירה במטמון לטווח ארוך

איך חבילת Webpack עוזרת לשמור נכסים במטמון

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

שימוש בניהול גרסאות של חבילות ובכותרות מטמון

הגישה הנפוצה לביצוע שמירה במטמון היא:

  1. להורות לדפדפן לשמור קובץ במטמון למשך זמן רב מאוד (למשל, שנה):

    # Server header
    Cache-Control: max-age=31536000
    

    אם אתם לא יודעים מה Cache-Control עושה, עיינו בפוסט המעולה של ג'ייק ארצ'יבלד על שיטות מומלצות לשמירה במטמון.

  2. ולשנות את שם הקובץ לאחר השינוי כדי לאלץ את ההורדה מחדש:

    <!-- Before the change -->
    <script src="./index-v15.js"></script>
    
    <!-- After the change -->
    <script src="./index-v16.js"></script>
    

כך הוא מורה לדפדפן להוריד את קובץ ה-JS, לשמור אותו במטמון ולהשתמש בעותק שנשמר במטמון. הדפדפן יתחבר לרשת רק אם שם הקובץ ישתנה (או אם חולפת שנה).

ב-Webpack מבצעים את אותו הדבר, אבל במקום מספר גרסה מציינים את הגיבוב (hash) של הקובץ. כדי לכלול את הגיבוב בשם הקובץ, משתמשים ב-[chunkhash]:

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

אם אתם צריכים את שם הקובץ כדי לשלוח אותו ללקוח, השתמשו ב-HtmlWebpackPlugin או ב-WebpackManifestPlugin.

השיטה HtmlWebpackPlugin היא פשוטה, אבל פחות גמישה. במהלך ההידור, הפלאגין הזה יוצר קובץ HTML שכולל את כל המשאבים ההידור. אם לוגיקת השרת לא מורכבת, היא אמורה להספיק לכם:

<!-- index.html -->
<!DOCTYPE html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>

הגישה WebpackManifestPlugin יותר גמישה, וכדאי להשתמש בה במקרים של חלק מורכב בשרת. במהלך ה-build נוצר קובץ JSON עם מיפוי בין שמות הקבצים ללא גיבוב (hash) לבין שמות הקבצים עם גיבוב. השתמשו ב-JSON הזה בשרת כדי לבחור עם איזה קובץ לעבוד:

// manifest.json
{
  "bundle.js": "bundle.8e0d62a03.js"
}

קריאה נוספת

חילוץ יחסי תלות וזמן ריצה לקובץ נפרד

יחסי תלות

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

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

  1. החליפו את שם קובץ הפלט ב-[name].[chunkname].js:

    // webpack.config.js
    module.exports = {
      output: {
        // Before
        filename: 'bundle.[chunkhash].js',
        // After
        filename: '[name].[chunkhash].js'
      }
    };
    

    כש-Webpack יוצר את האפליקציה, הוא מחליף את [name] בשם של מקטע אם לא נוסיף את החלק [name], נצטרך להבחין בין מקטעים באמצעות הגיבוב שלהם – זה די קשה!

  2. ממירים את השדה entry לאובייקט:

    // webpack.config.js
    module.exports = {
      // Before
      entry: './index.js',
      // After
      entry: {
        main: './index.js'
      }
    };
    

    בקטע הקוד הזה, 'main' הוא שם של מקטע. השם הזה יוחלף ב-[name] משלב 1.

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

  3. ב-webpack 4, מוסיפים את האפשרות optimization.splitChunks.chunks: 'all' להגדרה של Webpack:

    // webpack.config.js (for webpack 4)
    module.exports = {
      optimization: {
        splitChunks: {
          chunks: 'all'
        }
      }
    };
    

    האפשרות הזו מפעילה פיצול חכם באמצעות קוד. באמצעותו, ה-webpack מחלץ את קוד הספק אם הוא גדול מ-30KB (לפני ההקטנה ו-gzip). הפעולה הזו גם תחלץ את הקוד הנפוץ – האפשרות הזו שימושית אם ה-build מייצר כמה חבילות (למשל, אם מפצלים את האפליקציה למסלולים).

    בחבילה 3, מוסיפים את CommonsChunkPlugin:

    // webpack.config.js (for webpack 3)
    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
        // A name of the chunk that will include the dependencies.
        // This name is substituted in place of [name] from step 1
        name: 'vendor',
    
        // A function that determines which modules to include into this chunk
        minChunks: module => module.context && module.context.includes('node_modules'),
        })
      ]
    };
    

    הפלאגין הזה לוקח את כל המודולים שכוללים את node_modules, ומעביר אותם לקובץ נפרד בשם vendor.[chunkhash].js.

אחרי השינויים האלה, כל build ייצור שני קבצים במקום אחד: main.[chunkhash].js ו-vendor.[chunkhash].js (vendors~main.[chunkhash].js ל-webpack 4). במקרה של Webpack 4, יכול להיות שחבילת הספק לא תיווצר אם יחסי התלות קטנים, וזה בסדר:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                        Asset      Size  Chunks             Chunk Names
 ./main.00bab6fd3100008a42b0.js   82 kB       0  [emitted]  main
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

הדפדפן ישמור את הקבצים האלה במטמון בנפרד – ויוריד מחדש רק את הקוד שמשתנה.

קוד זמן ריצה של Webpack

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

// index.js
…
…

// E.g. add this:
console.log('Wat');

תוכלו לראות שגם הגיבוב מסוג vendor משתנה:

                           Asset   Size  Chunks             Chunk Names
./vendor.d9e134771799ecdf9483.js  47 kB       1  [emitted]  vendor

                            Asset   Size  Chunks             Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js  47 kB       1  [emitted]  vendor

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

// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
    "0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";

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

כדי לפתור את הבעיה, נעביר את זמן הריצה לקובץ נפרד. בחבילת Webpack 4 אפשר לעשות זאת על ידי הפעלת האפשרות optimization.runtimeChunk:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    runtimeChunk: true
  }
};

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

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => module.context && module.context.includes('node_modules')
    }),
    // This plugin must come after the vendor one (because webpack
    // includes runtime into the last chunk)
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      // minChunks: Infinity means that no app modules
      // will be included into this chunk
      minChunks: Infinity
    })
  ]
};

לאחר השינויים האלה, כל build ייצור שלושה קבצים:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

כוללים אותם ב-index.html בסדר ההפוך – וזהו:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

קריאה נוספת

זמן ריצה מוטבע של Webpack לשמירת בקשת HTTP נוספת

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

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>

עושים זאת:

<!-- index.html -->
<script>
!function(e){function n(r){if(t[r])return t[r].exports;…}} ([]);
</script>

סביבת זמן הריצה קטנה, והטמעתה תעזור לכם לשמור בקשת HTTP (חשובה למדי עם HTTP/1, היא פחות חשובה עם HTTP/2 אבל עדיין עשויה להפעיל אפקט).

כך עושים את זה:

אם אתם יוצרים HTML באמצעות HtmlWebpackPlugin

אם אתם משתמשים ב-HtmlWebpackPlugin כדי ליצור קובץ HTML, כל מה שצריך הוא InlineSourcePlugin:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

אם יוצרים HTML באמצעות לוגיקת שרת בהתאמה אישית

באמצעות Webpack 4:

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

    // webpack.config.js (for webpack 4)
    const ManifestPlugin = require('webpack-manifest-plugin');
    
    module.exports = {
      plugins: [
        new ManifestPlugin()
      ]
    };
    

    גרסת build עם הפלאגין הזה תיצור קובץ שנראה כך:

    // manifest.json
    {
      "runtime~main.js": "runtime~main.8e0d62a03.js"
    }
    
  2. מטמיעים את התוכן של מקטע זמן הריצה בצורה נוחה. לדוגמה, עם Node.js ו-Express:

    // server.js
    const fs = require('fs');
    const manifest = require('./manifest.json');
    const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

או עם Webpack 3:

  1. כדי ששם זמן הריצה יהיה סטטי, צריך לציין filename:

    module.exports = {
      plugins: [
        new webpack.optimize.CommonsChunkPlugin({
          name: 'runtime',
          minChunks: Infinity,
          filename: 'runtime.js'
        })
      ]
    };
    
  2. הצבת התוכן ב-runtime.js בצורה נוחה. לדוגמה, עם Node.js ו-Express:

    // server.js
    const fs = require('fs');
    const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8');
    
    app.get('/', (req, res) => {
      res.send(`
        …
        <script>${runtimeContent}</script>
        …
      `);
    });
    

קוד טעינה מדורגת שאינו נחוץ לך כרגע

לפעמים, הדף מכיל חלקים חשובים יותר ופחות חשובים:

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

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

// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

המדיניות import() מציינת שרוצים לטעון מודול ספציפי באופן דינמי. כש-Webpack מזהה את import('./module.js'), הוא מעביר את המודול הזה לקטע נפרד:

$ webpack
Hash: 39b2a53cb4e73f0dc5b2
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.f7e53d8e13e9a2745d6d.js    60 kB       1  [emitted]  main
 ./vendor.4f14b6326a80f4752a98.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

ויוריד אותה רק כשהביצוע מגיע לפונקציה import().

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

קריאה נוספת

פיצול הקוד לנתיבים ולדפים

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

דף הבית של WebFundamentals

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

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

לאפליקציות בדף יחיד

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

באפליקציות מסורתיות מרובות דפים

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

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

לכל קובץ רשומה, Webpack יבנה עץ תלות נפרד ותיצור חבילה שכוללת רק מודולים שמשמשים את אותה רשומה:

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

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

עם זאת, לעצים תלויים נפרדים יש חסרונות. אם שתי נקודות כניסה משתמשות ב-Lodash, ולא העברתם את יחסי התלות לחבילת ספק, בשתי נקודות הכניסה יופיע עותק של Lodash. כדי לפתור את הבעיה, ב-webpack 4,מוסיפים את האפשרות optimization.splitChunks.chunks: 'all' להגדרה של Webpack:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

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

לחלופין, בחבילה 3, משתמשים ב-CommonsChunkPlugin – הוא יעביר יחסי תלות נפוצים לקובץ ספציפי חדש:

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

אפשר לשחק עם הערך minChunks כדי למצוא את הכי טוב. באופן כללי, כדאי לשמור על גודל קטן, אבל אם מספר המקטעים גדל. לדוגמה, ל-3 מקטעים, ייתכן שהגודל של minChunks יהיה 2, אבל ל-30 מקטעים, הוא יכול להיות 8 – כי אם תשמרו את הערך 2, יותר מדי מודולים ייכנסו לקובץ המשותף, וכתוצאה מכך הוא יופחת יותר מדי.

קריאה נוספת

הפיכת מזהי המודולים ליציבים יותר

במהלך יצירת הקוד, ה-webpack מקצה לכל מודול מזהה. לאחר מכן, המזהים האלה יהיו בשימוש ב-require() בתוך החבילה. בדרך כלל מזהים בפלט ה-build ממש לפני נתיבי המודול:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ כאן

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

כברירת מחדל, מזהים מחושבים באמצעות מונה (כלומר, למודול הראשון יש מזהה 0, למודול השני יש מזהה 1 וכן הלאה). הבעיה היא שכאשר מוסיפים מודול חדש, הוא עשוי להופיע באמצע רשימת המודולים, ולשנות את כל מזהי המודולים הבאים:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ הוספנו מודול חדש...

[4] ./webPlayer.js 24 kB {1} [built]

↓ ותראו מה הוא עשה! ב-comments.js יש עכשיו מזהה 5 במקום 4

[5] ./comments.js 58 kB {0} [built]

↓ ל-ads.js יש עכשיו מזהה 6 במקום 5

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

הפעולה הזו מבטלת את התוקף של כל המקטעים שכוללים או תלויים במודולים עם מזהים שהשתנו – גם אם הקוד שלהם בפועל לא השתנה. במקרה שלנו, מקטע ה-0 (המקטע עם comments.js) והקטע main (שמוגדר עם קוד האפליקציה האחר) עלולים להיפסל, אבל רק המקטע main היה אמור להיות מבוטל.

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

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ כאן

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

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

כדי להפעיל את הפלאגין, יש להוסיף אותו לקטע plugins בהגדרה:

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

קריאה נוספת

סיכום

  • לשמור את החבילה במטמון ולהבדיל בין גרסאות על ידי שינוי שם החבילה
  • פיצול החבילה לקוד אפליקציה, קוד ספק וזמן ריצה
  • להכניס שורה של סביבת זמן הריצה כדי לשמור בקשת HTTP
  • טעינה מדורגת של קוד שאינו קריטי באמצעות import
  • פיצול הקוד לפי מסלולים/דפים כדי למנוע טעינה של דברים מיותרים