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

כלים קטנים וחבילות של 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 "runtime": קוד שהוחדר שאחראי לייבוא/לייצוא של פונקציונליות מהמודולים בחבילה. הפעם, במקום להציב את כל הסמלים מ-utils.js ומ-index.js באותו מרחב שמות, אנחנו דורשים באופן דינמי, בזמן הריצה, את הפונקציה add באמצעות __webpack_require__.

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

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

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

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

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

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

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

סיכום

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

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

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