• בלוג
  • שימוש ב Browser Cache בפרויקט Webpack

שימוש ב Browser Cache בפרויקט Webpack

03/10/2019

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

1. החשיבות של Browser Cache

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

מה קורה כשגולשת מנסה להגיע לאתר אבל חלק מהקבצים השתנו מאז הביקור האחרון שלה?

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

בשביל לעבוד בצורה נכונה עם Browser Cache בעזרת Webpack אנחנו מנצלים רעיון פשוט שמחבר בין שם הקובץ לתוכן שלו. וובפאק יוכל באופן אוטומטי לקחת את תוכן הקובץ, להפעיל עליו חישוב ייחודי שנקרא Hash (וובפאק משתמש בפונקציה בשם MD4 כברירת מחדל, אבל אתם יכולים לבחור גם פונקציות אחרות) ואז להשתמש בתוצאה של ה Hash הזה כחלק משם הקובץ. בהפעלה הבאה של התהליך, אם תוכן הקובץ ישתנה גם ה Hash ישתנה ולכן שם הקובץ ישתנה איתו. בצורה כזאת דפדפן יכול לשמור ב Cache קבצים לפי השם שלהם, ואם תוכן הקובץ ישתנה באופן אוטומטי גם שם הקובץ ישתנה וכך גם הקישור אליו, ואז הדפדפן יוריד את הקובץ החדש.

נמשיך לראות את הגדרות Webpack שמאפשרות לכל הקסם הזה לקרות.

2. יצירת קובץ JavaScript יחיד עם כל התוכן שלנו ושם ייחודי

ניצור פרויקט צד-לקוח חדש מבוסס Webpack בו יהיה קובץ JavaScript יחיד בשם src/main.js. בתוך הקובץ מספיק לרשום שורת הדפסה פשוטה כמו:

console.log('hello world');

אמשיך ליצירת פרויקט npm והתקנת התלויות עם:

$ npm init -y
$ npm install --save-dev webpack webpack-cli html-webpack-plugin

וקובץ הגדרות webpack.config.js עם התוכן הבא:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: '[name]-[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin(),
  ],
};

השורה המעניינת היא שם הקובץ בשדה filename: מילה בתוך סוגריים מרובעים מציינת ב Webpack משתנה והתוכן יישתל אוטומטית על ידי וובפאק בעת בניית הקובץ. המילה name מייצגת את שם ה Chunk שאנחנו בונים, והמילה contenthash את חישוב ה Hash על תוכן הקובץ. חיבור שני משתנים אלה ייצור שם ייחודי שישתנה כל פעם שיהיה שינוי בקובץ.

נלך לראות שהכל עובד - הריצו את השורה הבאה משורת הפקודה כדי לבנות את תיקיית היעד:

$ npx webpack -p

וקיבלנו את הפלט הבא:

Hash: 158cd973342eef53ef13
Version: webpack 4.39.2
Time: 699ms
Built at: 08/21/2019 10:59:42 AM
                       Asset       Size  Chunks             Chunk Names
                  index.html  201 bytes          [emitted]  
main-55e3de1bd424ad869985.js  956 bytes       0  [emitted]  main
Entrypoint main = main-55e3de1bd424ad869985.js
[0] ./src/main.js 28 bytes {0} [built]
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

המספר 55e3de1bd424ad869985 הוא ה Hash של הקובץ main.js. נפעיל עכשיו שוב את Webpack ונראה שאנחנו מקבלים בדיוק את אותו מספר:

$ npx webpack -p
Hash: 158cd973342eef53ef13
Version: webpack 4.39.2
Time: 1043ms
Built at: 08/21/2019 11:01:00 AM
                       Asset       Size  Chunks             Chunk Names
                  index.html  201 bytes          [emitted]  
main-55e3de1bd424ad869985.js  956 bytes       0  [emitted]  main
Entrypoint main = main-55e3de1bd424ad869985.js
[0] ./src/main.js 28 bytes {0} [built]
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

אבל אם נשנה את הקובץ src/main.js אפילו שינוי של תו בודד נקבל ערך Hash חדש לגמרי. בשביל המשחק אלך להוסיף לקובץ שורה כך שיהיה בו התוכן:

// just a stupid comment
console.log('hello world');

והתוצאה:

$ npx webpack -p
Hash: f3bc66185e19fb18a7ea
Version: webpack 4.39.2
Time: 687ms
Built at: 08/21/2019 11:02:18 AM
                       Asset       Size  Chunks             Chunk Names
                  index.html  201 bytes          [emitted]  
main-16a379c233465bdada59.js  956 bytes       0  [emitted]  main
Entrypoint main = main-16a379c233465bdada59.js
[0] ./src/main.js 53 bytes {0} [built]
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

מעניין לשים לב שבינתיים ה Hash מחושב על קובץ המקור ולא על הקובץ שבסוף נכתב ל dist. כרגע בתיקיית היעד אין הבדל בין שני הקבצים שכבר נוצרו בגלל שההערה נמחקת במהלך ה Minification של קובץ ה JavaScript. יש על זה Issue פתוח ב Webpack ואני מקווה שיתקנו את זה בתקופה הקרובה (אולי אפילו עד שאתם קוראים את הפוסט הכל כבר הסתדר):

https://github.com/webpack/webpack/issues/9520

אותו מנגנון בדיוק עובד גם אם הקובץ main.js יכלול קבצי JavaScript אחרים שיכנסו יחד איתו לתוך ה chunk (כלומר לתוך הפלט dist/main.js). נוסיף בשביל הדוגמא קובץ js נוסף בשם car.js עם התוכן הבא:

export function drive() {
  console.log('Vroom Vroom');
}

ונעדכן את main כך שישתמש בפונקציה החדשה:

import { drive } from './car';

drive();
// just a stupid comment
console.log('hello world');

ובבניה הבאה נקבל:

$ npx webpack -p
Hash: 6d3e489c98e330efdf9f
Version: webpack 4.39.2
Time: 616ms
Built at: 08/21/2019 11:22:21 AM
                       Asset        Size  Chunks             Chunk Names
                  index.html   201 bytes          [emitted]  
main-08b037b0f96c76477863.js  1010 bytes       0  [emitted]  main
Entrypoint main = main-08b037b0f96c76477863.js
[0] ./src/main.js + 1 modules 152 bytes {0} [built]
    | ./src/main.js 94 bytes [built]
    | ./src/car.js 58 bytes [built]
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

מכאן והלאה ה Hash ישתנה כל פעם שאשנה משהו בתוכן הקובץ src/main.js או בתוכן הקובץ src/car.js.

3. יצירת קובץ JavaScript נוסף עבור כל הספריות החיצוניות עם שם ייחודי

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

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

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

תחילה נתקין לפרויקט שתי ספריות חיצוניות, את jQuery ו underscore:

$ npm install --save jquery underscore

עכשיו נעדכן את הקובץ src/main.js כך שישתמש בשתי הספריות החיצוניות:

import { drive } from './car';
import $ from 'jquery';
import _ from 'underscore';

drive();
// just a stupid comment
$('body').text(_.random(1, 100));

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

Hash: c204d8266b5e84fef6fe
Version: webpack 4.39.2
Time: 589ms
Built at: 08/21/2019 11:32:13 AM
                       Asset       Size  Chunks             Chunk Names
                  index.html  201 bytes          [emitted]  
main-cf32ea359e0e1ee111de.js    105 KiB       0  [emitted]  main
Entrypoint main = main-cf32ea359e0e1ee111de.js
[2] (webpack)/buildin/global.js 472 bytes {0} [built]
[3] (webpack)/buildin/module.js 497 bytes {0} [built]
[4] ./src/main.js + 1 modules 220 bytes {0} [built]
    | ./src/main.js 152 bytes [built]
    | ./src/car.js 58 bytes [built]
    + 2 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

עדיין נוצר קובץ html יחיד וקובץ js יחיד. קובץ ה js שוקל עכשיו 105 קילו בייט בגלל שהוא כולל גם את שתי הספריות jQuery ו Underscore.

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

  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      },
    },
  },

התוספת לוקחת את כל הקבצים שנטענו מתוך node_modules ותשים אותם בקובץ js נפרד בשם vendors. נפעיל את הבניה ונראה מה קיבלנו:

Hash: 3e7734e86c6ed1d0c219
Version: webpack 4.39.2
Time: 2665ms
Built at: 08/21/2019 11:36:15 AM
                          Asset       Size  Chunks             Chunk Names
                     index.html  357 bytes          [emitted]  
   main-4d5b37b03fea2e5c5b5a.js  208 bytes       0  [emitted]  main
runtime-546933d72ea999e0722e.js   1.46 KiB       1  [emitted]  runtime
vendors-7e66225b04994e7e4b23.js    104 KiB       2  [emitted]  vendors
Entrypoint main = runtime-546933d72ea999e0722e.js vendors-7e66225b04994e7e4b23.js main-4d5b37b03fea2e5c5b5a.js
[2] (webpack)/buildin/global.js 472 bytes {2} [built]
[3] (webpack)/buildin/module.js 497 bytes {2} [built]
[4] ./src/main.js + 1 modules 220 bytes {0} [built]
    | ./src/main.js 152 bytes [built]
    | ./src/car.js 58 bytes [built]
    + 2 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

עכשיו יש לנו שלושה קבצי JavaScript בפרויקט: הראשון נקרא main-4d5b37b03fea2e5c5b5a.js וכולל את הקוד שלי בלבד, השני נקרא vendors-7e66225b04994e7e4b23.js וכולל את הספריות jQuery ו underscore והשלישי נקרא runtime-546933d72ea999e0722e.js וכולל מידע מנהלי ש Webpack צריך בשביל לדעת איפה כל קובץ נמצא.

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

Hash: abd001b8bbbad408cec5
Version: webpack 4.39.2
Time: 789ms
Built at: 08/21/2019 11:38:21 AM
                          Asset       Size  Chunks             Chunk Names
                     index.html  357 bytes          [emitted]  
   main-330171d140d25b44fcae.js  207 bytes       0  [emitted]  main
runtime-546933d72ea999e0722e.js   1.46 KiB       1  [emitted]  runtime
vendors-7e66225b04994e7e4b23.js    104 KiB       2  [emitted]  vendors
Entrypoint main = runtime-546933d72ea999e0722e.js vendors-7e66225b04994e7e4b23.js main-330171d140d25b44fcae.js
[2] (webpack)/buildin/global.js 472 bytes {2} [built]
[3] (webpack)/buildin/module.js 497 bytes {2} [built]
[4] ./src/main.js + 1 modules 194 bytes {0} [built]
    | ./src/main.js 126 bytes [built]
    | ./src/car.js 58 bytes [built]
    + 2 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 2 hidden modules

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

4. ניקוי תיקיית dist מכל הקבצים הישנים שבנינו

כשאנחנו עובדים על הפרויקט כל Build הולך ליצור קבצים חדשים בתיקיית dist, אבל אף אחד לא מוחק את התיקיה בין Build-ים שונים ולכן לאורך זמן התיקיה תתמלא בקבצים עם Hash-ים שונים. דרך קלה להתמודד עם זה היא למחוק בעצמכם את תיקיית dist לפני כל בניה, ודרך אפילו עוד יותר קלה היא לשלב פלאגין של וובפאק שעושה את זה אוטומטית בשבילכם. הפלאגין נקרא clean-webpack-plugin, נתקין אותו עם:

$ npm install --save-dev clean-webpack-plugin

ואחרי הוספת הפלאגין להגדרות Webpack שלנו נקבל קובץ הגדרות שנראה כך:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: './src/main.js',
  output: {
    filename: '[name]-[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin(),
    new CleanWebpackPlugin(),
  ],
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      },
    },
  },
};

5. טעינת הקבצים לפי השם הייחודי מתוך קוד צד שרת באמצעות Manifest

באמצעות HTML Webpack Plugin יצרנו מתוך Webpack קובץ HTML באופן אוטומטי שכולל את הקישור לכל קבצי ה JavaScript וה CSS שיצרנו עם המזהים היחודיים שלהם. אבל מה יעשה מי שלא משתמש ב HTML Webpack Plugin? איך נדע בלעדיו מהם השמות האמיתיים של קבצי ה JavaScript וה CSS שיצרנו?

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

הפלאגין שיעזור לנו לכתוב את המניפסט לקובץ נקרא webpack-manifest-plugin. נתקין אותו עם:

$ npm install --save-dev webpack-manifest-plugin

נוסיף גם אותו לקובץ הגדרות ה Webpack שלנו ונקבל:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
var ManifestPlugin = require('webpack-manifest-plugin');


module.exports = {
  entry: './src/main.js',
  output: {
    filename: '[name]-[contenthash].js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new HtmlWebpackPlugin(),
    new CleanWebpackPlugin(),
    new ManifestPlugin(),
  ],
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      },
    },
  },
};

ואחרי בניית הפרויקט ייווצר לנו בתיקיית dist קובץ בשם manifest.json. זה תוכן הקובץ אצלי על המכונה:

{
  "main.js": "main-330171d140d25b44fcae.js",
  "runtime.js": "runtime-546933d72ea999e0722e.js",
  "vendors.js": "vendors-7e66225b04994e7e4b23.js",
  "index.html": "index.html"
}

אתם יכולים בקוד צד השרת שלכם לקרוא את תוכן הקובץ וכך לדעת מהם השמות האמיתיים של קבצי ה JavaScript וה CSS שנוצרו בבניה האחרונה.