Tree Shaking React Applications natives

Nov 28 2022
Comment nous avons appliqué des techniques d'optimisation communes aux applications Web à notre application React Native pour réduire les temps de démarrage de 20 %. Arbre quoi ? Certes, Tree Shaking pourrait être un terme déroutant.

Comment nous avons appliqué des techniques d'optimisation communes aux applications Web à notre application React Native pour réduire les temps de démarrage de 20 %.

Arbre quoi ?

Certes, Tree Shaking pourrait être un terme déroutant. Vous en avez peut-être déjà entendu parler sous le nom d'« élision d'importation » dans TypeScript. Tree Shaking est une forme d' élimination du code mort liée spécifiquement à la suppression des exportations inutilisées. Si nous concaténons tous les modules, les exportations inutilisées sont en fait du code mort et peuvent être supprimées. Cependant, le processus de détermination des exportations inutilisées n'est pas anodin. Tree Shaking est généralement implémenté au niveau du compilateur/bundler (par exemple Webpack ou ESBuild ) et non par un moteur JavaScript (tel que V8 ou Hermes). De nombreux modèles en JavaScript peuvent casser Tree Shaking mais dans cet article, je veux me concentrer sur un aspect : le système de modules. Les deux systèmes de modules pertinents que nous devons comprendre ici sont les modules CommonJS etModules ES .

CommonJS est utilisé lorsque vous écrivez module.exports = {}ou exports.someMethod = () => {}. Les modules ES sont identifiés par une syntaxe import. exportIl est plus difficile pour les compilateurs d'appliquer Tree Shaking au code en utilisant CommonJS qu'avec les modules ES. Les modules CommonJS sont souvent dynamiques tandis que les modules ES peuvent être analysés statiquement. Par exemple, il n'est pas trivial de détecter statiquement tous les identifiants d'exportation dans le code suivant :

Étant donné que les modules ES sont analysables statiquement par conception, les compilateurs ont plus de facilité à détecter les exportations inutilisées.
Il est donc dans votre intérêt de donner à votre compilateur d'optimisation des modules ES avec lesquels travailler et non des modules CommonJS.

Arrière plan

Avant de rejoindre Klarna, je n'avais aucune expérience de travail avec React Native. Lors d'un refactoring de routine, j'ai appliqué le diff suivant :

En supposant que le groupeur utilisé considérerait someFeatureMethodcomme inutilisé si SOME_STATIC_FLAGest faux et donc retiré some-feature-moduledu groupe final. Lors de la révision du code, ce diff a été marqué comme problématique, je me suis donc assis et j'ai revérifié mes hypothèses et où elles étaient brisées. Heureusement, nous sommes déjà passés à Webpack (sous la forme de Re.Pack ) quelques mois plus tôt pour permettre le fractionnement de bundle avecReact.lazy . Cela m'a permis de configurer plus facilement le processus de groupement de manière à pouvoir inspecter le groupe JavaScript final. Seul Hermes devait être désactivé dans notre cas pour afficher la sortie JavaScript finale.

Après quelques essais et erreurs pour faciliter la recherche de l'endroit où some-feature-moduleest importé, j'ai repéré la ligne suivante : c=(n(463526),n(456189)L' opérateur virgule est quelque chose que vous n'utilisez normalement pas, alors laissez-moi résumer ce que cela fait : il évalue tous les opérandes et n'utilise que la valeur de retour de le dernier opérande. En d'autres termes, la valeur de retour de n(463526)était inutilisée. Étant donné que j'avais déjà de l'expérience dans le travail avec le tree-shaking sur le Web, il était assez clair pour moi de quoi il s'agissait avant la minification : require('some-feature-module')(Webpack transforme les chaînes source d'importation en nombres).

Webpack a en fait reconnu qu'il someFeatureMethodn'était pas utilisé et a donc supprimé son utilisation. Cependant, Webpack a renoncé à supprimer les exportations inutilisées du module et a donc conservé l'importation car il ne savait pas si le module avait des effets secondaires. Si un module a des effets secondaires, nous ne pouvons pas simplement le supprimer du bundle car cela modifierait le déroulement du programme.

Tout ce que nous avions à faire pour que le diff original fonctionne comme prévu, était de nous assurer que Tree Shaking est appliqué au bundle final.

Mise en œuvre

Tout revient à s'assurer que vous ne transpilez pas les modules ES vers CommonJS avant que Webpack ne regroupe tous les modules. Si vous utilisez le préréglage Metro Babel (par défaut pour les nouvelles applications React Native), la plupart du travail consiste à activer disableImportExportTransform:

Notez que cette option n'est actuellement pas documentée et peut être supprimée à tout moment.

Nous devions également dire à Webpack d'utiliser des points d'entrée qui utilisent des modules ES au lieu de modules CommonJS. Pour les fichiers individuels, cela signifie préférer .mjstandis que pour les packages, nous devions dire à Webpack d'utiliser le modulechamp principal .

Cependant, cela a révélé des problèmes avec la façon dont nous avons écrit JavaScript et comment le code est écrit dans l'écosystème React Native. Nous avons identifié 3 classes de problèmes.

Exporter différentes syntaxes dans mainetmodule

Ces champs principaux ne doivent être utilisés que pour différencier le système de modules ( mainpour CommonJS, modulepour les modules ES). Cependant, de nombreux packages fournissent une syntaxe plus moderne à partir du point d' moduleentrée. Par exemple, classla syntaxe n'est actuellement pas prise en charge par Hermes .

Pour l'instant, nous transpilons tout node_modulesle contenu jusqu'à la syntaxe ES5 ou plutôt la syntaxe prise en charge par Hermes en ajoutant une personnalisation ruleà la configuration Webpack :

Importer un module CommonJS avec une syntaxe ambiguë

Webpack ne pourra pas trouver les exports de modules qui mélangent des systèmes de modules. Cependant, React Native lui-même expédie ses fichiers source avec un système de modules mixtes, par exemple

La solution ici est de continuer à transpiler ces modules vers CommonJS (désactivant ainsi Tree Shaking) en ajoutant un spécial ruleà la configuration Webpack :

Identifiants d'importation inexistants

Il s'agit en fait d'une SyntaxError en JavaScript dont la plupart ne sont pas conscients. Par exemple, import { doesNotExist } from 'some-module';lancera un SyntaxError. C'est en grande partie une nuisance pour les développeurs, mais cela peut entraîner de réels problèmes d'exécution. Nous avons appliqué cette implémentation stricte des modules ES dans Webpack en activant module.parser.javascript.exportsPresencedans la configuration Webpack.

La plupart de ces problèmes ont été causés par la réexportation de types dans TypeScript, par exemple

Heureusement, TypeScript peut signaler ces problèmes au niveau du type en activantisolatedModules :

typeles modificateurs sur les noms d'importation sont nouveaux dans TypeScript 4.5. L'ajout de la prise en charge des typemodificateurs sur les noms d'importation était tout un défi car nous devions mettre à niveau l' analyseur ESLint que nous utilisions , Prettier et TypeScript.

L'ajout typede modificateurs aux noms d'importation entraîne la suppression par Babel des importations de type qui n'existent pas réellement au moment de l'exécution.

Résultats

La mise en œuvre initiale était assez hacky. Cependant, les tout premiers résultats ont déjà montré une amélioration médiane du démarrage de 20% sur les deux plates-formes (Samsung Galaxy S9 2.2s contre 2.8s et iPhone 11 640ms contre 802ms).

Ce que nous avons constaté, c'est une réduction de 46 % de notre bloc JavaScript critique initial. La taille globale du JavaScript que nous avons expédié a diminué de 14 %. La différence est largement attribuée au déplacement du code du bloc principal vers les blocs asynchrones (fonctionnalités et itinéraires).

Différence absolue des statistiques du bundle après Tree Shaking
Différence relative des statistiques du bundle après Tree Shaking

Ces images sont créées par l'incroyable statoscope.tech qui nous a aidés à analyser ce changement et continuera à nous aider à apporter d'autres améliorations à la taille du bundle.

Notez que la réduction ne vient pas seulement de la suppression des exportations inutilisées, mais aussi de la capacité de Webpack ModuleConcatenationPluginà concaténer plus de modules. En d'autres termes, nous pouvons hisser plus de modules . Nous n'utilisons pas encore pleinement le levage de portée. À l'heure actuelle, seuls 20 % des modules sont hissés. Nous nous attendions à des gains de taille et d'exécution supplémentaires une fois que nous aurons augmenté ce nombre.

La réduction de 40 % de JavaScript correspond presque exactement à 1:1 au temps nécessaire pour évaluer le bundle JavaScript avant de pouvoir l'exécuter. L'évaluation de JavaScript bloque le temps de démarrage résultant, donc la réduction de la quantité de JavaScript expédié réduit directement le temps de démarrage.

Après la dernière étape de la mise en œuvre 2 semaines plus tard, nous avions toujours les mêmes résultats dans notre laboratoire et étions prêts à débarquer cette fonctionnalité dans notre branche principale. Nous avons pris un soin particulier à apporter le changement final directement après la date limite de publication. Cela nous a permis de tester le nouveau système de modules de manière approfondie dans les versions d'applications internes utilisées par nos employés. Après une semaine de tests internes, la fonctionnalité a été progressivement déployée auprès de nos utilisateurs finaux. Nous avions beaucoup d'espoir après avoir constaté que la stabilité de l'application n'était en grande partie pas affectée. Les données de production ont montré les mêmes améliorations relatives du temps de démarrage médian que nous avons constatées dans nos résultats de laboratoire :

La version 22.37 est sans tree shaking ; 22.38 avec secouage d'arbres

Données de production pour les utilisateurs d'Android
Données de production pour les utilisateurs iOS

Ces améliorations se font au prix d'une augmentation des temps de construction. Le regroupement du bundle JavaScript de production prend environ 30 % (4 minutes) de temps en plus. Nous prenons avec plaisir ces temps de construction accrus car ils se traduisent directement par une meilleure expérience utilisateur. Certains des temps de construction accrus sont attribués au fait de transpiler plus que nécessaire. La mise en œuvre initiale n'a pas consacré de temps à réduire la quantité que nous devons transpiler. Nous récupérerons également une partie des augmentations de temps de construction, plus les packages embarqueront des modules ES appropriés. Gardez à l'esprit que le temps de regroupement de JavaScript n'est pas la seule tâche requise pour créer une application React Native. Avec la compilation de binaires, etc., l'augmentation du regroupement de JavaScript n'a finalement pas d'impact.

Et après

Il semble que les modules ES n'aient pas été activement travaillés dans l'écosystème React Native. Nous voudrons aligner davantage l'écosystème sur l'utilisation correcte des modules ES (par exemple module, les entrées pointant vers JavaScript avec une syntaxe équivalente). De cette façon, nous pouvons réduire notre configuration de construction et moins transpiler.

Bien qu'il existe un support pour l'utilisation de modules ES derrière un drapeau dans Metro ( experimentalImportSupport), il est marqué comme expérimental et non documenté. Activer cet indicateur en développement ne fonctionne pas (encore) pour nous, mais nous espérons pouvoir un jour utiliser le même système de modules en développement et en production. Nous voulons relancer la discussion sur les modules ES dans React native car il semble que la prise en charge des modules ES ne soit actuellement pas activement travaillée. Le support Tree Shaking a même été complètement abandonné il y a des années. .

Après tout, les modules ES sont une fonctionnalité de langage que toute personne connaissant JavaScript finit par apprendre. Nous ne voyons aucune raison pour laquelle React Native devrait avoir une étape d'apprentissage supplémentaire pour comprendre le fractionnement des bundles et l'élimination du code mort.

Écrivez une fois, exécutez partout!

Avez-vous aimé cette publication? Suivez Klarna Engineering sur Medium et LinkedIn pour rester à l'écoute d'autres articles comme ceux-ci.