Réduire les charges utiles JavaScript avec tree shaking

Aujourd'hui, les applications Web peuvent devenir assez volumineuses, en particulier la partie JavaScript. Depuis mi-2018, HTTP Archive estime la taille de transfert médiane de JavaScript sur les appareils mobiles à environ 350 Ko. Il ne s'agit que de la taille du transfert. Le code JavaScript est souvent compressé lors de l'envoi sur le réseau, ce qui signifie que la quantité réelle de code JavaScript est beaucoup plus importante après la décompression du navigateur. Il est important de le souligner, car en ce qui concerne le traitement des ressources, la compression n'a pas d'importance. 900 Ko de code JavaScript décompressé sont toujours de 900 Ko pour l'analyseur et le compilateur, même s'ils peuvent représenter 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és, il s'agit de 900 Ko de code JavaScript à analyser, compiler et exécuter.

Le traitement de JavaScript est coûteux. Contrairement aux images qui ne subissent qu'un temps de décodage relativement court une fois téléchargées, le code JavaScript doit être analysé, compilé, puis exécuté. Octet par octet, cela rend JavaScript plus cher que d'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 utilise beaucoup plus d'octets en ressources 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).

Nous apportons constamment des améliorations pour améliorer l'efficacité des moteurs JavaScript, mais l'amélioration des performances de JavaScript est, comme toujours, une tâche pour les développeurs.

C'est pourquoi il existe des techniques permettant d'améliorer les performances de JavaScript. La répartition du code est une technique qui améliore les performances. Elle partitionne le code JavaScript de l'application en fragments et ne diffuse ces fragments 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 lié aux applications gourmandes en 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 tremblement d'arbre ?

Les secousses d'arbre sont 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. Le concept d'achat est également présent dans webpack, comme l'illustre cet article à l'aide d'une application exemple.

Le terme « secoulement d'arbre » provient du modèle mental de votre application et de ses dépendances, sous la forme d'une structure arborescente. Chaque nœud de l'arborescence représente une dépendance qui fournit des fonctionnalités distinctes pour votre application. Dans les applications modernes, ces dépendances sont intégré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 toutes, les dépendances que vous ajoutez. Toutefois, au fur et à mesure que votre application évolue, d'autres dépendances peuvent être ajoutées. Pour aggraver les problèmes, les anciennes dépendances deviennent obsolètes, mais ne sont pas toujours éliminées de votre codebase. Au final, une application est livrée avec de nombreux JavaScript inutilisés. Le secouement d'arborescence résout ce problème en exploitant la façon dont les instructions import statiques récupèrent 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 comporter beaucoup de code), cet exemple n'importe que des parties spécifiques de celui-ci. Dans les versions de développement, cela ne change rien, car l'ensemble du module est importé malgré tout. Dans les builds de production, le webpack peut être configuré pour "secouer" des exportations à partir des modules ES6 qui n'ont pas été explicitement importés, ce qui réduit la taille des builds de production. Ce guide vous explique comment procéder.

Trouver des opportunités pour secouer un arbre

À titre d'illustration, voici un exemple d'application sur une page qui illustre le fonctionnement du "tree shaking". Vous pouvez le cloner et suivre la procédure si vous le souhaitez, mais nous aborderons toutes les étapes dans ce guide. Le clonage n'est donc pas nécessaire (sauf si vous préférez apprendre par la pratique).

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

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

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

Capture d'écran de deux groupes de code d'application (ou fragments) affichés dans le panneau "Network" (Réseau) des outils de développement Chrome.
Les deux bundles JavaScript de l'application. Ce sont des 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'optimisation. 21,1 Ko pour un bundle spécifique à une application n'est pas une mauvaise chose, mais il convient de noter qu'aucun tree shaking ne se produit. Examinons le code de l'application et voyons comment résoudre ce problème.

Dans n'importe quelle application, la recherche d'opportunités de tremblement d'arbre implique la recherche d'instructions import statiques. En haut du fichier du composant principal, vous verrez une ligne semblable à celle-ci:

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

Vous pouvez importer des modules ES6 de différentes manières, mais ce type de module devrait attirer votre attention. Cette ligne spécifique indique tout import dans le module utils et placez-le dans un espace de noms appelé utils. La grande question à se poser ici est la suivante : "Combien y a-t-il de contenu dans ce module ?".

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

Vous avez besoin de tout cela ? Vérifions ce point en recherchant le fichier du composant principal qui importe le module utils pour voir combien d'instances de cet espace de noms apparaissent.

Capture d'écran d'une recherche sur 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 emplacements dans notre application, mais pour quelles fonctions ? Si vous examinez à nouveau le fichier du composant principal, il semble qu'il n'y ait qu'une seule fonction, à savoir utils.simpleSort, qui permet de trier la liste des résultats de recherche en fonction d'un certain nombre de critères lorsque les listes déroulantes de tri sont modifiées:

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 comportant un ensemble d'exportations, une seule d'entre elles est utilisée. Cela entraîne l'envoi d'un grand nombre de JavaScript inutilisé.

Bien que cet exemple d'application soit certes un peu artificiel, il ne change rien au fait que ce type de scénario synthétique ressemble à des opportunités d'optimisation réelles que vous pouvez rencontrer dans une application Web de production. Maintenant que vous savez que le tremblement d'arbres peut être utile, 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 des secousses 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 require au lieu de import.

Le tremblement d'arbre étant 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 laisser explicitement les modules ES6 tels quels. Quel que soit l'endroit où vous configurez Babel, que ce soit dans babel.config.js ou package.json, vous devez ajouter un petit élément supplémentaire:

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

Si vous spécifiez modules: false dans votre configuration @babel/preset-env, Babel se comporte comme vous le souhaitez, ce qui permet à webpack d'analyser votre arborescence de dépendances et d'éliminer les dépendances inutilisées.

Garder les effets secondaires à l’esprit

Un autre aspect à prendre en compte lorsque vous agitez les dépendances de votre application les modules de votre projet ont des effets secondaires. Un exemple d'effet secondaire est lorsqu'un modifie un élément en dehors de son 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, ce qui dépasse son champ d'application.

Les effets secondaires s'appliquent également aux modules ES6, ce qui est important dans le contexte des tremblements d'arbre. 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 champ d'application sont des dépendances qui peuvent être supprimées en toute sécurité si nous ne les utilisons pas. Ce sont des éléments de code autonomes modulaires. D'où le nom de "modules".

En ce qui concerne webpack, vous pouvez utiliser une indication pour indiquer qu'un package et ses dépendances sont dépourvus 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 comportent pas d'effets indésirables:

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

Dans ce dernier exemple, tout fichier non spécifié est 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 les éléments nécessaires

Après avoir demandé à Babel de ne pas modifier les modules ES6, un léger ajustement de notre syntaxe import est nécessaire 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'ensemble 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 qui est nécessaire pour que le tremblement d'arbre fonctionne dans cet exemple. Voici le résultat du webpack avant de secouer 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 la réussite du tree shaking:

                 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

Même si les deux offres ont reculé, c'est le lot main qui profite le plus. En secouant les parties inutilisées du module utils, le bundle main se réduit d'environ 60%. Cela permet non seulement de réduire le temps nécessaire au téléchargement du script, mais aussi le temps de traitement.

Secouez des arbres !

Quelle que soit l'utilité de cette méthode, elle dépend de votre application, de ses dépendances et de son architecture. Essayer Si vous n'avez pas configuré votre bundler de modules pour effectuer cette optimisation, il n'y a aucun danger à essayer et à voir les avantages pour votre application.

Vous constaterez peut-être un gain de performances significatif, voire nul. Cependant, en configurant votre système de compilation pour tirer parti de cette optimisation dans les builds de production et en n'important de façon sélective que ce dont votre application a besoin, vous faites en sorte de réduire au maximum la taille de vos app bundles.

Nous remercions tout particulièrement 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.