Comment le hoisting fonctionne en JavaScript et pourquoi

in JavaScript fr

La plupart des développeurs front end que j’ai rencontré connaissent les principes de base du hoisting (hissage en anglais). Comment la déclaration des variables est gérée en JavaScript et pourquoi on peut rencontrer ce qui semblera être un comportement aberrant du language aux autres développeurs. Mais peu savent pourquoi et comment cela fonctionne sous le capot. Je vais ici tenter une explication basique du processus.

Qu’est-ce que le hoisting

Juste au cas, où vous ne seriez pas familier avec ce principe, voici quelques morceaux de codes pour le voir en action. Jouons avec la variable foo et envoyons la dans la console. N’hésitez pas à la taper dans votre propre console pour vous aider à mieux saisir le concept.

console.log( foo );

Celle-ci est facile, foo n’est pas déclarée avant, nous obtenons donc l’erreur suivante : ReferenceError: foo is not defined.

var foo;
console.log( foo );
var bar = 1;
console.log( bar );

Celle-ci est facile également, foo est déclarée, mais il ne lui a pas été assigné de valeur, elle est donc : undefined. bar est déclarée et une valeur lui a été assignée, elle renvoie donc 1.

console.log( foo );
console.log( bar );
var bar = 1;
var foo;

Maintenant, celle-là pose problème. Cela pourrait renvoyer une paire d’erreur puisque j’essaye de les loger avant de les déclarer. Sauf que non. Cela renvoie undefined pour les deux variables foo et bar.

var foo = 1;
(function() {
    console.log( foo );
    var foo = 10;
})();

Voici l’exemple classique complet de hoisting. Cela ne reverra ni 1 ni 10 mais undefined.

Que s’est-il donc passé pour ce deux derniers exemples ? Le moteur JavaScript s’est comporté comme si il avait réécrit le code un petit peu. Pour ces exemples, ils sont équivalents à ce qui suit.

var foo, bar;
console.log( foo );
console.log( bar );
bar = 1;

/* ***** */

var foo,
foo = 1;
(function() {
    var foo;
    console.log( foo );
    foo = 10;
})();

C’est le hoisting. Quand la compilation a lieu, le moteur semble hisser toutes les déclarations de variables en haut du contexte d’exécution pour exécuter le code seulement ensuite.

Quoi, qu’est-ce que je viens d’écrire ? compilation ? Oui, tout à fait. Nous verrons ça plus tard.

Une étrangeté ? Nop, c’est la spec.

JavaScript est une implementation de la spécification ECMAScript®. Actuellement dans sa cinquièmme version (la jolie sixièmme pointe le bout de son nez !). Et le début de la section 10.5 : Declaration Binding Instantiation est claire :

Every execution context has an associated VariableEnvironment. Variables and functions declared in ECMAScript code evaluated in an execution context are added as bindings in that VariableEnvironment’s Environment Record. For function code, parameters are also added as bindings to that Environment Record.

Which Environment Record is used to bind a declaration and its kind depends upon the type of ECMAScript code executed by the execution context, but the remainder of the behaviour is generic. On entering an execution context, bindings are created in the VariableEnvironment as follows using the caller provided code and, if it is function code, argument List args

Donc, quand le moteur JS entre dans un contexte d’exécution (une fonction par exemple), il commence par créer les bindings ; puis il exécute les instructions et assigne les valeurs. Ce qui signifie un certain nombre de choses pour les variables qu’il va rencontrer dans ce contexte :

Si le moteur rencontre une déclaration de variable (var = ...), il créé un binding dans le contexte courant.

var foo; // creates a binding

Si le moteur ne peut trouver de déclaration de variable pour une variable utilisée dans un contexte, il va aller chercher récursivement dans les contextes parents jusqu’à trouver la déclaration en question et créer un binding dans le contexte courant qui sera une référence vers le binding du contexte contenant la déclaration.

var foo;
(function(){
    foo = 1;    // creates a binding with the foo declared on parent context
})();

Pour cette première étape de l’exécution d’un code JavaScript, le moteur peut adopter diverses stratégies. Il pourrait scanner le code pour chercher des déclarations, créer les bondings correspondant ; puis le re-scanner avec en mémoire ce set de bindings puis aller chercher les références manquantes dans les contextes parents. Il pourrait aussi créer des bindings à la volée vers les contextes parents ; et les détruire s’il rencontre une déclaration dans le contexte courant. (voir l’exemple de code suivant)

Une partie des moteurs JavaScript n’étant pas open source, c’est difficile de dire quelle est la stratégie employée. Dans tous les cas, la bonne pratique, est de déclarer vos variables au début de votre contexte d’exécution ; même sans leur assigner de valeur tout de suite.

var foo;
(function(){
    foo = 1;    // does nothing for this step
    var foo;    // erase the previous binding and create a new one > set to undefined
    foo = 2;    // does nothing for this step
})();

Le contexte d’exécution courant garde une liste des bindings, c’est la table VariableEnvironment.

Techniquement, ce que nous appelons généralement le scope est en fait un LexicalEnvironment. Qui est un des trois composants du contexte d’exécution. C’est le fonctionnement théorique que vous trouverez décrit dans la spec. C’est l’endroit où les variables vont exister, mais c’est souvent confondu avec la liste des bindings ou carrément tout le contexte d’exécution. C’est une confusion qui ne prête généralement pas à conséquence ; mais c’est toujours mieux d’être au clair sur les concept que l’on manipule.

Le compilateur Javascript

Petit retour sur la mention de compilateur. Oui, le JS est un language de script (et un language de programmation dynamique). Oui vous envoyez direction un script aux postes clients et pas un binaire compilé ou un exécutable. Mais tout de même ; pensez-vous que var foo = 1; ait un sens quelconque pour le processeur ? Ce n’est pas du tout le cas.

Pour ce qui suit, je parlerai de la façon dont ça fonctionne pour Chromium (et Chrome) et pour son moteur JS : V8. Parce-que les deux sont open-source, on peut facilement lire et vérifier le code. Les autres navigateurs et moteurs ne fonctionnent pas exactement de la même façon, mais produisent plus ou moins le même résultat ; modulo la vitesse..

Si vous explorez un peu le brillant code source de V8, vous pouvez voir qu’il en existe une version pour chaque plateforme majeure (ARM, x64, ia32…). Parce-que le moteur a besoin d’un language et d’un jeu d’instruction spécifique pour s’adresser à chaque famille de processeur. L’instruction “allouer 128 bits” de mémoire pour ma nouvelle variable ne sera pas écrite de la même façon pour votre macbook et pour votre nexus.

Donc oui, JavaScript est compilé, c’est juste que cela n’arrive que quelques instants avant son exécution sur la machine client.

Les étapes de la compilation pour “hisser”

Quand V8 entre dans le contexte d’exécution du morceau de code suivant, quelques étapes principales ont lieu.

var foo;
var bar = function(){
    foo = 1;
    var foo;
};
bar();
  1. Le compilateur commence par faire le lexing (ou tokenizing) de ces deux lignes de code. Ce qui signifie qu’il va diviser le code en “tokens” atomiques comme foo, = or 1.
  2. Ensuite il va parser une version traduite de ces tokens dans un AST (for Abstract Syntax Tree) construit pour l’ensemble du contexte d’exécution.
  3. Ensuite, il va pratiquer une analyse du scope courant. Chaque fois qu’il va rencontrer une déclaration, il va l’envoyer avec le scope pour créer le binding. Chaque fois qu’il va rencontrer une assignation ou une évaluation, il va demander le binding au scope. Si le binding n’est pas présent dans le contexte courant, il va aller le chercher récursivement dans les contextes parents jusqu’à le trouver. Si le moteur atteint le contexte global et qu’il ne peut toujours pas trouver le binding, il y a deux options. Soit on est en ES6 ou en mode strict et il renvoie une erreur. Soit on est en ES5 ou moins et dans un mode permissif et le moteur va initialiser le binding pour vous dans le scope global (la variable sera alors accessible globalement). Mais ça, c’est mal. Évitez autant que possible. Le moteur termine en mapant tous les bindings pour les variables locales, capturée, libres et globales.
  4. Ensuite le moteur génère un code compilé que la machine peut exécuter et l’encapsule dans un objet Code<span style="font-family: Lato, sans-serif; font-size: medium;](.</span)>
    Puis le code est exécuté.

Il y a beaucoup d’autre étapes intermédiaires pour la compilation. Dans V8, l’infrastructure de compilation est nommée crankshaft. Après l’analyse du scope, il génère un graphe de control en Hydrogen. Qui est un language haut niveau cross platform de représentation de l’AST pour le compilateur de V8. Ensuite une douzaine d’optimisation sont effectuées sur ce code. Des choses comme l’inlining, l’inférence de typage statique, l’analyse de range, la canonicalisation, la suppression de code mort… Nous n’irons pas plus loin dans cet article parce-que chaque optimisation mériterai un article à elle toute seule. Et parce-que c’est complètement inutile de les comprendre pour faire du bon JavaScript. Après cette étape, le code est traduit dans un autre language, Lithium. C’est un language très bas niveau, spécifique à chaque plateforme. La version finale pour le processeur sera écrite à partir de cette version en Lithium.

Il y a deux versions parce-que la plupart des optimisations sont valable sur toutes les plateformes et qu’il est plus simple pour l’équipe de V8 de mutualiser cette partie du code.

Conclusion

Le hoisting n’est pas une étrangeté, c’est la façon dont le language est sensé fonctionner. Mais si vous respectez quelques bonnes pratiques de base, vous ne devriez pas souvent rencontrer de problèmes de ce type.