Riduci i payload JavaScript con lo scuotimento della struttura ad albero

Le applicazioni web di oggi possono essere piuttosto grandi, in particolare la parte in JavaScript. A partire da metà 2018, HTTP Archive indica che le dimensioni medie di trasferimento di JavaScript sui dispositivi mobili sono di circa 350 KB. E questo è solo il volume di trasferimento. JavaScript viene spesso compresso quando viene inviato tramite la rete, il che significa che la quantità di JavaScript effettiva è molto maggiore dopo che il browser lo decomprime. È importante sottolineare questo aspetto perché, per quanto riguarda l'elaborazione delle risorse, la compressione è irrilevante. 900 KB di codice JavaScript decompresso sono comunque 900 KB per l'interprete e il compilatore, anche se potrebbero essere circa 300 KB se compressi.

Un diagramma che illustra il processo di download, decompressione, analisi, compilazione ed esecuzione di JavaScript.
La procedura di download ed esecuzione di JavaScript. Tieni presente che, anche se le dimensioni di trasferimento dello script sono 300 KB compressi, si tratta comunque di 900 KB di codice JavaScript che devono essere analizzati, compilati ed eseguiti.

JavaScript è una risorsa di cui è costoso elaborare i dati. A differenza delle immagini, che richiedono solo un tempo di decodifica relativamente irrilevante una volta scaricate, JavaScript deve essere analizzato, compilato e infine eseguito. Byte per byte, questo rende JavaScript più costoso di altri tipi di risorse.

Un diagramma che confronta il tempo di elaborazione di 170 KB di codice JavaScript rispetto a un'immagine JPEG di dimensioni equivalenti. La risorsa JavaScript richiede una quantità di risorse molto maggiore rispetto al JPEG.
Il costo di elaborazione dell'analisi/compilazione di 170 KB di codice JavaScript rispetto al tempo di decodifica di un file JPEG di dimensioni equivalenti. (source).

Sebbene vengano apportati continui miglioramenti per migliorare l'efficienza degli engine JavaScript, il miglioramento delle prestazioni di JavaScript è, come sempre, compito degli sviluppatori.

A tal fine, esistono tecniche per migliorare le prestazioni di JavaScript. La suddivisione del codice è una di queste tecniche che migliora le prestazioni suddividendo il codice JavaScript dell'applicazione in blocchi e pubblicando questi blocchi solo nelle route di un'applicazione che ne hanno bisogno.

Sebbene questa tecnica funzioni, non risolve un problema comune delle applicazioni con un elevato utilizzo di JavaScript, ovvero l'inclusione di codice che non viene mai utilizzato. L'operazione di tree shaking tenta di risolvere questo problema.

Che cos'è il tree shaking?

Il tree shaking è una forma di eliminazione del codice inutilizzato. Il termine è stato reso popolare da Rollup, ma il concetto di eliminazione del codice morto esiste da un po' di tempo. Il concetto è stato adottato anche in webpack, come dimostrato in questo articolo tramite un'app di esempio.

Il termine "tree shaking" deriva dal modello mentale della tua applicazione e delle sue dipendenze come struttura ad albero. Ogni nodo dell'albero rappresenta una dipendenza che fornisce funzionalità distinte per l'app. Nelle app moderne, queste dipendenze vengono importate tramite istruzioni import statiche come segue:

// Import all the array utilities!
import arrayUtils from "array-utils";

Quando un'app è giovane, può avere poche dipendenze. Inoltre, utilizza la maggior parte, se non tutte, le dipendenze che aggiungi. Tuttavia, man mano che l'app matura, è possibile aggiungere altre dipendenze. A complicare le cose, le dipendenze precedenti non vengono più utilizzate, ma potrebbero non essere eliminate dal codice di base. Il risultato finale è che un'app viene rilasciata con molto JavaScript inutilizzato. Il tree shaking risolve questo problema sfruttando il modo in cui le istruzioni import statiche importano parti specifiche dei moduli ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

La differenza tra questo esempio di import e quello precedente è che, anziché importare tutto dal modulo "array-utils", che potrebbe essere molto codice, questo esempio ne importa solo parti specifiche. Nelle build di sviluppo, questo non cambia nulla, poiché l'intero modulo viene importato indipendentemente. Nelle build di produzione, webpack può essere configurato per "eliminare" le esportazioni dai moduli ES6 che non sono stati importati esplicitamente, rendendo queste build di produzione più piccole. In questa guida scoprirai come fare.

Trovare opportunità per scuotere un albero

A scopo illustrativo, è disponibile un'app di esempio di una pagina che mostra come funziona lo shaking dell'albero. Se vuoi, puoi clonarlo e seguire la procedura, ma in questa guida illustreremo ogni passaggio, quindi la clonazione non è necessaria (a meno che tu non preferisca l'apprendimento pratico).

L'app di esempio è un database di pedali per effetti per chitarra in cui è possibile eseguire ricerche. Inserisci una query e viene visualizzato un elenco di pedali effetti.

Uno screenshot di un'applicazione di esempio di una pagina per la ricerca in un database di pedali per effetti per chitarra.
Uno screenshot dell'app di esempio.

Il comportamento che guida questa app è suddiviso in fornitore (ovvero Preact ed Emotion) e bundle di codice specifici per l'app (o "chunk", come li chiama webpack):

Uno screenshot di due pacchetti (o chunk) di codice dell'applicazione mostrati nel riquadro della rete di DevTools di Chrome.
I due bundle JavaScript dell'app. Si tratta di dimensioni non compresse.

I bundle JavaScript mostrati nella figura sopra sono build di produzione, il che significa che sono ottimizzati tramite l'uglify. 21,1 KB per un bundle specifico per l'app non è male, ma va notato che non si verifica alcun tree shaking. Vediamo il codice dell'app e vediamo cosa si può fare per risolvere il problema.

In qualsiasi applicazione, trovare opportunità di tree shaking comporterà la ricerca di istruzioni import statiche. Nella parte superiore del file del componente principale, vedrai una riga simile alla seguente:

import * as utils from "../../utils/utils";

Puoi importare i moduli ES6 in diversi modi, ma quelli come questo dovrebbero attirare la tua attenzione. Questa riga specifica dice "import tutto del modulo utils e inseriscilo in uno spazio dei nomi denominato utils". La domanda più importante da porsi è: "quanto contenuto è presente in quel modulo?"

Se esamini il codice sorgente del modulo utils, noterai che ci sono circa 1300 righe di codice.

Hai bisogno di tutto questo? Verifichiamo cercando nel file del componente principale che importa il modulo utils per vedere quante istanze di questo spazio dei nomi vengono visualizzate.

Uno screenshot di una ricerca in un editor di testo per "utils", che restituisce solo 3 risultati.
Lo spazio dei nomi utils da cui abbiamo importato tonnellate di moduli viene invocato solo tre volte all'interno del file del componente principale.

A quanto pare, lo spazio dei nomi utils viene visualizzato solo in tre punti della nostra applicazione, ma per quali funzioni? Se dai un'altra occhiata al file del componente principale, sembra che ci sia una sola funzione, utils.simpleSort, che viene utilizzata per ordinare l'elenco dei risultati di ricerca in base a una serie di criteri quando vengono modificati i menu a discesa di ordinamento:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

In un file di 1300 righe con una serie di esportazioni, ne viene utilizzata solo una. Ciò comporta l'invio di molto codice JavaScript inutilizzato.

Sebbene questa app di esempio sia un po' artificiosa, non cambia il fatto che questo tipo di scenario sintetico assomigli a opportunità di ottimizzazione reali che potresti riscontrare in un'app web di produzione. Ora che hai identificato un'opportunità per cui l'eliminazione degli alberi è utile, come si esegue?

Impedire a Babel di transpilare i moduli ES6 in moduli CommonJS

Babel è uno strumento indispensabile, ma potrebbe rendere un po' più difficile osservare gli effetti del tremore degli alberi. Se utilizzi @babel/preset-env, Babel potrebbe trasformare i moduli ES6 in moduli CommonJS più compatibili, ovvero moduli che require anziché import.

Poiché l'eliminazione degli elementi non necessari è più difficile da eseguire per i moduli CommonJS, webpack non saprà cosa rimuovere dai bundle se decidi di utilizzarli. La soluzione è configurare @babel/preset-env in modo che lasci invariati i moduli ES6. Ovunque tu configuri Babel, che sia in babel.config.js o package.json, devi aggiungere un piccolo extra:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Se specifichi modules: false nella configurazione @babel/preset-env, Babel si comporta come previsto, il che consente a webpack di analizzare l'albero delle dipendenze ed eliminare le dipendenze inutilizzate.

Tenere presente gli effetti collaterali

Un altro aspetto da considerare quando elimini le dipendenze dalla tua app è se i moduli del progetto hanno effetti collaterali. Un esempio di effetto collaterale si verifica quando una funzione modifica qualcosa al di fuori del proprio ambito, che è un effetto collaterale della sua esecuzione:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

In questo esempio, addFruit produce un effetto collaterale quando modifica l'array fruits, che non rientra nel suo ambito.

Gli effetti collaterali si applicano anche ai moduli ES6 e questo è importante nel contesto del tree shaking. I moduli che accettano input prevedibili e producono output altrettanto prevedibili senza modificare nulla al di fuori del proprio ambito sono dipendenze che possono essere eliminate in sicurezza se non le utilizziamo. Si tratta di pezzi di codice modulari e autonomi. Da qui, "moduli".

Per quanto riguarda webpack, è possibile utilizzare un suggerimento per specificare che un pacchetto e le relative dipendenze sono privi di effetti collaterali specificando "sideEffects": false nel file package.json di un progetto:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

In alternativa, puoi indicare a webpack quali file specifici non sono privi di effetti collaterali:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

Nell'ultimo esempio, si presume che tutti i file non specificati siano privi di effetti collaterali. Se non vuoi aggiungerlo al file package.json, puoi anche specificare questo flag nella configurazione di webpack tramite module.rules.

Importazione solo di ciò che è necessario

Dopo aver chiesto a Babel di non modificare i moduli ES6, è necessario un leggero aggiustamento alla sintassi di import per importare solo le funzioni necessarie dal modulo utils. Nell'esempio di questa guida, è sufficiente la funzione simpleSort:

import { simpleSort } from "../../utils/utils";

Poiché viene importato solo simpleSort anziché l'intero modulo utils, ogni istanza di utils.simpleSort dovrà essere modificata in simpleSort:

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

In questo esempio dovrebbe essere tutto ciò che serve per far funzionare lo tree shaking. Questo è l'output di webpack prima di scuotere l'albero delle dipendenze:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

Questo è l'output dopo il completamento dell'operazione di tree shaking:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

Sebbene entrambi i pacchetti siano diminuiti, è il pacchetto main a trarre il maggior vantaggio. Eliminando le parti inutilizzate del modulo utils, il bundle main si riduce di circa il 60%. In questo modo, non solo si riduce il tempo necessario per il download dello script, ma anche il tempo di elaborazione.

Vai a scuotere qualche albero.

I risultati che ottieni dall'eliminazione degli alberi dipendono dalla tua app, dalle sue dipendenze e dalla sua architettura. Prova Se sai con certezza di non aver configurato il bundler dei moduli per eseguire questa ottimizzazione, non c'è nulla di male a provare e vedere in che modo può essere utile per la tua applicazione.

Potresti ottenere un aumento significativo del rendimento o quasi. Tuttavia, configurando il sistema di compilazione in modo da sfruttare questa ottimizzazione nelle build di produzione e importando in modo selettivo solo ciò che è necessario per l'applicazione, manterrai in modo proattivo i bundle dell'applicazione il più piccoli possibile.

Un ringraziamento speciale a Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone e Philip Walton per il loro prezioso feedback, che ha migliorato notevolmente la qualità di questo articolo.