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

איך מודולים של CommonJS משפיעים על ה-tree-shaking של האפליקציה

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

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

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

חבילות 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 מוסיף משקל משמעותי לגרסה לפרודקשן.

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

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

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

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

// 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));**

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

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

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

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

מפתחים נוהגים לכנות את הסרת הייבוא שלא בשימוש כ-tree-shaking. אפשר היה לבצע את ה-tree-shaking רק כי 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 משתמש בו מהיחסים שלו עם קבצים אחרים, ולכן הוא לא יכול להסיר אותם באמצעות tree-shake. נראה את אותה התנהגות גם במודולים של צד שלישי. אם תייבאו מודול CommonJS מ-node_modules, כלי הפיתוח לא יוכלו לבצע אופטימיזציה שלו בצורה נכונה.

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

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

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

סיכום

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

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

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