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 queste sono solo le dimensioni del 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 decompresi 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 costosa da elaborare. 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 molto più risorse byte per byte 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 tecnica di questo tipo che migliora le prestazioni partizionando il codice JavaScript dell'applicazione in blocchi e pubblicandoli solo per le route di un'applicazione che ne hanno bisogno.

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

Cos'è lo scuotimento degli alberi?

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. L'eliminazione degli alberi consente di risolvere questo problema sfruttando il modo in cui le istruzioni import statiche estraggono 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 dev non cambia nulla, dato che viene comunque importato l'intero modulo. Nelle build di produzione, Webpack può essere configurato in modo da "scuotere" le esportazioni da moduli ES6 che non sono stati importati esplicitamente, riducendo così le dimensioni delle build di produzione. 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'ugificazione. 21,1 KB per un bundle specifico per l'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, 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 moduli ES6 in diversi modi, ma quelli come questo dovrebbero attirare la tua attenzione. Questa riga specifica dice "import tutto dal modulo utils, e lo inserisci in uno spazio dei nomi chiamato utils. La domanda importante da porsi è: "quanto contenuti ci sono 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? Controlliamo 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);
}

Su un file di 1300 righe con un gruppo di esportazioni, ne viene utilizzato solo uno. Ciò comporta l'invio di molto codice JavaScript inutilizzato.

Anche se questa app di esempio è un po' artificiosa, non cambia il fatto che questo tipo di scenario sintetico assomigli a opportunità di ottimizzazione effettive che potresti riscontrare in un'app web di produzione. Ora che hai identificato un'opportunità utile per l'agitazione degli alberi, come si fa effettivamente?

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'operazione di scuotimento degli alberi è più difficile per i moduli CommonJS, webpack non saprà cosa eliminare dai bundle se decidi di usarli. 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.

Attenzione agli effetti collaterali

Un altro aspetto da considerare quando si scuotono le dipendenze dall'app è se i moduli del progetto hanno effetti collaterali. Un esempio di effetto collaterale è la modifica di qualcosa al di fuori del proprio ambito da parte di una funzione, che rappresenta 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 è al di fuori del suo ambito.

Gli effetti collaterali si applicano anche ai moduli ES6 e questo è importante nel contesto dell'effetto "albero scosso". 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 del 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);
}

Questo dovrebbe essere tutto ciò che serve per far funzionare l'oscillazione degli alberi in questo esempio. 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 che lo scuotimento degli alberi ha esito positivo:

                 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 la quantità di tempo che lo script impiega per il download, 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 per sfruttare questa ottimizzazione nelle build di produzione e importando selettivamente solo ciò di cui la tua applicazione ha bisogno, manterrai proattivamente le dimensioni dei tuoi bundle di applicazioni il più possibile ridotti.

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.