Riduci i payload JavaScript con lo scuotimento della struttura ad albero

Le applicazioni web odierne possono diventare piuttosto grandi, in particolare la loro parte JavaScript. A partire dalla metà del 2018, HTTP Archive imposta le dimensioni mediane di trasferimento di JavaScript sui dispositivi mobili a circa 350 kB. Queste sono solo le dimensioni del trasferimento. JavaScript è spesso compresso quando viene inviato attraverso la rete, il che significa che la quantità effettiva di JavaScript è leggermente superiore dopo che il browser l'ha decompressa. È importante sottolineare che, per quanto riguarda l'elaborazione delle risorse, la compressione è irrilevante. 900 KB di JavaScript decompresso sono ancora 900 KB per l'analizzatore sintattico e il compilatore, anche se possono essere circa 300 KB quando vengono compressi.

Un diagramma che illustra la procedura di download, decompressione, analisi, compilazione ed esecuzione di JavaScript.
Il processo di download e di esecuzione di JavaScript. Nota: anche se le dimensioni di trasferimento dello script sono compresse a 300 kB, equivale comunque a 900 kB di JavaScript che deve essere analizzato, compilato ed eseguito.

JavaScript è una risorsa costosa da elaborare. A differenza delle immagini che prevedono tempi di decodifica relativamente banali una volta scaricate, JavaScript deve essere analizzato, compilato ed eseguito. Byte per byte, questo rende JavaScript più costoso rispetto ad altri tipi di risorse.

Diagramma che mette a confronto il tempo di elaborazione di 170 kB di JavaScript con quello di un'immagine JPEG di dimensioni equivalenti. La risorsa JavaScript consuma molto più risorse per byte rispetto a JPEG.
Il costo di elaborazione dell'analisi/compilazione di 170 kB di JavaScript rispetto al tempo di decodifica di un file JPEG di dimensioni equivalenti. (fonte).

Mentre vengono apportati continui miglioramenti per migliorare 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 tecnica di questo tipo che migliora le prestazioni partizionando il codice JavaScript dell'applicazione in blocchi e pubblicando questi blocchi solo nelle route di un'applicazione che ne ha bisogno.

Sebbene questa tecnica funzioni, non risolve un problema comune delle applicazioni con uso intensivo di JavaScript, ovvero l'inclusione di codice mai utilizzato. La scuotimento degli alberi tenta di risolvere questo problema.

Che cosa sono le scosse degli alberi?

Lo scuotimento degli alberi è una forma di eliminazione dei codici morti. Il termine è stato reso popolare da Rollup, ma il concetto di eliminazione di codice non valido esiste da un po' di tempo. Il concetto ha rilevato anche l'acquisto in webpack, come dimostrato in questo articolo tramite un'app di esempio.

Il termine "trees shaking" deriva dal modello mentale dell'applicazione e dalle sue dipendenze in una struttura ad albero. Ogni nodo nella struttura ad albero rappresenta una dipendenza che fornisce funzionalità distinte per la tua app. Nelle app moderne, queste dipendenze vengono inserite tramite istruzioni import statiche, ad esempio:

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

Quando un'app è giovane (un alberello, se vuoi), potrebbe avere poche dipendenze. Sta anche utilizzando la maggior parte delle dipendenze, se non tutte. Man mano che la tua app matura, tuttavia, possono essere aggiunte altre dipendenze. Per aggravare le questioni, le dipendenze meno recenti non sono più in uso, ma potrebbero non essere eliminate dal codebase. Il risultato finale è che un'app viene spedito con una grande quantità di JavaScript non utilizzato. La scossa ad albero consente di risolvere questo problema sfruttando il modo in cui le istruzioni import statiche richiamano 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 quello precedente è che, anziché importare tutto dal modulo "array-utils", che potrebbe richiedere una quantità di codice elevato, questo esempio ne importa solo parti specifiche. Nelle build dev non cambia nulla, dato che in ogni caso viene importato l'intero modulo. Nelle build di produzione, il webpack può essere configurato in modo da "scuotere" le esportazioni dai moduli ES6 che non sono stati importati esplicitamente, riducendo così le dimensioni delle build di produzione. In questa guida, imparerai come fare.

Trovare opportunità per scuotere un albero

A scopo illustrativo, è disponibile un'app di esempio di una pagina che dimostra come funziona la scossa degli alberi. Puoi clonarlo e seguirlo se vuoi, ma in questa guida tratteremo tutti i passaggi del percorso, quindi la clonazione non è necessaria (a meno che tu non voglia imparare a fare pratica).

L'app di esempio è un database disponibile per la ricerca di pedali per effetti per chitarra. Se inserisci una query, viene visualizzato un elenco di pedali degli effetti.

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

Il comportamento alla base di questa app è separato in base al fornitore (ad esempio, Preact ed Emotion) e bundle di codice specifici dell'app (o "blocchi", come li chiama il webpack):

Uno screenshot di due blocchi di codice delle applicazioni (o blocchi) mostrati nel riquadro di 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, ovvero sono ottimizzate attraverso l'uglificazione. 21,1 kB per un bundle specifico dell'app non sono male, ma va tenuto presente che non si verifica alcuna scossa degli alberi. Diamo un'occhiata al codice dell'app e vediamo cosa si può fare per risolvere il problema.

In qualsiasi applicazione, trovare opportunità di scosseare gli alberi implica la ricerca di affermazioni 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 indica "import tutto del modulo utils e lo mette in uno spazio dei nomi denominato utils." La domanda principale è "quanto ci sono contenuti in quel modulo?"

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

Ti ti serve tutta quella roba? Controlliamo attentamente il numero di istanze di quello spazio dei nomi nel file del componente principale che importa il modulo utils.

Screenshot della ricerca "utils" in un editor di testo, che restituisce solo 3 risultati.
Lo spazio dei nomi utils da cui abbiamo importato molti moduli viene richiamato solo tre volte all'interno del file del componente principale.

Lo spazio dei nomi utils appare solo in tre punti della nostra applicazione, ma per quali funzioni? Se esamini di nuovo il file del componente principale, ti sembra che sia presente una sola funzione, ovvero utils.simpleSort, 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ò si traduce nella spedizione di una grande quantità di codice JavaScript inutilizzato.

Anche se questa app di esempio è certamente un po' inventata, non cambia il fatto che questo tipo di scenario sintetico assomiglia a opportunità di ottimizzazione reali che potresti trovare in un'app web di produzione. Dopo aver identificato un'opportunità che l'agitazione degli alberi sia utile, come viene effettivamente fatto?

Impedire a Babel di transpirare i moduli ES6 in moduli CommonJS

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

Poiché l'oscillazione degli alberi è più difficile da eseguire per i moduli CommonJS, webpack non saprà cosa eliminare dai bundle se decidi di usarli. La soluzione consiste nel configurare @babel/preset-env in modo da lasciare esplicitamente i moduli ES6. Ovunque configuri Babel, in babel.config.js o package.json, dovrai aggiungere qualcosa in più:

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

Se specifichi modules: false nella configurazione @babel/preset-env, Babel si comporti come previsto, consentendo al webpack di analizzare la tua struttura ad albero delle dipendenze e di eliminare le dipendenze inutilizzate.

Aspetti da considerare negli effetti collaterali

Un altro aspetto da considerare quando si sconvolgono 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 suo 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 esula dal suo ambito.

Gli effetti collaterali si applicano anche ai moduli ES6, e ciò è importante nel contesto dell'agitazione degli alberi. 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 vengono utilizzate. Sono parti di codice modulari indipendenti. Quindi, "moduli".

Per quanto riguarda il webpack, è possibile usare un suggerimento per specificare che un pacchetto e le sue 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 al webpack quali file specifici non sono privi di effetti collaterali:

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

Nel secondo esempio, qualsiasi file non specificato verrà considerato privo di effetti collaterali. Se non vuoi aggiungerlo al file package.json, puoi specificare questo flag anche nella configurazione del webpack tramite module.rules.

Importazione solo degli elementi necessari

Dopo aver indicato a Babel di non modificare i moduli ES6, è necessario apportare una leggera modifica alla sintassi import per inserire 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 affinché la scossa degli alberi funzioni in questo esempio. Questo è l'output del 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 la scossa dell'albero ha avuto 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 bundle si siano ridotti, è davvero il bundle main che ne trae i maggiori vantaggi. Eliminando le parti inutilizzate del modulo utils, il bundle main si riduce di circa il 60%. In questo modo, non solo riduci il tempo impiegato dallo script per il download, ma anche il tempo di elaborazione.

Scuoti qualche albero!

La distanza percorsa dalle scosse degli alberi dipende dalla tua app, dalle sue dipendenze e dall'architettura. Prova. Se sai per certo di non aver configurato il bundler di moduli per eseguire questa ottimizzazione, provare e vedere i vantaggi per la tua applicazione non comporta alcun danno.

Puoi ottenere un miglioramento del rendimento significativo in seguito alle scosse degli alberi, o addirittura ottenere risultati parziali. 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, eviterai proattivamente di ridurre al minimo le dimensioni dei bundle di applicazioni.

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