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

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

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

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

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

סיכום

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

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

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