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.
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.
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.
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):
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.
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.