Le applicazioni web di oggi possono diventare piuttosto grandi, soprattutto la parte JavaScript. A metà del 2018, HTTP Archive ha stimato che la dimensione media del trasferimento di JavaScript sui dispositivi mobili è di circa 350 kB. E questa è solo la dimensione del trasferimento. JavaScript viene spesso compresso quando viene inviato tramite la rete, il che significa che la quantità effettiva di JavaScript è notevolmente maggiore dopo che il browser lo decomprime. È importante sottolinearlo perché, per quanto riguarda l'elaborazione delle risorse, la compressione è irrilevante. 900 kB di JavaScript decompresso sono comunque 900 kB per il parser e il compilatore, anche se potrebbero essere circa 300 kB quando sono compressi.
JavaScript è una risorsa costosa da elaborare. A differenza delle immagini, che comportano solo un tempo di decodifica relativamente banale una volta scaricate, JavaScript deve essere analizzato, compilato e infine eseguito. Byte per byte, questo rende JavaScript più costoso rispetto ad altri tipi di risorse.
Sebbene vengano continuamente apportati miglioramenti per aumentare l'efficienza dei motori JavaScript, il miglioramento delle prestazioni di JavaScript è, come sempre, un compito degli sviluppatori.
A questo scopo, esistono tecniche per migliorare le prestazioni di JavaScript. La suddivisione del codice è una di queste tecniche che migliora le prestazioni suddividendo JavaScript dell'applicazione in blocchi e pubblicando questi blocchi solo per le route di un'applicazione che ne hanno bisogno.
Sebbene questa tecnica funzioni, non risolve un problema comune delle applicazioni con un uso intensivo di JavaScript, ovvero l'inclusione di codice che non viene mai utilizzato. Il 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 inutilizzato esiste da 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 dell'applicazione e delle relative 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, un germoglio, per così dire, potrebbe 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. Inoltre, le dipendenze precedenti non vengono più utilizzate, ma potrebbero non essere eliminate dal codebase. Il risultato finale è che un'app finisce per essere distribuita con una grande quantità di 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 import e il precedente è che, anziché importare tutto dal modulo "array-utils" (che potrebbe essere una grande quantità di codice), questo esempio 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, riducendo le dimensioni di queste build di produzione. In questa guida scoprirai come fare.
Trovare opportunità di tree shaking
A scopo illustrativo, è disponibile un'app di esempio a pagina singola che mostra il funzionamento del tree shaking. Se vuoi, puoi clonarla e seguire le istruzioni, ma in questa guida tratteremo ogni passaggio insieme, quindi la clonazione non è necessaria (a meno che tu non preferisca l'apprendimento pratico).
L'app di esempio è un database ricercabile di pedali per effetti di chitarra. Inserisci una query e verrà visualizzato un elenco di pedali per effetti.
Il comportamento che guida questa app è suddiviso in bundle di codice specifici del fornitore (ad es. Preact ed Emotion) e dell'app (o "blocchi", come li chiama webpack):
I bundle JavaScript mostrati nella figura sopra sono build di produzione, il che significa che sono ottimizzati tramite l'offuscamento. 21,1 kB per un bundle specifico dell'app non è male, ma va notato che non si verifica alcun tree shaking. Diamo un'occhiata al codice dell'app e vediamo cosa si può fare per risolvere il problema.
In qualsiasi applicazione, per trovare opportunità di tree shaking è necessario cercare le 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 vari modi, ma quelli come questo dovrebbero attirare la tua attenzione. Questa riga specifica indica di "import tutto dal modulo utils, e di inserirlo in uno spazio dei nomi chiamato utils." La domanda principale da porsi qui è: "quanta roba c'è in questo modulo?"
Se esamini il codice sorgente del modulo utils, vedrai che contiene circa 1300 righe di codice.
Hai bisogno di tutto questo? Verifichiamo cercando il file del componente principale che importa il modulo utils per vedere quante istanze di questo spazio dei nomi vengono visualizzate.
utils da cui abbiamo importato tantissimi moduli viene richiamato 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 esamini di nuovo il file del componente principale, sembra che sia solo una funzione, ovvero utils.simpleSort, che viene utilizzata per ordinare l'elenco dei risultati di ricerca in base a una serie di criteri quando vengono modificati gli elenchi 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);
}
Di un file di 1300 righe con una serie di esportazioni, ne viene utilizzata solo una. Di conseguenza, viene distribuita una grande quantità di JavaScript inutilizzato.
Sebbene questa app di esempio sia un po' artificiosa, non cambia il fatto che questo tipo di scenario sintetico assomiglia alle opportunità di ottimizzazione effettive che potresti incontrare in un'app web di produzione. Ora che hai identificato un'opportunità per cui il tree shaking può essere utile, come si fa?
Impedire a Babel di traspolare i moduli ES6 in moduli CommonJS
Babel è uno strumento indispensabile, ma potrebbe rendere un po' più difficile osservare gli effetti del tree shaking. Se utilizzi @babel/preset-env, Babel potrebbe trasformare i moduli ES6 in moduli CommonJS più ampiamente compatibili, ovvero moduli che require anziché import.
Poiché il tree shaking è più difficile da eseguire per i moduli CommonJS, webpack non saprà cosa eliminare dai bundle se decidi di utilizzarli. La soluzione è configurare @babel/preset-env in modo da lasciare esplicitamente i moduli ES6. Ovunque tu configuri Babel, 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 dall'app è se i moduli del progetto hanno effetti collaterali. Un esempio di effetto collaterale è 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 si trova al di fuori del 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. Sono parti di codice autonome e modulari. Da qui il termine "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 presuppone che tutti i file non specificati siano privi di effetti collaterali. Se non vuoi aggiungere questo al file package.json, puoi anche specificare questo flag nella configurazione di webpack tramite module.rules.
Importare solo ciò che è necessario
Dopo aver indicato a Babel di lasciare i moduli ES6, è necessario apportare una piccola modifica alla sintassi import per importare solo le funzioni necessarie dal modulo utils. Nell'esempio di questa guida, è necessaria solo 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);
}
Questo dovrebbe essere tutto ciò che è necessario per il funzionamento del tree shaking in questo esempio. Questo è l'output di webpack prima di eliminare 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 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 bundle siano stati ridotti, è il bundle main a trarne 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 di download dello script, ma anche il tempo di elaborazione.
Inizia a eliminare le dipendenze!
Il vantaggio che otterrai dal tree shaking dipende dall'app, dalle relative dipendenze e dall'architettura. Prova Se sai per certo di non aver configurato il bundler di moduli per eseguire questa ottimizzazione, non c'è alcun danno a provare e vedere come può avvantaggiare la tua applicazione.
Potresti ottenere un aumento significativo delle prestazioni grazie al tree shaking o non molto. Tuttavia, configurando il sistema di compilazione in modo da sfruttare questa ottimizzazione nelle build di produzione e importando selettivamente solo ciò di cui ha bisogno 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.