Fonctionnement de JS: arborescences de syntaxe abstraite, analyse et optimisation


Nous savons tous que le code JavaScript pour les projets Web peut atteindre une taille énorme. Et plus le code est grand, plus le navigateur le chargera longtemps. Mais le problème ici n'est pas seulement au moment de la transmission des données sur le réseau. Après le chargement du programme, il doit encore être analysé, compilé en bytecode et enfin exécuté. Aujourd'hui, nous portons à votre attention une traduction de la partie 14 de la série de l'écosystème JavaScript. À savoir, nous parlerons de l'analyse du code JS, de la façon dont les arbres de syntaxe abstraits sont construits et de la façon dont un programmeur peut influencer ces processus, augmentant ainsi la vitesse de leurs applications.

image

Comment sont les langages de programmation


Avant de parler des arbres de syntaxe abstraite, examinons le fonctionnement des langages de programmation. Quelle que soit la langue que vous utilisez, vous devez toujours utiliser certains programmes qui prennent le code source et le convertissent en quelque chose qui contient des commandes spécifiques pour les machines. Les interprètes ou les compilateurs agissent en tant que tels programmes. Peu importe que vous écriviez dans un langage interprété (JavaScript, Python, Ruby) ou compilé (C #, Java, Rust), votre code, qui est du texte brut, passera toujours par l'étape d'analyse, c'est-à-dire transformer le texte brut en une structure de données appelé un arbre de syntaxe abstraite (AST).

Les arbres de syntaxe abstraite fournissent non seulement une représentation structurée du code source, ils jouent également un rôle crucial dans l'analyse sémantique, au cours de laquelle le compilateur vérifie l'exactitude des constructions logicielles et l'utilisation correcte de leurs éléments. Après avoir formé l'AST et effectué des vérifications, cette structure est utilisée pour générer du bytecode ou du code machine.

Utilisation d'arbres syntaxiques abstraits


Les arbres de syntaxe abstraite sont utilisés non seulement dans les interprètes et les compilateurs. Ils, dans le monde des ordinateurs, sont utiles dans de nombreux autres domaines. L'une des applications les plus courantes est l'analyse de code statique. Les analyseurs statiques n'exécutent pas le code qui leur est transmis. Cependant, malgré cela, ils doivent comprendre la structure des programmes.

Supposons que vous souhaitiez développer un outil qui trouve des structures fréquentes dans votre code. Les rapports d'un tel outil aideront à la refactorisation et réduiront la duplication de code. Cela peut être fait en utilisant la comparaison de chaînes habituelle, mais cette approche sera très primitive, ses capacités seront limitées. En fait, si vous souhaitez créer un outil similaire, vous n'avez pas besoin d'écrire votre propre analyseur pour JavaScript. Il existe de nombreuses implémentations open source de ces programmes qui sont entièrement compatibles avec la spécification ECMAScript. Par exemple - Esprima et Acorn. Il existe également des outils qui peuvent aider à travailler avec ce que les analyseurs génèrent, à savoir, avec des arbres de syntaxe abstraite.

De plus, les arbres à syntaxe abstraite sont largement utilisés dans le développement de transpilers. Supposons que vous décidiez de développer un transpilateur qui convertit le code Python en code JavaScript. Un projet similaire peut être basé sur l'idée qu'un transpilateur est utilisé pour créer une arborescence de syntaxe abstraite basée sur du code Python, qui, à son tour, est converti en code JavaScript. Vous vous demanderez probablement ici comment cela est possible. Le fait est que les arbres de syntaxe abstraite ne sont qu'une façon alternative de représenter le code dans certains langages de programmation. Avant que le code soit converti en AST, il ressemble à du texte ordinaire, lorsqu'il est écrit et suit certaines règles qui forment le langage. Après l'analyse, ce code se transforme en une arborescence qui contient les mêmes informations que le code source du programme. En conséquence, il est possible d'effectuer non seulement la transition du code source vers AST, mais également la transformation inverse, transformant l'arbre de syntaxe abstraite en une représentation textuelle du code de programme.

Analyser JavaScript


Parlons de la construction des arbres de syntaxe abstraite. À titre d'exemple, considérons une simple fonction JavaScript:

function foo(x) {    if (x > 10) {        var a = 2;        return a * x;    }    return x + 10; } 

L'analyseur va créer un arbre de syntaxe abstrait, qui est schématiquement représenté dans la figure suivante.


Arbre de syntaxe abstraite

Veuillez noter qu'il s'agit d'une représentation simplifiée des résultats de l'analyseur. Un véritable arbre de syntaxe abstraite semble beaucoup plus compliqué. Dans ce cas, notre objectif principal est de se faire une idée de ce en quoi le code source se transforme avant son exécution. Si vous souhaitez voir à quoi ressemble un véritable arbre de syntaxe abstraite, utilisez le site Web AST Explorer . Afin de générer un AST pour un certain fragment de code JS, il suffit de le placer dans le champ correspondant de la page.

Peut-être que vous aurez ici une question sur la raison pour laquelle le programmeur doit savoir comment fonctionne l'analyseur JS. En fin de compte, l'analyse et l'exécution du code est une tâche du navigateur. D'une certaine manière, vous avez raison. La figure ci-dessous montre le temps nécessaire à certains projets Web bien connus pour effectuer diverses étapes du processus d'exécution du code JS.

Examinez de plus près ce dessin, vous y verrez peut-être quelque chose d'intéressant.


Temps passé à exécuter le code JS

Tu vois? Sinon, regardez encore. En fait, nous parlons du fait qu'en moyenne, les navigateurs passent 15 à 20% du temps à analyser le code JS. Et ce ne sont pas des données conditionnelles. Voici des informations statistiques sur le travail de vrais projets Web qui utilisent JavaScript d'une manière ou d'une autre. Peut-être que le chiffre de 15% ne vous semble pas si grand, mais croyez-moi, c'est beaucoup. Une application typique d'une page charge environ 0,4 Mo de code JavaScript et le navigateur a besoin d'environ 370 ms pour analyser ce code. Encore une fois, vous pouvez dire qu'il n'y a rien à craindre. Et oui, cela seul n'est pas beaucoup. Cependant, n'oubliez pas que c'est juste le temps qu'il faut pour analyser le code et le transformer en AST. Cela n'inclut pas le temps nécessaire pour exécuter le code, ni le temps nécessaire pour résoudre d'autres tâches qui accompagnent le chargement de la page, par exemple, les tâches de traitement HTML et CSS et de rendu de la page . De plus, nous ne parlons que des navigateurs de bureau. Dans le cas des systèmes mobiles, c'est encore pire. En particulier, le temps d'analyse du même code sur les appareils mobiles peut être 2 à 5 fois plus long que sur le bureau. Jetez un œil à la figure suivante.


Temps d'analyse de 1 Mo de code JS sur divers appareils

Voici le temps requis pour analyser 1 Mo de code JS sur divers appareils mobiles et de bureau.

De plus, les applications Web deviennent de plus en plus complexes et de plus en plus de tâches sont transférées du côté client. Tout cela vise à améliorer l'expérience utilisateur de travailler avec des sites Web, afin de rapprocher ces sentiments de ceux que les utilisateurs ressentent lorsqu'ils interagissent avec des applications traditionnelles. Il est facile de déterminer dans quelle mesure cela affecte les projets Web. Pour ce faire, ouvrez simplement les outils de développement dans le navigateur, accédez à un site moderne et voyez combien de temps est consacré à l'analyse du code, à la compilation et à tout ce qui se passe dans le navigateur lors de la préparation de la page pour le travail.


Analyse de site Web à l'aide d'outils de développement dans un navigateur

Malheureusement, les navigateurs mobiles ne disposent pas de tels outils. Cependant, cela ne signifie pas que les versions mobiles des sites ne peuvent pas être analysées. Ici, des outils comme DeviceTiming viendront à notre aide. Avec DeviceTiming, vous pouvez mesurer le temps nécessaire pour analyser et exécuter des scripts dans des environnements gérés. Cela fonctionne grâce au placement de scripts locaux dans l'environnement formé par le code auxiliaire, ce qui conduit au fait que chaque fois que la page est chargée à partir de divers appareils, nous avons la possibilité de mesurer localement le temps d'analyse et d'exécution de code.

Optimisation de l'analyse et moteurs JS


Les moteurs JS font beaucoup de choses utiles afin d'éviter un travail inutile et d'optimiser les processus de traitement de code. Voici quelques exemples.

Le moteur V8 prend en charge les scripts de streaming et la mise en cache du code. Dans ce cas, la diffusion en continu signifie que le système est engagé dans l'analyse des scripts chargés de manière asynchrone et des scripts dont l'exécution est retardée, dans un thread distinct, commençant à le faire à partir du moment où le code commence à se charger. Cela conduit au fait que l'analyse se termine presque simultanément à la fin du chargement du script, ce qui permet une réduction d'environ 10% du temps requis pour préparer les pages pour le travail.

Le code JavaScript est généralement compilé en bytecode chaque fois qu'une page est visitée. Ce bytecode, cependant, est perdu après que l'utilisateur accède à une autre page. Cela est dû au fait que le code compilé dépend fortement de l'état et du contexte du système au moment de la compilation. Afin d'améliorer la situation, Chrome 42 a introduit la prise en charge de la mise en cache de bytecode. Grâce à cette innovation, le code compilé est stocké localement, par conséquent, lorsque l'utilisateur revient sur la page qui a déjà été visitée, il n'est pas nécessaire de télécharger, analyser et compiler des scripts pour le préparer au travail. Cela permet à Chrome d'économiser environ 40% du temps d'analyse et de compilation. De plus, dans le cas des appareils mobiles, cela conduit à économiser la batterie.

Le moteur Carakan , qui était utilisé dans le navigateur Opera et a été remplacé depuis longtemps par la V8, pourrait réutiliser les résultats de compilation de scripts déjà traités. Il n'était pas nécessaire que ces scripts soient connectés à la même page ni même chargés à partir du même domaine. Cette technique de mise en cache est en effet très efficace et vous permet d'abandonner complètement l'étape de compilation. Elle s'appuie sur des scénarios de comportement utilisateur typiques, sur la façon dont les gens travaillent avec les ressources Web. À savoir, lorsque l'utilisateur suit une certaine séquence d'actions, tout en travaillant avec une application Web, le même code est chargé.

L'interpréteur SpiderMonkey utilisé par FireFox ne met pas tout en cache dans une rangée. Il prend en charge un système de surveillance qui compte le nombre d'appels à un script particulier. Sur la base de ces indicateurs, les sections du code qui doivent être optimisées sont déterminées, c'est-à-dire celles qui ont la charge maximale.

Bien sûr, certains développeurs de navigateurs peuvent décider que leurs produits n'ont pas du tout besoin de mise en cache. Ainsi, Masei Stachovyak , l'un des principaux développeurs du navigateur Safari, affirme que Safari n'est pas impliqué dans la mise en cache du bytecode compilé. La possibilité de mise en cache a été envisagée, mais elle n'a pas encore été implémentée, car la génération de code prend moins de 2% du temps total d'exécution du programme.

Ces optimisations n'affectent pas directement l'analyse du code source dans JS. Au cours de leur application, tout est mis en œuvre pour, dans certains cas, ignorer complètement cette étape. Quelle que soit la rapidité de l'analyse, cela prend encore un certain temps, et l'absence totale d'analyse est peut-être l'exemple d'une optimisation parfaite.

Réduisez le temps de préparation des applications Web


Comme nous l'avons découvert ci-dessus, il serait bien de minimiser le besoin d'analyser les scripts, mais vous ne pouvez pas vous en débarrasser complètement, alors parlons de la façon de réduire le temps nécessaire pour préparer les applications Web au travail. En fait, beaucoup peut être fait pour cela. Par exemple, vous pouvez réduire la quantité de code JS inclus dans l'application. Un petit code qui prépare une page pour le travail peut être analysé plus rapidement et son exécution prendra probablement moins de temps qu'un code plus volumineux.

Afin de réduire la quantité de code, vous pouvez organiser le chargement sur la page uniquement ce dont il a vraiment besoin, et non un énorme morceau de code, qui comprend absolument tout ce qui est nécessaire pour le projet Web dans son ensemble. Ainsi, par exemple, le modèle PRPL favorise exactement une telle approche du chargement de code. Comme alternative, vous pouvez vérifier les dépendances et voir s'il y a quelque chose de redondant en elles, de sorte que cela ne mène qu'à une croissance injustifiée de la base de code. En fait, nous avons abordé ici un grand sujet digne d'un matériau distinct. Retour à l'analyse.

Ainsi, le but de ce matériel est de discuter des techniques qui permettent à un développeur Web d'aider un analyseur à faire son travail plus rapidement. De telles techniques existent. Les analyseurs JS modernes utilisent des algorithmes heuristiques pour déterminer s'il sera nécessaire d'exécuter un certain morceau de code dès que possible, ou s'il devra être exécuté plus tard. Sur la base de ces prédictions, l'analyseur analyse soit complètement le fragment de code à l'aide de l'algorithme d'analyse désireuse ou utilise l'algorithme d'analyse paresseuse. Avec une analyse complète, vous comprenez les fonctions dont vous avez besoin pour compiler dès que possible. Au cours de ce processus, trois tâches principales sont résolues: la création d'un AST, la création d'une hiérarchie de zones de visibilité et la recherche d'erreurs de syntaxe. L'analyse paresseuse, en revanche, n'est utilisée que pour les fonctions qui n'ont pas encore besoin d'être compilées. Cela ne crée pas d'AST et ne recherche pas d'erreurs. Avec cette approche, seule une hiérarchie des zones de visibilité est créée, ce qui permet d'économiser environ la moitié du temps par rapport aux fonctions de traitement qui doivent être exécutées dès que possible.

En fait, le concept n'est pas nouveau. Même les navigateurs obsolètes comme IE9 prennent en charge de telles approches d'optimisation, bien que, bien sûr, les systèmes modernes soient allés loin.

Examinons un exemple illustrant le fonctionnement de ces mécanismes. Supposons que nous ayons le code JS suivant:

 function foo() {   function bar(x) {       return x + 10;   }   function baz(x, y) {       return x + y;   }   console.log(baz(100, 200)); } 

Comme dans l'exemple précédent, le code tombe dans l'analyseur, qui effectue son analyse et forme l'AST. Par conséquent, l'analyseur représente un code composé des parties principales suivantes (nous ne ferons pas attention à la fonction foo ):

  • Déclaration d'une fonction de bar qui prend un argument ( x ). Cette fonction a une commande de retour, elle retourne le résultat de l'addition de x et 10.
  • Déclaration d'une fonction baz qui prend deux arguments ( x et y ). Elle a également une commande de retour, elle renvoie le résultat de l'addition de x et y .
  • Faire un appel à la fonction baz avec deux arguments - 100 et 200.
  • Appeler la fonction console.log avec un argument, qui est la valeur renvoyée par la fonction précédemment appelée.

Voici à quoi ça ressemble.


Résultat de l'analyse de l'exemple de code sans appliquer d'optimisation

Parlons de ce qui se passe ici. L'analyseur voit la déclaration de la fonction bar , la déclaration de la fonction baz , l'appel à la fonction baz et l'appel à la fonction console.log . Évidemment, en analysant ce morceau de code, l'analyseur rencontrera une tâche dont l'exécution n'affectera pas les résultats de ce programme. Il s'agit d'analyser la bar fonctions. Pourquoi l'analyse de cette fonction n'est-elle pas pratique? Le fait est que la fonction bar , au moins dans le fragment de code présenté, n'est jamais appelée. Cet exemple simple peut sembler tiré par les cheveux, mais de nombreuses applications réelles ont un grand nombre de fonctions qui ne sont jamais appelées.

Dans une telle situation, au lieu d'analyser la fonction bar , nous pouvons simplement enregistrer qu'elle est déclarée, mais n'est utilisée nulle part. Dans le même temps, l'analyse réelle de cette fonction est effectuée lorsqu'elle devient nécessaire, juste avant son exécution. Naturellement, lors de l'analyse paresseuse, vous devez détecter le corps de la fonction et enregistrer sa déclaration, mais c'est là que le travail se termine. Pour une telle fonction, il n'est pas nécessaire de former un arbre de syntaxe abstraite, car le système ne dispose pas d'informations sur le fait que cette fonction doit être exécutée. De plus, la mémoire de tas n'est pas allouée, ce qui nécessite généralement des ressources système considérables. En résumé, le refus d'analyser des fonctions inutiles entraîne une augmentation significative des performances du code.

Par conséquent, dans l'exemple précédent, l'analyseur réel formera une structure ressemblant au schéma suivant.


Résultat de l'analyse d'un exemple de code avec optimisation

Notez que l'analyseur a fait une note sur la déclaration de la bar fonctions, mais n'a pas traité son analyse plus approfondie. Le système n'a fait aucun effort pour analyser le code de fonction. Dans ce cas, le corps de la fonction était une commande pour retourner le résultat de calculs simples. Cependant, dans la plupart des applications du monde réel, le code de fonction peut être beaucoup plus long et plus complexe, contenant de nombreuses commandes de retour, conditions, boucles, commandes de déclaration de variables et fonctions imbriquées. Analyser tout cela, à condition que de telles fonctions ne soient jamais appelées, est une perte de temps.

Il n'y a rien de compliqué dans le concept décrit ci-dessus, mais sa mise en œuvre pratique n'est pas une tâche facile. Ici, nous avons examiné un exemple très simple et, en fait, pour décider si un certain morceau de code sera demandé dans un programme, il est nécessaire d'analyser les fonctions, les boucles, les opérateurs conditionnels et les objets. En général, nous pouvons dire que l'analyseur doit traiter et analyser absolument tout ce qui est dans le programme.

Voici, par exemple, un modèle très courant d'implémentation de modules en JavaScript:

 var myModule = (function() {   //      //    })(); 

La plupart des analyseurs JS modernes reconnaissent ce modèle; pour eux, c'est un signal que le code situé à l'intérieur du module doit être entièrement analysé.

Mais que se passe-t-il si les analyseurs utilisent toujours l'analyse paresseuse? Ce n'est malheureusement pas une bonne idée. Le fait est qu'avec cette approche, si du code doit être exécuté le plus tôt possible, nous rencontrerons un ralentissement du système. L'analyseur effectuera une passe d'analyse paresseuse, après quoi il commencera immédiatement à analyser complètement ce qui doit être fait dès que possible. Cela entraînera un ralentissement d'environ 50% par rapport à l'approche lorsque l'analyseur commence immédiatement à analyser entièrement le code le plus important.

Optimisation du code, en tenant compte des caractéristiques de son analyse


Maintenant que nous avons compris un peu ce qui se passe à l'intérieur des analyseurs, il est temps de réfléchir à ce qui peut être fait pour les aider. Nous pouvons écrire du code pour que l'analyse des fonctions soit effectuée au moment voulu. Il y a un modèle que la plupart des analyseurs comprennent. Elle s'exprime dans le fait que les fonctions sont placées entre crochets. Une telle conception indique presque toujours à l'analyseur que la fonction doit être démontée immédiatement. Si l'analyseur détecte une parenthèse ouvrante, immédiatement après laquelle la déclaration de fonction suit, il commence immédiatement l'analyse de la fonction. Nous pouvons aider l'analyseur en appliquant cette technique lors de la description des fonctions qui doivent être effectuées dès que possible.

Supposons que nous ayons une fonction foo :

 function foo(x) {   return x * 10; } 

Puisqu'il n'y a aucune indication explicite dans ce fragment de code que cette fonction doit être exécutée immédiatement, le navigateur effectuera uniquement son analyse paresseuse. Cependant, nous sommes convaincus que nous aurons besoin de cette fonction très bientôt, afin que nous puissions recourir à la prochaine astuce.

Tout d'abord, enregistrez la fonction dans une variable:

 var foo = function foo(x) {   return x * 10; }; 

Veuillez noter que nous avons laissé le nom de la fonction initiale entre le mot-clé de la function et le crochet ouvrant. On ne peut pas dire que cela est absolument nécessaire, mais il est recommandé de le faire, car si une exception est levée lorsque la fonction est en cours d'exécution, vous pouvez voir le nom de la fonction dans les données de trace de la pile, pas <anonymous> .

Après la modification ci-dessus, l'analyseur continuera à utiliser l'analyse paresseuse. Pour changer cela, un petit détail suffit. La fonction doit être placée entre parenthèses:

 var foo = (function foo(x) {   return x * 10; }); 

Désormais, lorsque l'analyseur trouvera une parenthèse ouvrante devant le mot-clé function , il commencera immédiatement l'analyse de cette fonction.

Il peut ne pas être facile d'effectuer de telles optimisations manuellement, car pour cela, vous devez savoir dans quels cas l'analyseur effectuera une analyse paresseuse et dans lequel l'analyse complète. De plus, pour ce faire, vous devez passer du temps à décider si une fonction particulière doit être prête à travailler le plus rapidement possible ou non.

Les programmeurs, à coup sûr, ne voudront pas assumer tout ce travail supplémentaire. De plus, ce qui n'est pas moins important que tout ce qui a déjà été dit, le code ainsi traité sera plus difficile à lire et à comprendre. Dans cette situation, des progiciels spéciaux comme Optimize.js sont prêts à nous aider. Leur objectif principal est d'optimiser le temps de démarrage initial du code source JS. Ils effectuent une analyse de code statique et la modifient afin que les fonctions qui doivent être exécutées dès que possible soient placées entre crochets, ce qui conduit au fait que le navigateur les analyse immédiatement et les prépare pour l'exécution.

Supposons donc que nous programmions, sans vraiment penser à rien, et que nous ayons le fragment de code suivant:

 (function() {   console.log('Hello, World!'); })(); 

Il semble tout à fait normal, il fonctionne comme prévu, il est exécuté rapidement, car l'analyseur trouve le crochet ouvrant devant le mot-clé function . Jusqu'ici tout va bien. , , , :

 !function(){console.log('Hello, World!')}(); 

, , . , - .

, , . , , , . , , , . , , . Optimize.js. Optimize.js, :

 !(function(){console.log('Hello, World!')})(); 

, . , . , , , — .


, JS- — , . ? , , , , . , , , , JS- , . , , , -, . - . , , . , , , , . , JS- , , V8 , , . .


, -:

  • . .
  • , .
  • , , , JS-. , , .
  • DeviceTiming , .
  • Optimize.js , , .

Résumé


, , SessionStack , , -, . , . — . , — , -, , , .

Chers lecteurs! - JavaScript-?

Source: https://habr.com/ru/post/fr415269/


All Articles