Réduire les charges utiles JavaScript avec tree shaking

Les applications Web actuelles peuvent devenir assez volumineuses, en particulier la partie JavaScript. Depuis mi-2018, HTTP Archive définit la taille de transfert médiane de JavaScript sur les appareils mobiles à environ 350 Ko. Il s'agit simplement de la taille du transfert. Le code JavaScript est souvent compressé lorsqu'il est envoyé sur le réseau, ce qui signifie que la quantité réelle de JavaScript est bien plus importante après que le navigateur l'a décompressée. Il est important de souligner ce point, car en ce qui concerne le traitement des ressources, la compression n'a pas d'importance. 900 Ko de code JavaScript décompressé restent 900 Ko pour l'analyseur et le compilateur, même s'ils peuvent faire environ 300 Ko une fois compressés.

Schéma illustrant le processus de téléchargement, de décompression, d'analyse, de compilation et d'exécution de JavaScript.
Processus 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é, il s'agit quand même de 900 Ko de JavaScript à analyser, compiler et exécuter.

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

Schéma comparant le temps de traitement de 170 Ko de JavaScript par rapport à une image JPEG de taille équivalente. La ressource JavaScript consomme beaucoup plus de ressources en octets que le JPEG.
Coût de traitement de l'analyse/compilation de 170 Ko de JavaScript par rapport au temps de décodage d'un fichier JPEG de taille équivalente. (source).

Même si des améliorations sont constamment apportées pour optimiser l'efficacité des moteurs JavaScript, l'amélioration des performances de JavaScript est, comme toujours, une tâche pour les développeurs.

À cette fin, il existe des techniques permettant d'améliorer les performances JavaScript. La scission de code est une technique qui améliore les performances en partitionnant le code JavaScript d'application en fragments et en diffusant ces fragments uniquement vers 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 utilisant beaucoup JavaScript, à savoir l'inclusion de code qui n'est jamais utilisé. Le tremblement d'arbre 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 la propriété de consolidation, mais le concept d'élimination de code mort existe depuis un certain temps. Le concept a également trouvé l'achat dans le webpack, comme le montre cet article à l'aide d'une application exemple.

Le terme "tree shaking" provient 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 des fonctionnalités distinctes à 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 arbre, si vous voulez), elle peut avoir peu de dépendances. Il utilise également la plupart, voire la totalité, des dépendances que vous ajoutez. Cependant, à mesure que votre application évoluera, d'autres dépendances pourront être ajoutées. Pour aggraver la situation, les anciennes dépendances ne sont plus utilisées, mais risquent de ne pas être éliminées de votre codebase. Au final, l'application finit par être livrée avec une grande quantité de code JavaScript inutilisé. Le tree shaking résout ce problème en tirant parti de la façon dont les instructions statiques import 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 import et le précédent est qu'au lieu d'importer tout à partir du module "array-utils" (qui peut représenter beaucoup de code), cet exemple n'importe que certaines parties de celui-ci. Dans les builds de développement, cela ne change rien, car l'ensemble du module est importé malgré tout. Dans les builds de production, le pack Web peut être configuré pour "secouer" les exportations des modules ES6 qui n'ont pas été explicitement importés, réduisant ainsi la taille de ces builds. Ce guide vous explique comment procéder.

Trouver des occasions de secouer un arbre

À titre d'illustration, nous mettons à votre disposition un exemple d'application sur une page qui illustre le fonctionnement de l'utilisation de tree shaking. Vous pouvez le cloner et suivre la procédure si vous le souhaitez, mais nous couvrirons ensemble chaque étape de ce processus dans ce guide. Le clonage n'est donc pas nécessaire (à moins que vous ne vous aidiez à apprendre par la pratique).

L'application exemple consiste en une base de données de pédales à effet de guitare dans laquelle vous pouvez effectuer des recherches. Saisissez une requête pour afficher une liste de pédales d'effet.

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

Le comportement qui pilote cette application est distinct selon le fournisseur (par exemple, Preact et Emotion) et groupes de code spécifiques à l'application (ou "morceaux", comme les appelle Webpack):

Capture d'écran de deux groupes de code d'application (ou fragments) affichée dans le panneau "Réseau" des outils pour les développeurs Chrome.
Les deux bundles JavaScript de l'application. Il s'agit de tailles non compressées.

Les bundles JavaScript illustrés ci-dessus sont des builds de production. Ils sont donc optimisés par le biais d'une uglification. Ce n'est pas un problème de 21,1 Ko pour un app bundle spécifique, mais il convient de noter qu'aucun tremblement d'arbre ne se produit. Examinons le code de l'application et voyons comment résoudre ce problème.

Quelle que soit l'application, pour identifier des opportunités de secousses dans les arbres, vous devrez rechercher des instructions import statiques. En haut du fichier du composant principal, une ligne semblable à la suivante s'affiche:

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

Vous pouvez importer des modules ES6 de différentes manières, mais les modules comme celle-ci devraient attirer votre attention. Cette ligne spécifique indique "import tous les éléments du module utils et les placer dans un espace de noms appelé utils. La grande question à se poser ici est la suivante : "Combien de contenus contient ce module ?".

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

Est-ce que vous avez besoin de tout cela ? Vérifions le nombre d'instances de cet espace de noms qui apparaissent dans le fichier de composant principal qui importe le module utils.

Capture d'écran d'une recherche avec le terme "utils." dans un éditeur de texte, renvoyant seulement trois résultats.
L'espace de noms utils à partir duquel nous avons importé des tonnes de modules n'est appelé que trois fois dans le fichier du composant principal.

Il s'avère que l'espace de noms utils n'apparaît qu'à trois endroits de notre application. Mais pour quelles fonctions ? Si vous examinez à nouveau le fichier du composant principal, il semble qu'il ne s'agisse que d'une seule fonction, utils.simpleSort, qui permet de trier la liste des résultats de recherche selon plusieurs 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 plusieurs exportations, une seule est utilisée. Cela entraîne l'envoi d'une grande quantité de code JavaScript inutilisé.

Bien que cet exemple d'application soit un peu artificiel, cela ne change rien au fait que ce type de scénario synthétique ressemble à des opportunités d'optimisation réelles que vous pourriez rencontrer dans une application Web de production. Maintenant que vous avez identifié l'utilité du tree shaking, comment procéder ?

Empêcher Babel de transpiler les modules ES6 en modules CommonJS

Babel est un outil indispensable, mais il peut rendre les effets du tremblement d'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 le tree shaking est plus difficile à effectuer pour les modules CommonJS, webpack ne saura pas quoi élaguer des groupes si vous décidez de les utiliser. La solution consiste à configurer @babel/preset-env de manière à ne pas modifier les modules ES6. Quel que soit l'endroit où vous configurez Babel, que ce soit dans babel.config.js ou package.json, vous devez ajouter un élément supplémentaire:

// 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 vous le souhaitez, ce qui permet au pack Web d'analyser votre arborescence de dépendances et de se débarrasser des dépendances inutilisées.

Garder les effets secondaires à l’esprit

Un autre aspect à prendre en compte lorsque vous secouez les dépendances de votre application est de savoir si les modules de votre projet ont des effets secondaires. Voici un exemple d'effet secondaire : lorsqu'une fonction modifie un élément qui n'est pas dans son propre champ d'application, 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 se situe en dehors de son champ d'application.

Les effets secondaires s'appliquent également aux modules ES6, ce qui est important dans le contexte du tree shaking. Les modules qui prennent des entrées prévisibles et produisent des sorties tout aussi prévisibles sans modifier quoi que ce soit en dehors de leur propre champ d'application sont des dépendances qui peuvent être abandonnées en toute sécurité si nous ne les utilisons pas. Il s'agit d'extraits de code modulaires autonomes. d'où "modules".

En cas de problème avec webpack, un indice peut être utilisé 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 quels fichiers spécifiques ne sont pas dépourvus d'effets secondaires:

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

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

Importer uniquement les éléments nécessaires

Après avoir demandé à Babel de ne pas modifier les modules ES6, vous devez légèrement ajuster la syntaxe import pour n'intégrer que les fonctions nécessaires au module utils. Dans l'exemple de ce guide, tout ce dont vous avez besoin est 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 devra ê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);
}

Cela ne devrait être nécessaire que dans cet exemple. Voici la sortie webpack avant de secouer l'arborescence de 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 que le tree shake a été secoué correctement:

                 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 groupes aient diminué, c'est le bundle main qui profite le plus. En secouant les parties inutilisées du module utils, le bundle main rétrécit d'environ 60%. Cela permet non seulement de réduire la durée de téléchargement du script, mais également le temps de traitement.

Secoue des arbres !

Quel que soit le kilométrage qu'il vous reste à maîtriser, celui-ci dépend de votre application, de ses dépendances et de son architecture. Essayer Si vous savez que vous n'avez pas configuré votre bundler de modules pour effectuer cette optimisation, il n'y a aucun danger à essayer de voir les avantages pour votre application.

Il est possible que vous constatiez un gain de performances significatif avec le secouement des arbres, voire pas du tout. Toutefois, en configurant votre système de compilation de sorte qu'il profite 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 réduirez au maximum la taille de vos groupes d'applications de manière proactive.

Un grand merci à Kristofer Baxter, Jason Miller, Addy Osmani, Jeff Posnick, Sam Saccone et Philip Walton pour leurs précieux commentaires qui nous ont permis d'améliorer considérablement la qualité de cet article.