Portée, hoisting et mots-clés déclaratifs
Par Christophe Porteneuve • Publié le 28 février 2022 • 8 min

Bienvenue dans notre huitième article de la série JS idiomatique.

Tous les langages de programmation ont une notion de portée ; et la plupart des langages impératifs ou orientés objets l’implémentent de façon similaire. Mais JavaScript a — comme souvent — quelques spécificités importantes, sans compter qu’ES2015 a ajouté de nouveaux comportements. Voyons ça en détail, pour comprendre en quoi var est à bannir, pourquoi function est parfois plus pratique que les fonctions fléchées, et d’autres choses encore !

Tu préfères une vidéo ?

Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :

La portée, c’est quoi ?

Revenons un instant sur les bases. On utilise ce terme de deux façons, qui correspondent à deux perspectives réciproques :

  • La portée d’un identifiant, c’est l’ensemble des endroits du code où cet identifiant est « visible », c’est-à-dire référençable.
  • La portée d’un endroit du code, c’est l’ensemble des identifiants référençables depuis cet endroit (c’est le sens retenu dans une phrase comme « à cet endroit, la fonction bidule est dans la portée »).

On va se concentrer ici principalement sur le premier sens : la portée de nos identifiants, c’est-à-dire de nos déclarations. Mais on reparlera du deuxième sens lorsqu’on abordera les fermetures lexicales (“closures” en anglais).

Portée en JS et hoisting

Historiquement, JavaScript ne fournissait que deux mots-clés déclaratifs : var et function.

Ces deux mots-clés étaient visibles dans leur unité de portée, qui était forcément la fonction englobante (à défaut, la portée globale). En d’autres termes, il n’y avait pas d’unité de portée plus petite que le bloc de fonction. Les autres blocs (les paires d’accolades suivant une structure de contrôle comme un if ou un for par exemple) n’avaient pas leur propre portée, contrairement à ce qui se passe dans de nombreux langages :

var a = 1

function foo(b) {
// Ici on voit a, foo et b (c et i aussi, on va voir pourquoi
// tout à l'heure)

if (b > 1) {
var c = b * 10

}

for (var i = 0; i < 10; ++i) {

}

// Ici on voit toujours c et i : il ne sont pas limités aux
// blocs du `if` et du `for`.
}

// Ici on voit a et foo, mais pas b, ni c, ni i

C’est déjà un point surprenant pour beaucoup de gens venant à JS depuis des langages à la portée plus traditionnelle, par exemple en C# ou Java (mais pas en PHP, qui n’a pas de portée dédiée aux blocs non plus). Les idiosyncrasies (#TIL) de JS sur les portées ne s’arrêtent toutefois pas là.

Le hoisting

Lorsqu’un moteur JS met en place une unité de portée, il procède au hoisting des déclarations de fonctions (function) et de variables (var), c’est-à-dire qu’il fait remonter les déclarations en haut de la portée (celles des fonctions d’abord, puis celles des variables). Attention, on parle bien des déclarations, pas des initialisations. Les variables, donc, sont déclarées mais undefined jusqu’à l’exécution de leur ligne d’initialisation.

Ainsi, le code précédent est en fait perçu comme celui-ci :

// La fonction est hoistée en premier, avant la variable de même portée.
function foo(b) {
// Hoisting des deux déclarations `var`, mais pas de leur initialisation
var c, i

if (b > 1) {
c = b * 10
}

for (i = 0; i < 10; ++i) {

}
}

var a
a = 1

Et du coup, pour l’interprétation du code d’origine, on a les aspects surprenants suivants :

// En fait dès cette ligne on voit a (undefined) et foo (définie)

var a = 1

function foo(b) {
// Ici on voit a, foo et b, mais aussi c et i
// (tous deux undefined)

if (b > 1) {
var c = b * 10

}

for (var i = 0; i < 10; ++i) {

}
}

Une fausse bonne idée ?

Le hoisting des déclarations de fonctions est plutôt une bonne idée, en nous permettant de commencer notre code par l’algo principal avant de charger le cerveau de la personne qui lit le code avec le détail des fonctions locales. Le déroulé « du principal vers les détails » peut ainsi rester « de haut en bas », ce qui est une bonne chose :

function processItems(items) {
return items.filter(itemMatches).map(transformItem)

function itemMatches(item) {}

function transformItem(item) {}
}

En revanche, le hoisting des déclarations var, qui entraîne la possibilité de référencer ces variables avant leur ligne déclarative (mais elles y seront undefined), n’a pas seulement aucun intérêt, c’est carrément contre-productif. Ça vient d’une optimisation prématurée de l’allocation des registres dans les toutes premières moutures du moteur JS de Netscape, et ça n’a plus aucune raison d’être depuis plus de 20 ans.

C’est la raison derrière le fameux problème des boucles numériques sur traitement asynchrone :

const people = ['Alice', 'Bob', 'Claire', 'David']
for (var i = 0, len = people.length; i < len; ++i) {
setTimeout(() => console.log(people[i]), 0)
}

// Affiche 4 × undefined au lieu des 4 prénoms.
// En effet, au moment où les callbacks de timer s'exécutent,
// le `i` qu'ils voient est celui existant hors de la boucle,
// qui à ce stade vaudra `len`, donc 4, un index non renseigné
// dans le tableau.

La manière de régler ça, avant ES2015, consistait à utiliser une IIFE, ce qui est garanti imbitable pour la plupart des devs JS :

const people = ['Alice', 'Bob', 'Claire', 'David']
for (var i = 0, len = people.length; i < len; ++i) {
;(function (i) {
setTimeout(() => console.log(people[i]), 0)
})(i)
}

Non mais sérieux… On verra qu’avec ES2015, ça se règle beaucoup plus simplement, en changeant simplement le mot-clé déclaratif.

Attention aux expressions de fonctions !

Remarque aussi que JS ne hoiste pas les expressions de fonctions, c’est-à-dire les endroits où le mot-clé function apparaît à un endroit du code n’autorisant que les expressions et pas les instructions (on ne pourrait pas y mettre un if, par exemple). Ainsi, le code suivant ne marcherait pas :

foo() // => undefined is not a function

var foo = function () {
console.log('foo')
}

En effet, ici c’est la déclaration var qui est hoistée, sans son initialisation. Alors qu’avec une déclaration de fonction, ça marche :

foo() // => logue 'foo'

function foo() {
console.log('foo')
}

À propos, les fonctions fléchées étant des expressions, elles ne sont pas hoistées par leur nature-même.

Un mot sur les fermetures lexicales (closures)

JS a également un aspect très particulier (et extrêmement pratique) de gestion des portées : les fermetures lexicales. Ça vient de la nature éminemment asynchrone et événementielle des codes JavaScript, où du code va souvent être exécuté bien après que la fonction englobante (qui a planifié ce code) a fini de s’exécuter. Regarde ce code :

function logOnClicks(message) {
const scheduledAt = new Date()
const announcement = `Planification à ${scheduledAt}`
console.log(announcement)

document.addEventListener('click', function () {
console.log(message, '(planifié à ', scheduledAt, ')')
})
}

logOnClicks('Super Michel !')

Quand la fonction logOnClicks s’exécute, elle définit message, scheduledAt et announcement, planifie un gestionnaire d’événements clic, puis se termine. Normalement, après ça elle nettoierait sa portée (les données de celle-ci seraient libérées en mémoire). Mais si JS faisait ça, lorsqu’un événement clic va se déclencher, comment la fonction gestionnaire (la fonction de rappel passée à addEventListener) pourrait-elle utiliser message et scheduledAt ?

Du coup JS analyse, pour chaque fonction qui persiste au-delà sa fonction planifiante (donc les fonctions de rappel passées à des API asynchrones / événementielles) quelles références elle fait aux portées englobantes. Ici, le gestionnaire référence message et scheduledAt, mais pas announcement. Lorsque la fonction logOnClicks se termine, JS va donc nettoyer announcement de la mémoire, mais préserver message et scheduledAt le temps nécessaire (jusqu’à ce que plus aucune fonction les référençant ne persiste en mémoire, par exemple ici une fois le gestionnaire d’événements désinscrit).

Cette persistance sélective d’éléments de portée au-delà de l’exécution de leur fonction, c’est ce qu’on appelle les fermetures lexicales. Voilà, comme ça tu sais.

Qu’apporte ES2015 ?

Cette version ne peut évidemment pas changer les règles existantes (pour ne pas Casser Internet™), mais elle introduit trois nouveaux mots-clés déclaratifs dont le comportement est différent : let, const et class.

Ceux-ci se distinguent des précédents par deux aspects :

  1. Leur portée se limite au bloc courant. Pour des déclarations au sein d’une des boucles for (for numérique, for…in ou for…of), il s’agit même individuellement de chaque tour de boucle. Ça marche même pour les « blocs implicites » (en l’absence des accolades, lorsque la structure de contrôle ne pilote qu’une instruction).
  2. Ils ne sont pas hoistés au sein de leur portée : concrètement, ils ne sont exploitables qu’à partir de leur emplacement de déclaration dans le code source. (Pour la petite histoire, ils sont en fait définis mais non-référençables entre le début du bloc et l’exécution de leur ligne déclarative ; on dit qu’ils sont dans la Temporal Dead Zone, ou TDZ, un terme qui a carrément de la gueule !)

Le souci de la boucle sur traitement asynchrone se résout d’un simple changement de mot-clé déclaratif pour l’index :

const people = ['Alice', 'Bob', 'Claire', 'David']
for (let i = 0, len = people.length; i < len; ++i) {
setTimeout(() => console.log(people[i]), 0)
}

Les mots-clés var et let remplissent donc la même fonction : déclarer un identifiant potentiellement réaffectable. Sauf que var a une portée contre-intuitive (le corps de fonction, pas le bloc) et est hoisté par-dessus le marché (ce qui autorise des trucs pas nets, comme le fait de référencer l’identifiant « avant » de l’avoir déclaré).

On considère donc que, dans du code ES2015+, le mot-clé var est à bannir absolument. Mais pas forcément au profit de let, comme on pourrait le penser de prime abord…

const is the new var

Historiquement, en JS, nous n’avions pas de mot-clé pour les constantes. On les déclarait donc avec var et une convention de nommage en majuscules, du genre MAX_ITEMS. Il s’agissait généralement de « constantes absolues », invariantes durant tout le programme. Des valeurs seuils, des limites, etc.

Pourtant, dans nos programmes, l’écrasante majorité des déclarations de soi-disant variables que nous faisons… ne varient pas. Pour être plus précis, nous ne réaffectons pas leur identifiant dans sa portée. On se sert des variables comme de simples étiquettes sur des calculs ou appels de fonction qu’on ne souhaite pas inutilement refaire.

Le souci c’est qu’en les déclarant avec let (sans parler de var), on rend leur réaffectation syntaxiquement légale. Et en ces temps de complétion automatique omniprésente, il n’est pas rare qu’on réaffecte par mégarde un identifiant similaire à celui qu’on visait, mais qui n’est pas le bon.

Nos biais cognitifs nous empêchent alors de repérer, en relisant le code, qu’on n’est pas sur le bon identifiant. Ça donne des bugs assez pénibles à débusquer.

L’idée est donc plutôt de déclarer nos « variables » en const par défaut, et de ne réserver let qu’à des cas justifiés en triple exemplaire signés avec notre sang. Ainsi, toute réaffectation par mégarde entraînera une erreur syntaxique (et en amont, des avertissements du linter), ce qui est largement préférable.

Il y a bien sûr des cas légitimes pour un let, notamment :

  • Les initialisations asynchrones, lorsqu’on a besoin de déclarer la variable en amont du code qui pourra l’initialiser (par exemple suite à un appel réseau ou d’API locale asynchrone, telle qu’IndexedDB).
  • Les évolutions itératives de la référence, par exemple lorsqu’on utilise certains outils de construction incrémentale de requêtes (query builders), qui affinent petit à petit un objet de requêtage (à coup de passes du genre scope = scope.where(…)).
  • Les algorithmes de bas niveau, comme une implémentation de hash cryptographique, qui font évoluer sans cesse un état interne.

Mais ces cas constituent une infime minorité de nos déclarations. Recourir à const par défaut (approche appelée const is the new var) nous prémunit contre la majorité des risques, même si ça ne protège pas, par exemple, les paramètres déclarés dans la signature d’une fonction, qui restent implicitement réaffectables.

Garde aussi à l’esprit que const ne veut pas dire immuable : si on parle d’une valeur primitive (String, Number, etc.), pas de souci. Mais si on parle d’un objet (objets littéraux, tableaux, etc.) alors c’est simplement la référence qui est constante : l’intérieur de l’objet peut parfaitement bouger, lui, et le « geler » nécessiterait des mesures supplémentaires.

On parle aussi de tout ça, avec des infos sur la configuration associée d’ESLint, dans cet article sur notre site.

En résumé…

  • Déclarer une fonction locale avec function reste pertinent et utile, en permettant de profiter du hoisting pour la déclarer « plus bas » dans le code que l’algorithme qui s’en sert dans la même portée, pour dérouler de haut en bas du général aux détails.
  • var est à proscrire depuis ES2015 (let fait la même chose, sans le hoisting de la déclaration, qui est contre-productif).
  • const devrait être le mot-clé déclaratif par défaut pour les données, calculs, etc. On réserve let aux quelques cas, très minoritaires, qui le justifient.

Ça t’a plu ? Ne manque pas la suite !

Il y a aussi encore tout plein de sujets merveilleux à venir dans cette série JS idiomatique.

Pour être sûr·e de ne rater aucun de nos tutos et articles, le mieux est encore de t’abonner à notre newsletter et à notre chaîne YouTube. Tu peux aussi nous suivre sur Twitter.

Et bien entendu, n’hésite pas à jeter un œil à nos formations ! Si explorer l’intégralité des recoins du langage t’intéresse, la ES Total notamment est faite pour toi !