In che modo CommonJS sta aumentando le dimensioni dei tuoi bundle

Scopri come i moduli CommonJS influiscono sulla scossa ad albero della tua applicazione

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

Riepilogo: per assicurarti che il bundler sia in grado di ottimizzare correttamente la tua 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 delle convenzioni per i moduli JavaScript. Inizialmente era destinato all'utilizzo al di fuori del browser web, principalmente per 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 potrà importare e utilizzare alcune o tutte le seguenti funzioni:

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

La chiamata di index.js con node restituirà 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 popolare formato di modulo anche per le librerie lato client di JavaScript.

In che modo CommonJS influisce sulle dimensioni finali del bundle?

Le dimensioni della tua applicazione JavaScript lato server non sono così importanti come nel browser; per questo motivo CommonJS non è stato progettato per ridurre le dimensioni del bundle di produzione. Allo stesso tempo, l'analisi mostra che le dimensioni del bundle JavaScript sono ancora il motivo principale per cui le app browser rallentano.

I bundler e i minificatori JavaScript, come webpack e terser, eseguono ottimizzazioni diverse per ridurre le dimensioni della tua app. Analizzando l'applicazione in fase di creazione, cercano di rimuovere il più possibile dal codice sorgente che non stai utilizzando.

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 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 utilizzare le ottimizzazioni della modalità di produzione e usare index.js come punto di ingresso. Dopo aver richiamato webpack, se esploreremo la dimensione output, vedremo qualcosa di simile a questo:

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

Osserva che il bundle è da 625 kB. Se analizzeremo 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 più peso ai nostri asset di produzione.

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

Inoltre, 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 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 alcuna delle funzioni di utils.js che non utilizziamo e non c'è traccia di lodash. Inoltre, terser (il minificatore JavaScript utilizzato da webpack) ha incorporato la funzione add in console.log.

Una domanda che potresti porti è: perché l'utilizzo di CommonJS fa sì che il bundle di output sia quasi 16.000 volte più grande? Ovviamente 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 il bundler e il minifier siano in grado di 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 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 del ModuleConcatenationPlugin in webpack e, successivamente, parleremo dell'analizzabilità 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 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 generato:

/******/ (() => { // 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 conflitti, il webpack ha rinominato la funzione subtract in index.js in index_subtract.

Se un minificatore elabora il codice sorgente sopra indicato:

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

Spesso gli sviluppatori fanno riferimento a questa rimozione delle importazioni inutilizzate come tremolio degli alberi. L'operazione di scuotimento degli alberi è stata possibile solo perché il webpack è stato in grado di comprendere in modo statico (al momento della creazione) quali simboli stiamo importando da utils.js e quali esporta.

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

Esaminiamo esattamente lo stesso esempio, ma questa volta cambia utils.js per 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 modificherà notevolmente l'output. Dato che l'incorporamento è troppo lungo in questa pagina, 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 un certo webpack "runtime": codice inserito che è responsabile dell'importazione/esportazione della funzionalità dai moduli in bundle. Questa volta, invece di collocare 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 è in grado di sapere, in fase di compilazione, il nome del simbolo esportato, poiché richiede informazioni 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 e non può quindi allontanarlo dall'albero. Osserveremo lo stesso comportamento anche per i moduli di terze parti. Se importiamo un modulo CommonJS da node_modules, la toolchain di build non sarà in grado di ottimizzarla correttamente.

Scuotimento degli 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 un valore letterale stringa, rispetto a CommonJS, dove è un'espressione.

In alcuni casi, se la libreria che stai usando segue convenzioni specifiche sull'utilizzo di CommonJS, è possibile rimuovere le esportazioni non utilizzate in fase di creazione utilizzando un plugin webpack di terze parti. Anche se questo plug-in aggiunge il supporto per l'albero di scuotimento, non copre tutti i diversi modi in cui le dipendenze possono utilizzare CommonJS. Ciò significa che non puoi ottenere le stesse garanzie dei moduli ES. Inoltre, oltre al comportamento predefinito di webpack, viene aggiunto un costo aggiuntivo durante il processo di compilazione.

Conclusione

Per assicurarti che il bundler sia in grado di ottimizzare correttamente la tua applicazione, evita di dipendere dai moduli CommonJS e utilizza la sintassi del modulo ECMAScript nell'intera applicazione.

Ecco alcuni suggerimenti pratici per verificare se sei sulla strada 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 dipendi da moduli che non possono essere scomposti ad albero.