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

איך משתמשים ב-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 יש שתי דרכים לבצע אופטימיזציה של הקוד: אופטימיזציה ברמת החבילה ואפשרויות ספציפיות למטען. צריך להשתמש בהם בו-זמנית.

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

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

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

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    
  2. Webpack יוצר ממנו קובץ משולב (compiled) שנראה בערך כך:

    // 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(),
  ],
};

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

הדרך השנייה לצמצום הקוד היא באמצעות אפשרויות ספציפיות לטען (מהו טען). בעזרת אפשרויות הטעינה אפשר לדחוס דברים שהכלי להקטנה לא יכול לדחוס. לדוגמה, כשמייבאים קובץ 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.');
}
// …

גם React פועל באופן דומה – הוא טוען גרסה מפותחת של 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 (למשל, הם לא חוסמים את העיבוד), אבל הם עדיין צורכים חלק גדול מרוחב הפס. משתמשים ב-url-loader, ב-svg-url-loader וב-image-webpack-loader כדי לבצע אופטימיזציה שלהם ב-webpack.

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

כדי לעשות זאת, צריך לקמפל את קוד ה-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 כדי לטעון את יחסי התלות שלו, הקבצים האלה ייטענו רק פעם אחת – בקשות נוספות ישתמשו במטמון של ה-loader.

קריאה נוספת

סיכום

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