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 riporta la dimensione mediana di trasferimento di JavaScript sui dispositivi mobili a circa 350 kB. Queste sono solo dimensioni del trasferimento! Quando viene inviato tramite la rete, JavaScript viene spesso compresso, il che significa che la quantità effettiva di JavaScript è leggermente superiore dopo la decompressione da parte del browser. Questo è importante da sottolineare, perché 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.

Diagramma che illustra il processo di download, decompressione, analisi, compilazione ed esecuzione di JavaScript.
. Il processo di download ed esecuzione di JavaScript. Tieni presente che, anche se la dimensione di trasferimento dello script è di 300 KB compressa, è comunque 900 KB di JavaScript che deve essere analizzato, compilato ed eseguito.

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

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

Sebbene vengano costantemente apportati miglioramenti per migliorare l'efficienza dei motori JavaScript, migliorare le prestazioni di JavaScript è, come sempre, un compito per gli sviluppatori.

A questo scopo, ci sono 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. Lo scuotimento degli alberi tenta di risolvere questo problema.

Cos'è lo scuotimento degli alberi?

Lo scuotimento degli alberi è una forma di eliminazione del codice morto. Il termine è stato reso popolare da Rollup, ma il concetto di eliminazione del codice obsoleto esiste da tempo. L'idea ha individuato anche l'acquisto in webpack, che è dimostrato in questo articolo tramite un'app di esempio.

Il termine "scuotimento degli alberi" proviene dal modello mentale della tua applicazione e le sue dipendenze formano una struttura ad albero. Ciascun nodo nell'albero rappresenta una dipendenza che fornisce funzionalità distinte per l'app. Nelle app moderne, queste dipendenze vengono importate tramite istruzioni import statiche, in questo modo:

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

Quando un'app è giovane (se vuoi tu) un'app, potrebbe avere poche dipendenze. Inoltre, utilizza la maggior parte (se non tutte) delle dipendenze che aggiungi. Man mano che la tua app matura, tuttavia, possono essere aggiunte altre dipendenze. Per aggravare le pratiche, le dipendenze precedenti smettono di essere utilizzate, ma potrebbero non essere eliminate dal codebase. Il risultato finale è che un'app spedisce 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 import e quello precedente è che, anziché importare tutto dal modulo "array-utils", che potrebbe essere una grande quantità di 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, il webpack può essere configurato per "scuotere" sulle esportazioni da moduli ES6 che non sono stati importati esplicitamente, il che riduce le dimensioni delle build di produzione. In questa guida, imparerai a fare proprio questo.

Trovare opportunità per scuotere un albero

A scopo illustrativo, è disponibile un esempio di app di una pagina che dimostra come funziona l'oscillazione degli alberi. Puoi clonarlo e continuare a seguire, se vuoi, ma in questa guida tratteremo ogni fase del processo, quindi la clonazione non è necessaria (a meno che non ti interessi l'apprendimento pratico).

L'app di esempio è un database consultabile di pedali per effetti per chitarra. Inserendo una query, verrà visualizzato un elenco di pedali per effetti.

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

Il comportamento che guida questa app è separato per fornitore (ad es. Preact ed Emotion) e pacchetti di codice specifici dell'app (o "blocchi", come li chiama Webpack):

Uno screenshot di due pacchetti (o blocchi) di codice dell'applicazione mostrati nel riquadro 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 uglificazione. 21,1 kB per un bundle specifico dell'app non sono male, ma va notato che non si verificano scosse di alberi. Diamo un'occhiata al codice dell'app e vediamo cosa si può fare per risolvere il problema.

In qualsiasi applicazione, la ricerca di opportunità di scuotimento degli alberi implicherà la ricerca di istruzioni import statiche. Nella parte superiore del file del componente principale, viene visualizzata 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 riporta "import tutto dal modulo utils e lo ha inserito in uno spazio dei nomi chiamato utils." La domanda principale da porsi è: "quanto contenuti ci sono in quel modulo?"

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

Ti servino tutta quella roba? Eseguiamo un controllo cercando nel file del componente principale che importa il modulo utils per vedere quante istanze vengono visualizzate nello spazio dei nomi.

Uno screenshot della ricerca di "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.

È emerso che lo spazio dei nomi utils compare solo in tre punti dell'applicazione, ma per quale funzione? Se dai di nuovo un'occhiata al file del componente principale, sembra essere 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ò comporta la spedizione di una grande quantità di codice JavaScript inutilizzato.

Anche se questa app di esempio è un po' artificiosa, non cambia il fatto che questo tipo di scenario sintetico assomigli alle effettive opportunità di ottimizzazione che potresti incontrare in un'app web di produzione. Ora che hai identificato un'opportunità utile per scuotere gli alberi, come si fa in pratica?

Impedire a Babel di traspirare i moduli ES6 a moduli CommonJS

Babel è uno strumento indispensabile, ma potrebbe rendere un po' più difficili da osservare gli effetti dello scuotimento 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'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 da lasciare esplicitamente i moduli ES6. Indipendentemente da dove configuri Babel, che si tratti di babel.config.js o package.json, devi 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 comporta come desiderato, il che consente a Webpack di analizzare il tuo 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 degli effetti collaterali. Un esempio di effetto collaterale è quando modifica qualcosa al di fuori del proprio ambito, creando 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 loro ambito sono dipendenze che possono essere eliminate in sicurezza se non le utilizziamo. Si tratta di parti di codice modulari indipendenti. Pertanto, "moduli".

Per quanto riguarda il webpack, puoi usare un hint 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 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 qualsiasi file non specificato sia privo 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 del necessario

Dopo aver chiesto a Babel di lasciare invariati i moduli ES6, è necessario apportare una leggera modifica alla sintassi import per importare solo le funzioni necessarie dal modulo utils. Nell'esempio di questa guida, tutto ciò che serve è 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 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 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

Anche se entrambi i bundle si sono ridotti, è in realtà il bundle main a cui sono più vantaggiosi. Scuotendo 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.

Scuoti qualche albero!

Qualunque sia il chilometraggio ottenuto dallo scuotimento degli alberi dipende dalla tua app, dalle sue dipendenze e dall'architettura. Prova Se per un dato di fatto non hai configurato il tuo bundler di moduli per eseguire questa ottimizzazione, non c'è problema nel provare e vedere i vantaggi per la tua applicazione.

Se scuoti gli alberi potresti ottenere un aumento significativo delle prestazioni o non molto. 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 significativamente la qualità di questo articolo.