Réduire les charges utiles JavaScript avec tree shaking

Les applications Web d'aujourd'hui peuvent être très volumineuses, en particulier la partie JavaScript. Mi-2018, HTTP Archive estimait la taille de transfert médiane de JavaScript sur les appareils mobiles à environ 350 ko. Et ce n'est que la taille du transfert ! Le code JavaScript est souvent compressé lorsqu'il est envoyé sur le réseau. Cela signifie que la quantité réelle de code JavaScript est beaucoup plus importante après que le navigateur l'a décompressé. Il est important de le souligner, car en ce qui concerne le traitement des ressources, la compression est sans importance. 900 Ko de code JavaScript décompressé représentent toujours 900 Ko pour l'analyseur et le compilateur, même s'ils peuvent être compressés à environ 300 Ko.

Schéma illustrant le processus de téléchargement, de décompression, d'analyse, de compilation et d'exécution de JavaScript.
Procédé de téléchargement et d'exécution de JavaScript. Notez que même si la taille de transfert du script est de 300 Ko compressés, il reste 900 Ko de code JavaScript à analyser, compiler et exécuter.

Le traitement du code JavaScript est coûteux. Contrairement aux images, qui ne nécessitent qu'un temps de décodage relativement faible une fois téléchargées, le code JavaScript doit être analysé, compilé, puis exécuté. Par octet, cela rend JavaScript plus coûteux que les autres types de ressources.

Diagramme comparant le temps de traitement de 170 Ko de code JavaScript à celui d'une image JPEG de taille équivalente. La ressource JavaScript est beaucoup plus gourmande en ressources octet par octet que le fichier JPEG.
Le coût de traitement de l'analyse/compilation de 170 ko de code JavaScript par rapport au temps de décodage d'un fichier JPEG de taille équivalente. (source).

Bien que des améliorations soient constamment apportées pour améliorer l'efficacité des moteurs JavaScript, l'amélioration des performances JavaScript est, comme toujours, une tâche à accomplir par les développeurs.

Pour ce faire, il existe des techniques permettant d'améliorer les performances JavaScript. Le fractionnement du code est une technique qui améliore les performances en partitionnant le code JavaScript de l'application en segments et en ne les diffusant que sur les routes d'une application qui en ont besoin.

Bien que cette technique fonctionne, elle ne résout pas un problème courant des applications axées sur JavaScript, à savoir l'inclusion de code qui n'est jamais utilisé. Le balayage d'arbres tente de résoudre ce problème.

Qu'est-ce que le tree shaking ?

Le tree shaking est une forme d'élimination du code mort. Ce terme a été popularisé par Rollup, mais le concept d'élimination du code mort existe depuis un certain temps. Ce concept a également été adopté par webpack, comme illustré dans cet article à l'aide d'un exemple d'application.

Le terme "tree shaking" vient du modèle mental de votre application et de ses dépendances en tant que structure arborescente. Chaque nœud de l'arborescence représente une dépendance qui fournit une fonctionnalité distincte pour votre application. Dans les applications modernes, ces dépendances sont importées via des instructions import statiques comme suit:

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

Lorsqu'une application est jeune (un jeune plant, si vous voulez), elle peut avoir peu de dépendances. Il utilise également la plupart des dépendances que vous ajoutez, voire toutes. Toutefois, à mesure que votre application évolue, vous pouvez ajouter d'autres dépendances. Pour compliquer la situation, les anciennes dépendances ne sont plus utilisées, mais elles ne sont pas forcément supprimées de votre codebase. Par conséquent, une application est fournie avec beaucoup de JavaScript inutilisé. Le tree shaking résout ce problème en exploitant la façon dont les instructions import statiques extraient des parties spécifiques des modules ES6:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

La différence entre cet exemple de import et le précédent est que, au lieu d'importer tout à partir du module "array-utils" (ce qui pourrait représenter beaucoup de code), cet exemple n'importe que des parties spécifiques. Dans les builds de développement, cela ne change rien, car l'intégralité du module est importée. Dans les builds de production, webpack peut être configuré pour "éliminer" les exportations des modules ES6 qui n'ont pas été importés explicitement, ce qui réduit la taille de ces builds de production. C'est précisément ce que vous allez apprendre à faire dans ce guide.

Trouver des opportunités de secouer un arbre

À des fins d'illustration, un exemple d'application en une page est disponible pour montrer comment fonctionne le tree shaking. Vous pouvez le cloner et suivre la procédure si vous le souhaitez, mais nous allons couvrir toutes les étapes ensemble dans ce guide. Le clonage n'est donc pas nécessaire (sauf si vous préférez apprendre en pratique).

L'application exemple est une base de données de pédales d'effets de guitare que vous pouvez rechercher. Vous saisissez une requête, et une liste de pédales d'effets s'affiche.

Capture d'écran d'un exemple d'application en une page permettant de rechercher dans une base de données de pédales d'effets pour guitare.
Une capture d'écran de l'application exemple.

Le comportement qui gère cette application est divisé en deux : Preact et Emotion) et des bundles de code spécifiques à l'application (ou "chunks", comme webpack les appelle):

Capture d'écran de deux bundles (ou blocs) de code d'application affichés dans le panneau "Network" (Réseau) de Chrome DevTools.
Les deux bundles JavaScript de l'application. Il s'agit de tailles non compressées.

Les bundles JavaScript illustrés dans la figure ci-dessus sont des builds de production, ce qui signifie qu'ils sont optimisés via l' uglification. 21,1 ko pour un bundle spécifique à une application n'est pas mal, mais il convient de noter qu'aucun tremblement d'arbre ne se produit. Examinons le code de l'application pour voir ce que nous pouvons faire pour résoudre ce problème.

Dans n'importe quelle application, trouver des opportunités de suppression d'arbres implique de rechercher des instructions import statiques. En haut du fichier du composant principal, vous trouverez une ligne semblable à celle-ci:

import * as utils from "../../utils/utils";

Vous pouvez importer des modules ES6 de différentes manières, mais ceux-ci méritent votre attention. Cette ligne spécifique indique "import tout du module utils, et le place dans un espace de noms appelé utils". La grande question à se poser ici est la suivante : "Combien de éléments se trouvent dans ce module ?"

Si vous examinez le code source du module utils, vous constaterez qu'il contient environ 1 300 lignes de code.

Avez-vous besoin de tout cela ? Vérifions en recherchant dans le fichier du composant principal qui importe le module utils le nombre d'instances de cet espace de noms.

Capture d'écran d'une recherche dans un éditeur de texte pour "utils.", qui ne renvoie que trois résultats.
Dans le fichier de composant principal, l'espace de noms utils à partir duquel nous avons importé des tonnes de modules n'est appelé que trois fois.

Il s'avère que l'espace de noms utils n'apparaît que dans trois endroits de notre application, mais pour quelles fonctions ? Si vous examinez à nouveau le fichier du composant principal, il ne semble y avoir qu'une seule fonction, utils.simpleSort, qui permet de trier la liste des résultats de recherche en fonction d'un certain nombre de critères lorsque les menus déroulants de tri sont modifiés:

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);
}

Sur un fichier de 1 300 lignes contenant un certain nombre d'exportations, une seule est utilisée. Cela entraîne l'envoi de beaucoup de code JavaScript inutilisé.

Bien que cet exemple d'application soit un peu artificiel, il n'en reste pas moins que ce type de scénario synthétique ressemble aux opportunités d'optimisation réelles que vous pourriez rencontrer dans une application Web de production. Maintenant que vous avez identifié une opportunité pour laquelle le tree shaking peut être utile, comment procéder ?

Empêcher Babel de transcompiler des modules ES6 en modules CommonJS

Babel est un outil indispensable, mais il peut rendre les effets du tremblement des arbres un peu plus difficiles à observer. Si vous utilisez @babel/preset-env, Babel peut transformer les modules ES6 en modules CommonJS plus compatibles, c'est-à-dire des modules que vous require au lieu de import.

Étant donné que l'élagage d'arbre est plus difficile à effectuer pour les modules CommonJS, webpack ne saura pas quoi supprimer des bundles si vous décidez de les utiliser. La solution consiste à configurer @babel/preset-env pour qu'il laisse explicitement les modules ES6 intacts. Où que vous configuriez Babel, que ce soit dans babel.config.js ou package.json, vous devez ajouter un petit quelque chose en plus:

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

Spécifier modules: false dans votre configuration @babel/preset-env permet à Babel de se comporter comme souhaité, ce qui permet à webpack d'analyser votre arborescence de dépendances et de supprimer les dépendances inutilisées.

Tenir compte des effets secondaires

Un autre aspect à prendre en compte lorsque vous éliminez les dépendances de votre application est de savoir si les modules de votre projet ont des effets secondaires. Un exemple d'effet secondaire est lorsqu'une fonction modifie quelque chose en dehors de son propre portée, ce qui est un effet secondaire de son exécution:

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"]

Dans cet exemple, addFruit produit un effet secondaire lorsqu'il modifie le tableau fruits, qui est en dehors de son champ d'application.

Les effets secondaires s'appliquent également aux modules ES6, ce qui est important dans le contexte du ramassage d'arbres. Les modules qui reçoivent des entrées prévisibles et produisent des sorties tout aussi prévisibles sans modifier quoi que ce soit en dehors de leur propre portée sont des dépendances que vous pouvez supprimer en toute sécurité si vous ne les utilisez pas. Il s'agit de morceaux de code modulaires et autonomes. C'est pourquoi nous parlons de "modules".

Pour webpack, vous pouvez utiliser un indice pour spécifier qu'un package et ses dépendances sont exempts d'effets secondaires en spécifiant "sideEffects": false dans le fichier package.json d'un projet:

{
 
"name": "webpack-tree-shaking-example",
 
"version": "1.0.0",
 
"sideEffects": false
}

Vous pouvez également indiquer à webpack les fichiers spécifiques qui ne sont pas exempts d'effets secondaires:

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

Dans le dernier exemple, tout fichier non spécifié sera considéré comme exempt d'effets secondaires. Si vous ne souhaitez pas l'ajouter à votre fichier package.json, vous pouvez également spécifier cet indicateur dans votre configuration webpack via module.rules.

Importer uniquement ce qui est nécessaire

Après avoir demandé à Babel de ne pas toucher aux modules ES6, un léger ajustement de la syntaxe import est nécessaire pour n'importer que les fonctions nécessaires du module utils. Dans l'exemple de ce guide, il suffit d'utiliser la fonction simpleSort:

import { simpleSort } from "../../utils/utils";

Étant donné que seul simpleSort est importé au lieu de l'intégralité du module utils, chaque instance de utils.simpleSort doit être remplacée par 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);
}

C'est tout ce dont vous avez besoin pour que le balayage d'arbre fonctionne dans cet exemple. Voici la sortie webpack avant le secouement de l'arborescence des dépendances:

                 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

Voici le résultat après le nettoyage de l'arborescence:

                 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

Bien que les deux bundles aient diminué, c'est le bundle main qui en profite le plus. En éliminant les parties inutilisées du module utils, le groupe main est réduit d'environ 60%. Cela réduit non seulement le temps de téléchargement du script, mais aussi le temps de traitement.

Allez secouer des arbres !

Les avantages que vous retirerez de l'élagage d'arbres dépendent de votre application, de ses dépendances et de son architecture. Essayer Si vous savez avec certitude que vous n'avez pas configuré votre outil de regroupement de modules pour effectuer cette optimisation, vous pouvez essayer de le faire et voir en quoi cela peut bénéficier à votre application.

Vous pouvez constater un gain de performances significatif ou très faible. Toutefois, en configurant votre système de compilation pour tirer parti de cette optimisation dans les builds de production et en n'important de manière sélective que ce dont votre application a besoin, vous pourrez de manière proactive réduire la taille de vos bundles d'applications au maximum.

Remerciements particuliers à Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone et Philip Walton pour leurs précieux commentaires, qui ont considérablement amélioré la qualité de cet article.