Secouer les arbres : un guide de référence
Publié: 2022-03-10Avant de commencer notre voyage pour apprendre ce qu'est le tree-shaking et comment nous préparer pour réussir, nous devons comprendre quels sont les modules de l'écosystème JavaScript.
Depuis ses débuts, les programmes JavaScript ont gagné en complexité et en nombre de tâches qu'ils effectuent. La nécessité de compartimenter ces tâches dans des périmètres d'exécution fermés est devenue évidente. Ces compartiments de tâches, ou valeurs, sont ce que nous appelons des modules . Leur objectif principal est d'empêcher la répétition et de tirer parti de la réutilisabilité. Ainsi, les architectures ont été conçues pour permettre ces types particuliers de portée, pour exposer leurs valeurs et leurs tâches, et pour consommer des valeurs et des tâches externes.
Pour approfondir ce que sont les modules et leur fonctionnement, je recommande "Modules ES : une plongée en profondeur dans un dessin animé". Mais pour comprendre les nuances du tree-shaking et de la consommation de modules, la définition ci-dessus devrait suffire.
Que signifie réellement secouer les arbres ?
En termes simples, tree-shaking signifie supprimer le code inaccessible (également appelé code mort) d'un bundle. Comme l'indique la documentation de Webpack version 3 :
« Vous pouvez imaginer votre application comme un arbre. Le code source et les bibliothèques que vous utilisez réellement représentent les feuilles vertes et vivantes de l'arbre. Le code mort représente les feuilles brunes et mortes de l'arbre qui sont consommées par l'automne. Pour se débarrasser des feuilles mortes, il faut secouer l'arbre, ce qui les fait tomber.
Le terme a été popularisé pour la première fois dans la communauté frontale par l'équipe Rollup. Mais les auteurs de tous les langages dynamiques se débattent avec le problème depuis bien plus tôt. L'idée d'un algorithme de secouage d'arbre remonte au moins au début des années 1990.
Au pays de JavaScript, l'arborescence est possible depuis la spécification du module ECMAScript (ESM) dans ES2015, anciennement connu sous le nom d'ES6. Depuis lors, le tree-shaking a été activé par défaut dans la plupart des bundlers car ils réduisent la taille de sortie sans modifier le comportement du programme.
La raison principale en est que les ESM sont statiques par nature. Disséquons ce que cela signifie.
Modules ES contre CommonJS
CommonJS est antérieur à la spécification ESM de quelques années. Il a été conçu pour remédier au manque de prise en charge des modules réutilisables dans l'écosystème JavaScript. CommonJS a une fonction require()
qui récupère un module externe en fonction du chemin fourni et l'ajoute à la portée pendant l'exécution.
Le fait que require
est une function
comme n'importe quelle autre dans un programme rend assez difficile l'évaluation de son résultat d'appel au moment de la compilation. En plus de cela, il est possible d'ajouter des appels require
n'importe où dans le code - enveloppés dans un autre appel de fonction, dans des instructions if/else, dans des instructions switch, etc.
Avec l'apprentissage et les luttes qui ont résulté de l'adoption à grande échelle de l'architecture CommonJS, la spécification ESM s'est installée sur cette nouvelle architecture, dans laquelle les modules sont importés et exportés par les mots-clés respectifs import
et export
. Par conséquent, plus d'appels fonctionnels. Les ESM ne sont également autorisés qu'en tant que déclarations de niveau supérieur - les imbriquer dans une autre structure n'est pas possible, car ils sont statiques : les ESM ne dépendent pas de l'exécution au moment de l'exécution.
Portée et effets secondaires
Il y a cependant un autre obstacle que le secouage des arbres doit surmonter pour éviter le ballonnement : les effets secondaires. Une fonction est considérée comme ayant des effets secondaires lorsqu'elle modifie ou repose sur des facteurs extérieurs à la portée de l'exécution. Une fonction avec des effets secondaires est considérée comme impure . Une fonction pure donnera toujours le même résultat, quel que soit le contexte ou l'environnement dans lequel elle a été exécutée.
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
Les bundlers servent leur objectif en évaluant le code fourni autant que possible afin de déterminer si un module est pur. Mais l'évaluation du code au moment de la compilation ou du regroupement ne peut pas aller plus loin. Par conséquent, il est supposé que les emballages avec des effets secondaires ne peuvent pas être correctement éliminés, même lorsqu'ils sont complètement inaccessibles.
Pour cette raison, les bundlers acceptent désormais une clé dans le fichier package.json
du module qui permet au développeur de déclarer si un module n'a pas d'effets secondaires. De cette façon, le développeur peut désactiver l'évaluation du code et donner un indice au bundler ; le code d'un package particulier peut require
éliminé s'il n'y a pas d'importation accessible ou d'instruction requise qui y est liée. Cela rend non seulement un bundle plus léger, mais peut également accélérer les temps de compilation.
{ "name": "my-package", "sideEffects": false }
Donc, si vous êtes un développeur de packages, utilisez consciencieusement sideEffects
avant de publier et, bien sûr, révisez-le à chaque version pour éviter toute modification inattendue.
En plus de la clé sideEffects
racine, il est également possible de déterminer la pureté fichier par fichier, en annotant un commentaire en ligne, /*@__PURE__*/
, à votre appel de méthode.
const x = */@__PURE__*/eliminated_if_not_called()
Je considère cette annotation en ligne comme une trappe de sortie pour le développeur consommateur, à faire dans le cas où un package n'a pas déclaré sideEffects: false
ou dans le cas où la bibliothèque présente effectivement un effet secondaire sur une méthode particulière.
Optimiser Webpack
Depuis la version 4, Webpack nécessite progressivement moins de configuration pour faire fonctionner les meilleures pratiques. La fonctionnalité de quelques plugins a été intégrée au noyau. Et parce que l'équipe de développement prend très au sérieux la taille des bundles, elle a simplifié le tree-shaking.
Si vous n'êtes pas vraiment un bricoleur ou si votre application n'a pas de cas particuliers, alors l'arborescence de vos dépendances est une question d'une seule ligne.
Le fichier webpack.config.js
a une propriété racine nommée mode
. Chaque fois que la valeur de cette propriété est production
, elle secouera l'arbre et optimisera pleinement vos modules. En plus d'éliminer le code mort avec le TerserPlugin
, mode: 'production'
activera les noms mutilés déterministes pour les modules et les morceaux, et il activera les plugins suivants :
- l'utilisation de la dépendance de l'indicateur,
- drapeau inclus des morceaux,
- concaténation de modules,
- pas d'émission sur les erreurs.
Ce n'est pas par hasard que la valeur de déclenchement est production
. Vous ne voudrez pas que vos dépendances soient entièrement optimisées dans un environnement de développement, car cela rendra les problèmes beaucoup plus difficiles à déboguer. Je suggérerais donc de procéder avec l'une des deux approches.
D'une part, vous pouvez passer un indicateur de mode
à l'interface de ligne de commande Webpack :
# This will override the setting in your webpack.config.js webpack --mode=production
Alternativement, vous pouvez utiliser la variable process.env.NODE_ENV
dans webpack.config.js
:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
Dans ce cas, vous devez vous rappeler de passer --NODE_ENV=production
dans votre pipeline de déploiement.
Les deux approches sont une abstraction au-dessus du très connu definePlugin
de Webpack version 3 et inférieure. L'option que vous choisissez ne fait absolument aucune différence.
Webpack version 3 et inférieure
Il convient de mentionner que les scénarios et exemples de cette section peuvent ne pas s'appliquer aux versions récentes de Webpack et d'autres bundlers. Cette section considère l'utilisation de UglifyJS version 2, au lieu de Terser. UglifyJS est le package à partir duquel Terser a été forké, donc l'évaluation du code peut différer entre eux.
Étant donné que Webpack version 3 et inférieure ne prend pas en charge la propriété sideEffects
dans package.json
, tous les packages doivent être complètement évalués avant que le code ne soit éliminé. Cela seul rend l'approche moins efficace, mais plusieurs mises en garde doivent également être prises en compte.
Comme mentionné ci-dessus, le compilateur n'a aucun moyen de découvrir par lui-même quand un paquet altère la portée globale. Mais ce n'est pas la seule situation dans laquelle il saute le secouement des arbres. Il existe des scénarios plus flous.
Prenez cet exemple de package dans la documentation de Webpack :
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
Et voici le point d'entrée d'un bundle consommateur :
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
Il n'y a aucun moyen de déterminer si mylib.transform
provoque des effets secondaires. Par conséquent, aucun code ne sera éliminé.
Voici d'autres situations avec un résultat similaire :
- invoquer une fonction d'un module tiers que le compilateur ne peut pas inspecter,
- réexporter des fonctions importées de modules tiers.
Un outil qui pourrait aider le compilateur à faire fonctionner l'arborescence est babel-plugin-transform-imports. Il divisera tous les membres et les exportations nommées en exportations par défaut, permettant aux modules d'être évalués individuellement.
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
Il possède également une propriété de configuration qui avertit le développeur d'éviter les instructions d'importation gênantes. Si vous êtes sur Webpack version 3 ou supérieure, et que vous avez fait preuve de diligence raisonnable avec la configuration de base et ajouté les plugins recommandés, mais que votre bundle semble toujours gonflé, alors je vous recommande d'essayer ce package.
Temps de levage et de compilation de la portée
À l'époque de CommonJS, la plupart des bundlers encapsulaient simplement chaque module dans une autre déclaration de fonction et les mappaient dans un objet. Ce n'est pas différent de n'importe quel objet cartographique :
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
En plus d'être difficile à analyser statiquement, cela est fondamentalement incompatible avec les ESM, car nous avons vu que nous ne pouvons pas envelopper les instructions d' import
et export
. Ainsi, de nos jours, les bundlers hissent chaque module au plus haut niveau :
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
Cette approche est entièrement compatible avec les ESM ; De plus, il permet à l'évaluation du code de repérer facilement les modules qui ne sont pas appelés et de les supprimer. La mise en garde de cette approche est que, lors de la compilation, cela prend beaucoup plus de temps car elle touche chaque instruction et stocke le bundle en mémoire pendant le processus. C'est l'une des principales raisons pour lesquelles le regroupement des performances est devenu une préoccupation encore plus grande pour tout le monde et pourquoi les langages compilés sont exploités dans les outils de développement Web. Par exemple, esbuild est un bundler écrit en Go et SWC est un compilateur TypeScript écrit en Rust qui s'intègre à Spark, un bundler également écrit en Rust.
Pour mieux comprendre le levage de portée, je recommande fortement la documentation de Parcel version 2.
Éviter le transpilage prématuré
Il y a un problème spécifique qui est malheureusement assez courant et qui peut être dévastateur pour les secoueurs d'arbres. En bref, cela se produit lorsque vous travaillez avec des chargeurs spéciaux, intégrant différents compilateurs à votre bundler. Les combinaisons courantes sont TypeScript, Babel et Webpack - dans toutes les permutations possibles.
Babel et TypeScript ont leurs propres compilateurs, et leurs chargeurs respectifs permettent au développeur de les utiliser, pour une intégration facile. Et c'est là que réside la menace cachée.
Ces compilateurs atteignent votre code avant l'optimisation du code. Et que ce soit par défaut ou par mauvaise configuration, ces compilateurs produisent souvent des modules CommonJS, au lieu d'ESM. Comme mentionné dans une section précédente, les modules CommonJS sont dynamiques et, par conséquent, ne peuvent pas être correctement évalués pour l'élimination du code mort.
Ce scénario devient encore plus courant de nos jours, avec la croissance des applications "isomorphes" (c'est-à-dire des applications qui exécutent le même code côté serveur et côté client). Étant donné que Node.js n'a pas encore de support standard pour les ESM, lorsque les compilateurs sont ciblés sur l'environnement de node
, ils génèrent CommonJS.
Assurez-vous donc de vérifier le code que votre algorithme d'optimisation reçoit .
Liste de contrôle pour secouer les arbres
Maintenant que vous connaissez les tenants et les aboutissants du fonctionnement du regroupement et de l'arborescence, dessinons-nous une liste de contrôle que vous pourrez imprimer à un endroit pratique lorsque vous revisiterez votre implémentation et votre base de code actuelles. Espérons que cela vous fera gagner du temps et vous permettra d'optimiser non seulement les performances perçues de votre code, mais peut-être même les temps de construction de votre pipeline !
- Utilisez les ESM, et pas seulement dans votre propre base de code, mais privilégiez également les packages qui génèrent des ESM comme consommables.
- Assurez-vous de savoir exactement lesquelles (le cas échéant) de vos dépendances n'ont pas déclaré
sideEffects
ou les ont définies surtrue
. - Utilisez l'annotation en ligne pour déclarer des appels de méthode purs lors de la consommation de packages avec des effets secondaires.
- Si vous produisez des modules CommonJS, assurez-vous d'optimiser votre bundle avant de transformer les instructions d'importation et d'exportation.
Création de packages
Espérons qu'à ce stade, nous convenons tous que les ESM sont la voie à suivre dans l'écosystème JavaScript. Comme toujours dans le développement de logiciels, cependant, les transitions peuvent être délicates. Heureusement, les auteurs de packages peuvent adopter des mesures incassables pour faciliter une migration rapide et transparente pour leurs utilisateurs.
Avec quelques petits ajouts à package.json
, votre package pourra indiquer aux bundlers les environnements pris en charge par le package et comment ils sont le mieux pris en charge. Voici une liste de contrôle de Skypack :
- Inclure une exportation ESM.
- Ajoutez
"type": "module"
. - Indiquez un point d'entrée via
"module": "./path/entry.js"
(une convention communautaire).
Et voici un exemple qui se produit lorsque toutes les meilleures pratiques sont suivies et que vous souhaitez prendre en charge les environnements Web et Node.js :
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
En plus de cela, l'équipe Skypack a introduit un score de qualité de package comme référence pour déterminer si un package donné est configuré pour la longévité et les meilleures pratiques. L'outil est open source sur GitHub et peut être ajouté en tant que devDependency
à votre package pour effectuer facilement les vérifications avant chaque version.
Emballer
J'espère que cet article vous a été utile. Si tel est le cas, envisagez de le partager avec votre réseau. J'ai hâte d'interagir avec vous dans les commentaires ou sur Twitter.
Ressources utiles
Articles et documentation
- "Modules ES : une analyse approfondie des dessins animés", Lin Clark, Mozilla Hacks
- "Arbre secouant", Webpack
- "Configuration", Webpack
- « Optimisation », Webpack
- « Scope Hoisting », documentation de Parcel version 2
Projets et outils
- Terser
- babel-plugin-transformer-importe
- Skypack
- Webpack
- Colis
- Cumul
- esbuild
- CFC
- Vérification du colis