הקטנת גודל ממשק הקצה

איך משתמשים ב-Webpack כדי שהאפליקציה תהיה קטנה ככל האפשר

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

שימוש במצב הייצור (webpack 4 בלבד)

ב-Webpack 4 נוסף הדגל החדש mode. אפשר להגדיר את הדגל הזה ל-'development' או ל-'production' כדי להעביר ל-webpack רמז שאתם מפתחים את האפליקציה לסביבה ספציפית:

// webpack.config.js
module.exports = {
  mode: 'production',
};

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

קריאה נוספת

הפעלת הקטנה

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

// Original code
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}

// Minified code
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l}

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

הקטנה ברמת החבילה

ההקטנה ברמת החבילה דוחסת את כל החבילה לאחר הידור. ככה זה עובד:

  1. כותבים את הקוד כך:

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. ה-Webpack מורכב ממערכות כאלה בערך:

    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
    console.log('Rendered!');
    }
    
  3. כלי מיני מכווץ אותם בערך כך:

    // minified bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    

ב-webpack 4,ההקטנה ברמת החבילה מופעלת באופן אוטומטי – גם במצב הייצור וגם בלי. ברקע, הכלי משתמש בממינימיזר של UglifyJS. (כדי להשבית את ההקטנה תצטרכו להשתמש במצב פיתוח או להעביר את false לאפשרות optimization.minimize).

ב-webpack 3 צריך להשתמש ישירות בפלאגין UglifyJS. הפלאגין מגיע בחבילה עם webpack. כדי להפעיל אותו, מוסיפים אותו לקטע plugins בתצורה:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

אפשרויות ספציפיות למטען

הדרך השנייה להקטנת הקוד היא אפשרויות ספציפיות לגורם הטעינה (מה זה Loader). בעזרת אפשרויות הטעינה אפשר לדחוס דברים שהכלי להקטנה לא יכול לדחוס. לדוגמה, כשמייבאים קובץ CSS עם css-loader, הקובץ עובר הידור למחרוזת:

/* comments.css */
.comment {
  color: black;
}
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

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

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};

קריאה נוספת

יש לציין NODE_ENV=production

דרך נוספת להקטין את הקצה הקדמי היא להגדיר את NODE_ENV משתנה הסביבה בקוד שלך לערך production.

הספריות קוראות את המשתנה NODE_ENV כדי לזהות באיזה מצב הן צריכות לפעול – בשלב הפיתוח או במצב הייצור. ספריות מסוימות פועלות באופן שונה בהתאם למשתנה הזה. לדוגמה, אם NODE_ENV לא מוגדר ל-production, Vue.js מבצע בדיקות נוספות ומדפיס אזהרות:

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// …

התגובה פועלת באופן דומה – היא טוענת גרסת פיתוח (build) שכוללת את האזהרות:

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
    componentClass.getDefaultProps.isReactClassApproved,
    'getDefaultProps is only used on classic React.createClass ' +
    'definitions. Use a static property named `defaultProps` instead.'
);
// …

בדרך כלל אין צורך בבדיקות ובאזהרות כאלה בשלב הייצור, אבל הן נשארות בקוד ומגדילות את גודל הספרייה. ב-webpack 4, מסירים אותם על ידי הוספת האפשרות optimization.nodeEnv: 'production':

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
};

ב-webpack 3,משתמשים במקום זאת ב-DefinePlugin:

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.optimize.UglifyJsPlugin()
  ]
};

האפשרות optimization.nodeEnv וגם האפשרות DefinePlugin פועלות באותו אופן – הם מחליפים את כל המופעים של process.env.NODE_ENV בערך שצוין. עם ההגדרות שלמעלה:

  1. ה-Webpack יחליף את כל המופעים של process.env.NODE_ENV ב-"production":

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    
  2. לאחר מכן, המיני-מקט יסיר את כל ההסתעפויות האלו ב-if – כי "production" !== 'production' תמיד מוגדר כ-False, והפלאגין מבין שהקוד שבתוך ההסתעפויות האלה אף פעם לא יבצע:

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    

    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    

קריאה נוספת

שימוש במודולים של ES

הדרך הבאה להקטין את הגודל של הקצה הקדמי היא להשתמש במודולים של ES.

כשמשתמשים במודולים של ES, webpack יכול לבצע רעידת עצים. 'ניעור עצים' הוא תהליך שבו הכלי לאיחוד חבילות עובר על כל עץ יחסי התלות, בודק אילו יחסי תלות נמצאים בשימוש ומסיר את אלה שלא נמצאים בשימוש. לכן, אם משתמשים בתחביר של מודול ES, webpack יכול להסיר את הקוד שלא בשימוש:

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

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack מבין שלא נעשה שימוש ב-commentRestEndpoint ולא יוצר נקודת ייצוא נפרדת בחבילה:

    // bundle.js (part that corresponds to comments.js)
    (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    const render = () => { return 'Rendered!'; };
    /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
    const commentRestEndpoint = '/rest/comments';
    /* unused harmony export commentRestEndpoint */
    })
    
  3. הכלי למזעור מסיר את המשתנה שלא נמצא בשימוש:

    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    

השיטה הזו פועלת גם בספריות אם הן כתובות באמצעות מודולים של ES.

עם זאת, אתם לא חייבים להשתמש במדויק ב-minifier המובנה של webpack‏ (UglifyJsPlugin). כל כלי מיני שתומך בהסרת קוד מת (למשל, הפלאגין של Babel Minify או הפלאגין של Google Closure Compiler) יעשה את העבודה.

קריאה נוספת

בצע אופטימיזציה לתמונות

התמונות תופסות יותר ממחצית מגודל הדף. אומנם הם לא חיוניים כמו JavaScript (למשל, הם לא חוסמים את העיבוד), אבל הם עדיין צורכים חלק גדול מרוחב הפס. כדי לבצע אופטימיזציה שלהם ב-webpack צריך להשתמש ב-url-loader, ב-svg-url-loader וב-image-webpack-loader.

הפונקציה url-loader מחילה קבצים סטטיים קטנים באפליקציה. ללא הגדרות אישיות, היא לוקחת את הקובץ שהועבר, מציבה אותו ליד החבילה שעברה הידור ומחזירה את כתובת ה-URL של הקובץ הזה. עם זאת, אם נציין את האפשרות limit, היא תקודד קבצים שקטנים מהמגבלה הזו ככתובת URL של נתוני Base64 ותחזיר את כתובת ה-URL הזו. כך התמונה מוטמעת בקוד ה-JavaScript ואפשר לחסוך בקשת HTTP:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loader פועל בדיוק כמו url-loader, אבל הוא מקודד קבצים באמצעות קידוד כתובות URL במקום באמצעות Base64. האפשרות הזו שימושית לתמונות SVG – כי קובצי SVG הם פשוט טקסט פשוט, והקידוד הזה יעיל יותר בגודל.

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: "svg-url-loader",
        options: {
          limit: 10 * 1024,
          noquotes: true
        }
      }
    ]
  }
};

image-webpack-loader דוחס את התמונות שעוברות דרכו. המערכת תומכת בתמונות בפורמטים JPG,‏ PNG,‏ GIF ו-SVG, ולכן נשתמש בה לכל סוגי התמונות האלה.

הטען הזה לא מטמיע תמונות באפליקציה, ולכן הוא צריך לפעול בשילוב עם url-loader ו-svg-url-loader. כדי למנוע העתקה שלו לשני הכללים (אחד לתמונות בפורמט JPG/PNG/GIF, ואחד לתמונות בפורמט SVG), נוסיף את כלי הטעינה הזה לכלל נפרד עם enforce: 'pre':

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre'
      }
    ]
  }
};

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

קריאה נוספת

אופטימיזציה של יחסי התלות

יותר ממחצית מהגודל הממוצע של JavaScript נובע מיחסי תלות, וחלק מהגודל הזה עשוי להיות מיותר.

לדוגמה, Lodash (החל מגרסה 4.17.4) יוסיף 72KB של קוד מוקטן לחבילה. אבל אם משתמשים רק ב-20 שיטות מהשיטה הזו, כ-65KB של קוד מוקטן לא תורמת פשוט.

דוגמה נוספת: Moment.js. בגרסה 2.19.1 היא משתמשת בקוד מוקטן בנפח של 223KB, שזה גודל עצום – הגודל הממוצע של JavaScript בדף היה 452KB באוקטובר 2017. עם זאת, 170KB מתוך הגודל הזה הם קובצי לוקליזציה. אם אתם לא משתמשים ב-Moment.js עם כמה שפות, הקבצים האלה יגדילו את הגודל של החבילה ללא צורך.

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

הפעלת שרשור מודולים למודולים של ES (נקרא גם העלאת היקף)

כשיוצרים חבילה, ה-webpack אורז כל מודול לפונקציה:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();
}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }
})

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

ב-Webpack 2 יש תמיכה במודולים של ES, שבניגוד למודולים של CommonJS ו-AMD, אפשר לקבץ יחד בלי לכווץ כל אחד מהם באמצעות פונקציה. ו-webpack 3 אפשרה את האפשרות הזו באמצעות שרשור מודולים. כך שרשור המודולים עושה:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
    function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();
})

רואה את ההבדלים? בחבילה הרגילה, מודול 0 דרש את render ממודול 1. כשמשתמשים בשרשור מודולים, require פשוט מוחלף בפונקציה הנדרשת, והמודול 1 מוסר. החבילה כוללת פחות מודולים – ופחות תקורת מודול!

כדי להפעיל את ההתנהגות הזו, ב-webpack 4 מפעילים את האפשרות optimization.concatenateModules:

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

ב-webpack 3, משתמשים ב-ModuleConcatenationPlugin:

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin()
  ]
};

קריאה נוספת

אם יש לך גם קוד של חבילת אינטרנט וגם קוד שלא של חבילת אינטרנט, צריך להשתמש ב-externals

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

צילום מסך של אתר לאירוח סרטונים
(אתר אקראי לגמרי לאירוח סרטונים)

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

אם יחסי תלות זמינים ב-window

אם הקוד שאינו של webpack מסתמך על יחסי תלות שזמינים כמשתנים ב-window, צריך להגדיר כינוי לשמות יחסי התלות בשמות משתנים:

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
};

עם ההגדרה הזו, webpack לא יאגד את החבילות react ו-react-dom. במקום זאת, הן יוחלפו בקוד כזה:

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

אם יחסי התלות נטענים כחבילות AMD

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

לשם כך, צריך להדר את הקוד של חבילת ה-webpack כחבילת AMD ומודולים של כינוי לכתובות ה-URL של הספרייה:

// webpack.config.js
module.exports = {
  output: {
    libraryTarget: 'amd'
  },
  externals: {
    'react': {
      amd: '/libraries/react.min.js'
    },
    'react-dom': {
      amd: '/libraries/react-dom.min.js'
    }
  }
};

ה-Webpack יכניס את החבילה ל-define() ויתבסס על כתובות ה-URL האלה:

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () {  });

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

קריאה נוספת

סיכום

  • הפעלת מצב הייצור אם משתמשים ב-webpack 4
  • צמצום הקוד באמצעות האפשרויות של המיניפייזר והמטען ברמת החבילה
  • כדי להסיר את הקוד לפיתוח בלבד, צריך להחליף את הקוד NODE_ENV בטקסט production
  • שימוש במודולים של ES כדי להפעיל רעידת עצים
  • דחיסת תמונות
  • החלת אופטימיזציות ספציפיות ליחסי תלות
  • הפעלת שרשור מודולים
  • מומלץ להשתמש בexternals, אם זה מתאים לך