כיצד CommonJS מגדילים את החבילות שלך

איך מודולים של CommonJS משפיעים על רעידת העץ של האפליקציה

בפוסט הזה נסביר מהו CommonJS ולמה הוא מגדילה את חבילות ה-JavaScript שלכם ליותר מהנדרש.

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

מהו CommonJS?

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

ב-CommonJS אפשר להגדיר מודולים, לייצא מהם פונקציונליות ולייבא אותם במודולים אחרים. לדוגמה, קטע הקוד הבא מגדיר מודול שמייצא חמש פונקציות: add, subtract, multiply, divide ו-max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

לאחר מכן, מודול אחר יוכל לייבא חלק מהפונקציות הבאות או להשתמש בהן:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

הפעלה של index.js עם node תפיק את המספר 3 במסוף.

בגלל שבתחילת 2010 הייתה חסר מערכת מודולים סטנדרטית בדפדפן, לכן CommonJS הפך לפורמט מודול פופולרי גם בספריות בצד הלקוח של JavaScript.

איך CommonJS משפיע על גודל החבילה הסופי?

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

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

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

צריך ליצור את האפליקציה באמצעות ההגדרות הבאות של webpack:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

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

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

שימו לב שגודל החבילה הוא 625KB. אם נבדוק את הפלט, נמצא את כל הפונקציות מ-utils.js ועוד הרבה מודולים מ-lodash. אנחנו לא משתמשים ב-lodash ב-index.js, אבל זה חלק מהפלט, מה שמוביל לתוספת משקל לנכסים בסביבת הייצור.

עכשיו צריך לשנות את פורמט המודול למודולים של ECMAScript ולנסות שוב. הפעם, utils.js ייראה כך:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

וגם index.js ייובא מ-utils.js באמצעות תחביר מודול ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

באמצעות אותה הגדרה של webpack, אנחנו יכולים ליצור את האפליקציה ולפתוח את קובץ הפלט. הוא עכשיו 40 בייטים עם הפלט הבא:

(()=>{"use strict";console.log(1+2)})();

שימו לב שהחבילה הסופית לא מכילה אף אחת מהפונקציות מ-utils.js שאנחנו לא משתמשים בהן, ואין עקבות מ-lodash. בנוסף, terser (המצמצם של JavaScript שמשמש את webpack) הוסיף את הפונקציה add ב-console.log.

שאלה הוגנת שתוכלו לשאול היא: למה השימוש ב-CommonJS גורם לחבילת הפלט להיות גדולה כמעט פי 16,000? כמובן, זו דוגמה לצעצוע. בפועל, ייתכן שההבדל בגודל לא יהיה כל כך גדול, אבל רוב הסיכויים הם ש-CommonJS מוסיף משקל משמעותי לגרסת ה-build של סביבת הייצור.

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

שימו לב שגם אם אתם משתמשים במודולים של ECMAScript ב-index.js, אם המודול שאתם צורכים הוא מודול CommonJS, גודל החבילה של האפליקציה שלכם לא ישתנה.

למה CommonJS מגדילה את האפליקציה שלך?

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

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

למעלה יש לנו מודול ECMAScript, שאותו אנחנו מייבאים ב-index.js. אנחנו גם מגדירים פונקציה subtract. אנחנו יכולים ליצור את הפרויקט באמצעות אותן הגדרות webpack המפורטות למעלה, אבל הפעם נשבית את המזעור:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

נסתכל על הפלט שנוצר:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

בפלט שלמעלה, כל הפונקציות נמצאות באותו מרחב שמות. כדי למנוע התנגשויות, ה-webpack שינה את השם של הפונקציה subtract ב-index.js ל-index_subtract.

אם כלי המזעור מעבד את קוד המקור שלמעלה, הוא:

  • מסירים את הפונקציות subtract ו-index_subtract שלא נמצאות בשימוש
  • מסירים את כל התגובות ורווחים מיותרים
  • הטמעה בתוך הגוף של הפונקציה add בקריאה console.log

לעיתים קרובות מפתחים מתייחסים להסרת פריטים שלא נוצלו כייבוא עצים. רעידת העצים הייתה אפשרית רק כי Webpack הצליח להבין (בזמן build) באופן סטטי אילו סמלים מייבאים מ-utils.js ואילו סמלים הוא מייצא.

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

נבחן את אותה דוגמה בדיוק, אך הפעם משנים את utils.js לשימוש ב-CommonJS במקום במודולים של ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

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

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

חשוב לשים לב שהחבילה הסופית מכילה "זמן ריצה" מסוים מסוג webpack: קוד מוחדר שאחראי לייבוא ולייצוא של פונקציונליות מהמודולים בחבילה. הפעם, במקום להציב את כל הסמלים מ-utils.js ומ-index.js באותו מרחב שמות, אנחנו דורשים באופן דינמי בזמן הריצה את הפונקציה add באמצעות __webpack_require__.

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

module.exports[localStorage.getItem(Math.random())] = () => { … };

ל-bundler אין דרך לדעת בזמן ה-build מה שם הסמל המיוצא, כי לשם כך נדרש מידע שזמין רק בזמן הריצה, בהקשר של הדפדפן של המשתמש.

כך הכלי למזער לא יכול להבין במה בדיוק index.js משתמש מיחסי התלות שלו, ולכן לא יוכל למנענע אותו. נבחין באותה התנהגות גם במודולים של צד שלישי. אם נייבא מודול CommonJS מ-node_modules, מערכת הכלים של ה-build לא תוכל לבצע אופטימיזציה כראוי.

רעידות עצים באמצעות CommonJS

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

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

סיכום

כדי להבטיח שה-bundler יוכל לבצע אופטימיזציה של האפליקציה, חשוב להימנע משימוש במודולים של CommonJS ולהשתמש בתחביר של מודול ECMAScript בכל האפליקציה.

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

  • צריך להשתמש ב-node-resolve של Rollup.js את הפלאגין ולהגדיר את הדגל modulesOnly כדי לציין שרוצים להיות תלויים רק במודולים של ECMAScript.
  • שימוש בחבילה is-esm כדי לוודא שחבילת NPM משתמשת במודולים של ECMAScript.
  • אם משתמשים ב-Agular, תוצג כברירת מחדל אזהרה אם המודולים תלויים במודולים שלא ניתנים לניעור של עץ.