In che modo CommonJS sta aumentando le dimensioni dei tuoi bundle

Scopri in che modo i moduli CommonJS stanno influenzando l'eliminazione del "albero della tua applicazione" nella 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 l'applicazione, evita di dipendere dai moduli CommonJS e utilizza la sintassi del modulo ECMAScript nell'intera applicazione.

Che cos'è CommonJS?

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

Con CommonJS puoi definire i moduli, esportarli 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));

Se richiami index.js con node, nella console verrà restituito il numero 3.

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 finali del bundle?

Le dimensioni dell'applicazione JavaScript lato server non sono così importanti come quelle del 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. L'analisi dell'applicazione al momento della creazione tenta di rimuovere il più possibile il codice sorgente che non stai utilizzando.

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

Creiamo l'app utilizzando la seguente configurazione di 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 usare le ottimizzazioni della modalità di produzione e usare index.js come punto di ingresso. Dopo aver richiamato webpack, se esploriamo la dimensione di output, vedremo qualcosa di simile a questo:

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

Tieni presente che il bundle ha una dimensione di 625 kB. Se esaminiamo l'output, vediamo tutte le funzioni di utils.js più 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 cambiamo il formato del modulo in moduli ECMAScript e riprova. Questa volta, utils.js avrebbe 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 di webpack, possiamo creare l'applicazione e aprire il file di output. Ora è di 40 byte con il seguente output:

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

Nota che il bundle finale non contiene nessuna delle funzioni di utils.js che non utilizziamo e che non c'è alcuna traccia da lodash. Inoltre, terser (il minificatore JavaScript utilizzato da webpack) ha incorporato la funzione add in console.log.

Una domanda che potresti chiederti è: perché l'uso di CommonJS causa una dimensione del bundle di output quasi 16.000 volte più grande? Naturalmente, questo è un esempio di giocattolo. 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 bundler e minifier possano ottimizzare correttamente l'applicazione, evita di dover 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 stai utilizzando è un modulo CommonJS, le dimensioni del bundle dell'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));

Qui sopra è presente un modulo ECMAScript, che viene importato 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',
};

Vediamo 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 precedente, tutte le funzioni si trovano all'interno dello stesso spazio dei nomi. Per evitare collisioni, il pacchetto webpack ha rinominato la funzione subtract in index.js in index_subtract.

Se un minificatore elabora il codice sorgente riportato sopra, procede in questo modo:

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

Spesso gli sviluppatori fanno riferimento a questa rimozione delle importazioni inutilizzate con il termine "scuotimento degli alberi". L'eliminazione degli alberi è stata possibile solo perché webpack è riuscita a comprendere in modo statico (al momento della creazione) quali simboli vengono importati da utils.js e quali simboli esporta.

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

Osserviamo esattamente lo stesso esempio, ma questa volta cambieremo utils.js per utilizzare i moduli CommonJS anziché 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));

})();

Nota che il bundle finale contiene alcuni "runtime" webpack: codice inserito responsabile dell'importazione/esportazione delle funzionalità dai moduli del bundle. Questa volta, invece di posizionare tutti i simboli di utils.js e index.js nello stesso spazio dei nomi, richiediamo in modo dinamico, in fase di runtime, la funzione add che utilizza __webpack_require__.

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

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

Il bundler non può sapere in fase di build qual è il nome del simbolo esportato in quanto questo richiede informazioni che sono disponibili solo in fase di runtime, 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. Il comportamento è lo stesso anche per i moduli di terze parti. Se importi un modulo CommonJS da node_modules, la tua toolchain di build non sarà in grado di ottimizzarlo correttamente.

Trekking tra gli alberi 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 per l'utilizzo di CommonJS, è possibile rimuovere le esportazioni non utilizzate in fase di creazione utilizzando un plug-in webpack 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 nel processo 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 essere sulla strada giusta:

  • Utilizzare la funzionalità node-resolve di Rollup.js. e impostare il flag modulesOnly per specificare che si vuole dipendere solo dai moduli ECMAScript.
  • Utilizzare il pacchetto is-esm per verificare che un pacchetto npm utilizzi i moduli ECMAScript.
  • Se utilizzi Angular, per impostazione predefinita riceverai un avviso se dipendi da moduli non ad albero.