In che modo CommonJS sta aumentando le dimensioni dei tuoi bundle

Scopri in che modo i moduli CommonJS influiscono sul tree-shaking della tua applicazione

In questo post vedremo cos'è CommonJS e perché sta rendendo i bundle JavaScript più grandi del necessario.

Riepilogo: per assicurarti che il bundler possa ottimizzare correttamente la tua applicazione, evita di dipendere dai moduli CommonJS e utilizza la sintassi del modulo ECMAScript nell'intera applicazione.

Cos'è CommonJS?

CommonJS è uno standard del 2009 che ha stabilito convenzioni per i moduli JavaScript. Inizialmente era destinato all'utilizzo al di fuori del browser web, principalmente per le applicazioni lato server.

Con CommonJS puoi definire moduli, esportarne le funzionalità e importarli in altri moduli. Ad esempio, lo snippet seguente definisce un modulo che esporta cinque funzioni: add, subtract, multiply, divide e 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]);

In seguito, un altro modulo può importare e utilizzare alcune di queste funzioni o tutte:

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

L'invocazione di index.js con node restituisce il numero 3 nella console.

A causa della mancanza di un sistema di moduli standardizzato nel browser all'inizio degli anni 2010, CommonJS è diventato un formato di modulo popolare anche per le librerie lato client JavaScript.

In che modo CommonJS influisce sulle dimensioni del bundle finale?

Le dimensioni dell'applicazione JavaScript lato server non sono così importanti come nel browser, per questo motivo CommonJS non è stato progettato pensando alla riduzione delle dimensioni del bundle di produzione. Allo stesso tempo, l'analisi mostra che la dimensione del bundle JavaScript è ancora il motivo principale per cui le app del browser vengono rallentate.

I bundler e i minificatori JavaScript, come webpack e terser, eseguono diverse ottimizzazioni per ridurre le dimensioni dell'app. Analizzando l'applicazione al momento della compilazione, cercano di rimuovere il più possibile dal codice sorgente non utilizzato.

Ad esempio, nello snippet riportato sopra, il bundle finale deve includere solo la funzione add, poiché è l'unico simbolo di utils.js che importi in index.js.

Creiamo l'app utilizzando la seguente configurazione webpack:

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

Qui specifichiamo che vogliamo utilizzare le ottimizzazioni in modalità di produzione e utilizziamo index.js come punto di contatto. Dopo aver invocato webpack, se esploriamo la dimensione output, vedremo qualcosa di simile al seguente:

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

Tieni presente che il bundle è di 625 KB. Se esaminiamo l'output, troveremo tutte le funzioni di utils.js, oltre a molti moduli di lodash. Anche se non utilizziamo lodash in index.js, fa parte dell'output, il che aggiunge molto peso ai nostri asset di produzione.

Ora cambia il formato del modulo in moduli ECMAScript e riprova. Questa volta, utils.js avrà il seguente aspetto:

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

e index.js importerebbe da utils.js utilizzando la sintassi del modulo ECMAScript:

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

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

Utilizzando la stessa configurazione webpack, possiamo compilare la nostra applicazione e aprire il file di output. Ora è di 40 byte con il seguente output:

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

Tieni presente che il bundle finale non contiene nessuna delle funzioni di utils.js che non utilizziamo e non c'è traccia di lodash. Inoltre, terser (lo strumento di minificazione JavaScript utilizzato da webpack) ha inserito in linea la funzione add in console.log.

Una domanda lecita potrebbe essere: perché l'utilizzo di CommonJS causa un aumento del bundle di output di quasi 16.000 volte? Ovviamente, questo è un esempio pratico, in realtà la differenza di dimensioni potrebbe non essere così grande, ma è probabile che CommonJS aggiunga un peso significativo alla build di produzione.

I moduli CommonJS sono più difficili da ottimizzare in generale perché sono molto più dinamici dei moduli ES. Per assicurarti che il bundler e il minifier possano ottimizzare correttamente la tua applicazione, evita di dipendere dai moduli CommonJS e utilizza la sintassi del modulo ECMAScript nell'intera applicazione.

Tieni presente che, anche se utilizzi moduli ECMAScript in index.js, se il modulo che utilizzi è un modulo CommonJS, le dimensioni del bundle della tua app ne risentiranno.

Perché CommonJS ingrandisce la tua app?

Per rispondere a questa domanda, esamineremo il comportamento dell'evento ModuleConcatenationPlugin in webpack e, successivamente, parleremo dell'analisi statica. Questo plug-in concatena l'ambito di tutti i tuoi moduli in un'unica chiusura e consente al tuo codice di avere un tempo di esecuzione più rapido nel browser. Ad esempio:

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

Sopra abbiamo un modulo ECMAScript, che importiamo in index.js. Definiamo anche una funzione subtract. Possiamo creare il progetto utilizzando la stessa configurazione di webpack di cui sopra, ma questa volta disabiliteremo la minimizzazione:

const path = require('path');

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

Esaminiamo l'output prodotto:

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

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

Nell'output riportato sopra, tutte le funzioni si trovano nello stesso spazio dei nomi. Per evitare collisioni, webpack ha rinominato la funzione subtract in index.js in index_subtract.

Se un compressore elabora il codice sorgente riportato sopra, eseguirà le seguenti operazioni:

  • Rimuovi le funzioni inutilizzate subtract e index_subtract
  • Rimuovi tutti i commenti e gli spazi vuoti ridondanti
  • Inserisci in linea il corpo della funzione add nella chiamata console.log

Spesso gli sviluppatori fanno riferimento a questa rimozione delle importazioni inutilizzate come tree-shaking. L'eliminazione degli elementi inutilizzati è stata possibile solo perché webpack è stato in grado di capire in modo statico (in fase di compilazione) quali simboli importiamo da utils.js e quali simboli vengono esportati.

Questo comportamento è abilitato per impostazione predefinita per i moduli ES perché sono più analizzabili in modo statico rispetto a CommonJS.

Esamineremo lo stesso identico esempio, ma questa volta modifichiamo utils.js in modo da utilizzare CommonJS anziché i moduli 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]);

Questo piccolo aggiornamento cambierà notevolmente l'output. Dato che è troppo lungo per essere incorporato in questa pagina, ne ho condiviso solo una piccola parte:

...
(() => {

"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));

})();

Tieni presente che il bundle finale contiene del codice webpack "runtime": codice iniettato responsabile dell'importazione/esportazione delle funzionalità dai moduli in bundle. Questa volta, invece di inserire tutti i simboli di utils.js e index.js nello stesso spazio dei nomi, richiediamo dinamicamente, in fase di esecuzione, la funzione add utilizzando __webpack_require__.

Questo è necessario perché con CommonJS possiamo ottenere il nome dell'esportazione da un'espressione arbitraria. Ad esempio, il codice seguente è un costrutto assolutamente valido:

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

Non è possibile per il bundler sapere in fase di compilazione il nome del simbolo esportato, poiché ciò richiede informazioni disponibili solo in fase di esecuzione, nel contesto del browser dell'utente.

In questo modo, il minificatore non è in grado di capire cosa usa esattamente index.js dalle sue dipendenze, perciò non può eliminarlo. Osserveremo lo stesso identico comportamento anche per i moduli di terze parti. Se importiamo un modulo CommonJS da node_modules, la tua toolchain di compilazione non potrà ottimizzarlo correttamente.

Tree shaking con CommonJS

È molto più difficile analizzare i moduli CommonJS poiché sono dinamici per definizione. Ad esempio, la posizione di importazione nei moduli ES è sempre una stringa letterale, rispetto a CommonJS, dove è un'espressione.

In alcuni casi, se la libreria che utilizzi segue convenzioni specifiche su come utilizza CommonJS, è possibile rimuovere le esportazioni inutilizzate in fase di compilazione utilizzando un webpack plug-in di terze parti. Anche se questo plug-in aggiunge supporto per il tree-shaking, non copre tutti i diversi modi in cui le dipendenze potrebbero utilizzare CommonJS. Ciò significa che non ottengo le stesse garanzie dei moduli ES. Inoltre, aggiunge un costo aggiuntivo durante la procedura di compilazione oltre al comportamento predefinito di webpack.

Conclusione

Per garantire che il bundler possa ottimizzare correttamente l'applicazione, evita di dipendere dai moduli CommonJS e utilizza la sintassi del modulo ECMAScript nell'intera applicazione.

Ecco alcuni suggerimenti pratici per verificare di seguire il percorso ottimale:

  • Utilizza il plug-in node-resolve di Rollup.js e imposta il flag modulesOnly per specificare che vuoi dipendere solo dai moduli ECMAScript.
  • Utilizza il pacchetto is-esm per verificare che un pacchetto npm utilizzi i moduli ECMAScript.
  • Se utilizzi Angular, per impostazione predefinita riceverai un avviso se dipendono da moduli non tree-shakeable.