La portée des variables en Javascript

La portée (i.e. scope) d’un objet est la portion de code dans laquelle une variable peut exister et maintenir une valeur qui lui aura été préalablement affectée. La portée des objets est loin d’être un sujet trivial en Javascript car suivant la façon dont on déclare les objets, beaucoup de règles ou de comportements du langage ne sont pas forcément intuitifs et surtout sont opposés à ceux d’autres langages plus standards comme le C++, C#, Java etc…

@martinols3n

Le but de cet article est d’essayer d’indiquer tous les cas atypiques de comportements de la gestion de la portée des objets en Javascript pouvant mener à des erreurs d’implémentations.
Dans un premier temps, on va indiquer les règles générales liées à la portée suivant l’utilisation de var, let ou const. Ensuite, on montrera quelle syntaxe permet de modifier les règles liées à la gestion de la portée par le moteur Javascript. Dans un 3e temps, on développera les différents comportements du mot-clé this. Enfin, on indiquera quelles sont les règles liées à l’hoisting.

Pour déclarer des variables en Javascript de façon à éviter de déclarer de façon globale, on utilise les mots-clé var ou let (ou const dans le cas d’une variable dont la valeur est constante). Suivant le mot-clé utilisé, le comportement du moteur Javascript sera très différent. Avec let et const, les comportements sont assez standards pour rapport à d’autres langages. En revanche, avec var, les comportements sont spécifiques et peuvent surprendre en particulier si on est habitué à d’autres langages.
On pourrait se demander quelle est la justification qui nécessite de s’intéresser à var, il suffirait d’utiliser uniquement let. La raison est que let est apparu avec ES6 (ES2015) et que beaucoup d’applications fonctionnent avec un code compatible ES5 utilisant var.

Variables globales

Un des plus gros inconvénient de Javascript est de permettre de déclarer des variables globales. La portrée de ces variables étant celui de l’application, elles garderont leur valeur dans toute l’application. Ainsi une même variable peut être valuée à des endroits différents du code et des conflits peuvent apparaître entre ces valuations dans le cas où elles sont incompatibles ce qui peut mener à des erreurs. D’une façon générale, il faut éviter d’utiliser des variables globales.

Les variables globales se déclarent de cette façon:

<nom variable> = <valeur>;

ou

window.<nom variable> = <valeur>;

Par exemple:

variable = 'OK';

ou

window.variable = 'OK';

Portée statique vs portée dynamique

Quand Kyle Simpson présente la portée des objets en Javascript dans You don’t know JS, il explique le comportement de Javascript en passant par la portée statique (i.e. lexical scope).
La portée statique ou portée lexicale (i.e. static scope ou lexical scope) s’oppose à la portée dynamique (i.e. dynamic scope). Il s’agit d’un ensemble de règles utilisé par le moteur d’exécution d’un langage pour identifier une variable nommée et pour en déterminer la valeur. La portée statique est le comportement utilisé par la plupart des langages y compris C++, C# ou Java. Javascript utilise aussi cet ensemble de règles.

Dans la gestion de la portée des variables, tous ces langages ont des comportements similaires:

  • Les portées s’imbriquent: une portée peut se trouver à l’intérieur d’un autre portée. Les variables définies dans la portée parente sont accessibles dans la portée enfant.
  • La portée la plus générale est la portée globale: dans le cas de Javascript, une variable globale est accessible partout.
  • La plupart du temps, une portée ne peut pas appartenir à plus d’une seule portée parente.

En Javascript, les règles qu’imposent la portée statique sont les suivantes:

  • Règle 1: lorsque le moteur d’exécution exécute la déclaration d’une variable, la portée de la variable sera celle dans laquelle se trouve la déclaration.
  • Règle 2: lorsque le moteur d’exécution cherche la valeur d’une variable, il recherche sa déclaration dans la portée courante. Si aucune déclaration n’est trouvée, le moteur cherche dans les portées parentes. Si aucune déclaration n’est trouvée alors la variable appartient à la portée globale.
  • Règle 3: si une variable est déclarée dans une portée parente et qu’une variable est déclarée avec le même nom dans une portée enfant, la variable dans la portée parente sera occultée par celle de la portée enfant. Ce comportement correspond au shadowing.

Pour se rendre compte de la différence entre la portée statique et la portée dynamique, on peut prendre l’exemple suivant:

var x = 1;

function a() {
  console.log('x in a(): ',x); // x => 1
  x = 2;
}

function b() {
  var x = 3; // x => 3
  a();
  console.log('x in b(): ',x); // x => 3
}

b();
console.log(x); // x => 2

Suivant la portée utilisée, le comportement est le suivant:

Portée statique (i.e. lexical scope)

Portée dynamique (i.e. dynamic scope)

La portée dynamique utilise des piles durant l’exécution pour garder les valeurs des variables de façon dynamique.
Javascript a le comportement correspondant à la portée statique, il n’utilise donc pas la portée dynamique..
  1. Dans le corps principal, var x = 1 permet d’affecter la valeur 1 à la variable x (qu’on peut appeler x1).
  2. La méthode b() est appelée:
    1. Dans le corps de la méthode b(), var x = 3 permet d’affecter 3 à une nouvelle variable nommée x (qu’on peut appeler x2). La première variable x (i.e. x1) déclarée dans le corps principal est occultée par shadowing.
    2. On appelle la méthode a(), dans le corps de la méthode b():
      1. On affiche la valeur de x, pour savoir à quoi correspond cette variable:
        • Dans la portée de la méthode a(), y’a-t-il une déclaration d’une variable x dans la portée correspondant au corps de la méthode a() ? ⇒ non donc on cherche dans la portée parente de la méthode.
        • Dans le corps principal, y’a-t-il une déclaration d’une variable nommée x ? ⇒ oui avec var x = 1 (il s’agit de x1).
        • On peut en déduire la valeur de x qui est 1.
      2. On affecte une nouvelle valeur à la variable x. Par le même principe que précédemment, la variable x (i.e. x1) correspond à la variable du corps principal. La nouvelle valeur de cette variable est donc 2.
    3. Dans le corps de b(), on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable: y’a-t-il une déclaration d’une variable x dans la portée correspondant au corps de b() ? ⇒ oui à cause de var x = 3. La valeur de cette variable est donc 3 (il s’agit de x2).
  3. Dans le corps principal, on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable x: y’a-t-il une déclaration d’une variable x dans la portée du corps principal ? ⇒ oui à cause de var x = 1 (il s’agit de x1). Toutefois la valeur de cette variable a été modifiée lors de l’appel à la méthode a(). La valeur de x est 2.
  1. Dans le corps principal, var x = 1 permet d’ajouter à la pile la valeur 1 pour la variable x (qu’on peut appeler x1).
  2. La méthode b() est appelée:
    1. Dans le corps de la méthode b(), var x = 3 ajoute à la pile la valeur 3 pour une nouvelle variable x (qu’on peut appeler x2).
    2. On appelle la méthode a(), dans le corps de la méthode b():
      • On affiche la valeur de x. En regardant au sommet de la pile, on cherche une variable nommée x (il s’agit de x2) donc la valeur affichée est 3.
      • On affecte une nouvelle valeur à la variable x ⇒ en cherchant au sommet de la pile on trouve une variable nommée x (i.e. x2) donc la valeur est 3. On modifie cette valeur pour affecter 2.
    3. Dans le corps de b(), on affiche la valeur de la variable x. Pour savoir à quoi correspond cette variable, on cherche au sommet de la pile une variable nommée x ⇒ la variable trouvée est x2, la valeur affichée est donc 2. Quand on sort de la méthode b(), la variable nommée x au sommet de la pile est supprimée (x2 est donc supprimée de la pile).
  3. Dans le corps principal, on affiche la valeur d’une variable nommée x. Pour savoir à quoi correspond cette variable x: on regarde au sommet de la pile, la variable nommée x correspond à x1. La valeur de cette variable n’a jamais été modifiée, sa valeur est 1.

Les 2 comportements sont donc différents et impliquent des valeurs différentes suivants les portées dans lesquelles les affectations et les récupérations de valeurs sont faites.

var, let et const

Les mots-clé var, let et const permettent de déclarer une variable en limitant sa portée: suivant le mot-clé utilisé, la portée de la variable sera très différente:

  • var permet de déclarer une variable dans la portée globale ou dans la portée d’une fonction.
  • const et let permettent de déclarer une variable dans la portée d’un bloc de code.

Déclarer une variable avec ces mots-clé est possible avec la syntaxe suivante:

var <nom variable>;
let <nom variable>;

Avec const, une initialisation est obligatoire.

Pour initialiser une variable en même temps que sa déclaration, la syntaxe est:

var <nom variable> = <valeur d'initialisation>;
let <nom variable> = <valeur d'initialisation>;
const <nom variable> = <valeur d'initialisation>;

Portée avec let et const

La gestion de la portée est similaire entre let et const. La différence entre ces mots-clé est que:

  • let autorise des affectations d’une nouvelle valeur à une variable.
  • const interdit de nouvelles affectations.

Si on exécute:

const a = 3;
a = 5; // SyntaxError

On obtient une erreur de type 'SyntaxError: redeclaration of const a'.

let et const sont apparus à partir de ES6 et ont pour but d’avoir un comportement similaire à la plupart des autres langages: ils limitent la portée d’une variable à un bloc de code. Un bloc de code étant la portion de code délimitée par { ... }, par exemple:

  • Une fonction:
    function add(a, b) {
      // ...
    }
    
  • Une clause if...else, for etc:
    for (let i = 0; i < 10; i++) {
      // ...
    }
    
  • Le contenu des blocs dans un try...catch:
    try {
      // ...
    }
    catch (err) {
      // err est limité au bloc catch
    }
    
  • Une bloc simple:
    {
      // ...
    }
    
  • Une bloc nommé:
    namedBlock: {
      // ...
    }
    
  • Un objet:
    var obj = {
      // ...
    };
    

Ainsi avec let ou const, la portée d’une variable est limitée au bloc de code dans laquelle elle est définie et dans les blocs enfant.

Le comportement est similaire à la plupart des autres langages, une variable déclarée dans un bloc de code ne sera pas accessible en dehors de ce bloc et de ses blocs enfant (sauf en cas de shadowing).

Par exemple:

const a = 'OK';
if (a === 'OK') {
  let b = 5;
  console.log('Inside if: a=',a,' ;b=',b); // 'Inside if: a=OK ;b=5'
}

console.log('Outside if: a=',a); // le résultat est 'Outside if: a=OK'
console.log('Outside if: b=',b): // Reference Error

Dans le code plus haut, on peut voir 2 blocs de code: celui en dehors de la clause if et celui en dehors. Ainsi:

  • La variable a a été définie dans le bloc en dehors de la clause if, elle est donc disponible dans les 2 blocs.
  • La variable b a été définie dans le bloc du if, elle n’est accessible que dans ce bloc.

Portée avec var

Contrairement à let et const, la portée des objets avec var correspond aux fonctions. Les blocs de code ne limitent pas les portées quand on déclare une variable avec var.

Par exemple, si on considère le code suivant:

var a = 'OK';
if (a === 'OK') {
  var b = 5;
  console.log('Inside if: a=',a,' ;b=',b);
}

console.log('Outside if: a=',a,' ;b=',b);

La portée est la même pour les variables a et b même si b est déclarée dans le corps de la clause if. A l’intérieur ou à l’extérieur de la clause if, le résultat est le même:

Inside if: a=OK ;b=5
Outside if: a=OK ;b=5
Fonction, fonction anonyme, arrow function et IIFE

La notion de portée n’est pas modifiée suivant le type de fonction. Dans le cas de var, la portée des variables correspond aux fonctions.

Si on considère le code suivant:

var x = 3;
function namedFunction() {
  var x = 2;
  console.log(x); // 2
}

namedFunction();

console.log(x); // 3

La règle de la portée statique s’applique dans le cadre de cet exemple.

  • Fonction anonyme (i.e. function expression): le comportement est le même pour une fonction anonyme:
    var x = 3;
    var anonymousFunction = function() {
      var x = 2;
      console.log(x); // 2
    }
    
    anonymousFunction();
    
    console.log(x); // 3
    
  • IIFE (i.e. Immediatly Invoked Function Expression) qui sont exécutées au même moment que leur déclaration:
    var x = 3;
    (function() {
      var x = 2;
      console.log(x); // 2
    })();
    
    console.log(x); // 3
    
  • Arrow function: à partir de ES6:
    var x = 3;
    var arrowFunction = () => {
      var x = 2;
      console.log(x); // 2
    })();
    
    arrowFunction();
    console.log(x); // 3
    

Comment modifier le comportement de la portée statique ?

Il est possible de modifier le comportement de la portée statique en utilisant eval() ou le constructeur new Function().

eval()

eval() permet d’évaluer une expression sous forme de chaîne de caractères.

Par exemple:

let result = eval('1 + 3');
console.log(result); // 4

Ainsi eval() exécute l’expression sous forme de chaîne de caractères: '1 + 3'.

La portée d’une variable utilisée dans une expression évaluée par eval() dépend de la portée dans laquelle l’expression est évaluée et non en fonction de la portée dans laquelle la chaîne évaluée est construite.

Par exemple, si on exécute:

function a(expr) {
  eval(expr);
  console.log('x=',x); // 4
}

var x = 3;
let expr = 'var x = 4';
a(expr);

console.log('x=',x); // 3

La portée utilisée pour évaluer x avec eval() est celui de la fonction a(). Même si l’expression expr a été construite à l’extérieure de a(), c’est la portée dans laquelle eval() est exécuté qui sera utilisé.
Dans cet exemple, quand expr est évaluée dans le corps de la méthode a(), var x = 4 occulte la première déclaration de x pour définir une 2e variable nommée x dont la valeur est 4. À l’extérieur de a(), la variable x correspond à la variable déclarée avec var x = 3.

Comportement de eval() avec le mode strict

Si on utilise le mode strict, l’expression évaluée dans eval() possède une portée spécifique qui ne déborde pas de la portée dans laquelle eval() est exécutée.

Par exemple si on exécute le code suivant:

function a(expr) {
  "use strict"; 
  eval(expr); // Inside eval: 4
  console.log('x=',x); // 3
}

var x = 3;
let expr = "var x = 4; console.log('Inside eval: ',x)";
a(expr);

console.log('x=',x); // 3

Toutes les évaluation de x en dehors de eval() possède la valeur 3 car la variable utilisée est celle déclarée par var x = 3. Toutefois avec le mode strict, la portée de l’expression évaluée par eval() est limité, ainsi x possède la valeur 4 seulement dans l’expression évaluée.

Comportement de eval() avec const et let

Si on utilise const ou let pour déclarer une variable dans une expression évaluée par eval(), la portée est spécifique à l’expression évaluée, il ne déborde pas dans la portée dans laquelle eval() est exécutée.

Par exemple, si on exécute le code suivant:

function a(expr) {
  eval(expr); // Inside eval: 4
  console.log('x=',x); // 3
}

let x = 3;
let expr = "let x = 4; console.log('Inside eval: ',x)";
a(expr);

console.log('x=',x); // 3

Toutes les évaluation de x en dehors de eval() possède la valeur 3 car la variable utilisée est celle déclarée par var x = 3. La portée de l’expression évaluée par eval() est limitée, x possède la valeur 4 seulement dans l’expression évaluée.

new Function()

La constructeur Function() permet de déclarer un objet de type Function. Utiliser ce constructeur permet d’évaluer une expression de la même façon qu’avec eval(). La syntaxe à utiliser est:

var objectFunction = new Function([arg1, arg2, ..., argN], expression);

Dans cet appel, expression contient la chaîne de caractères à évaluer.

On peut exécuter la fonction de cette façon:

objectFunction(arg1, arg1, ..., argN, '....');

Du point de vue de la portée, cet objet ne se comporte ni comme une fonction normale, ni comme eval(): la portée parente d’un objet de type Function est la portée globale et non la portée de la fonction parente.

Par exemple dans le cadre général, si une fonction est définie dans une autre fonction dite parente, la portée parente de la fonction est la portée de la fonction parente. C’est la règle de la portée statique.

Ainsi, si on exécute le code suivant:

function parent() {
  function a() {
    x = 2;
    console.log('x=',x); // x=2
  }

  var x = 3;
  a();

  console.log('x=',x,'window.x=',window.x); // x=2 window.x=undefined
}

parent();

La règle de la portée statique s’applique pour déterminer qu’elle est la déclaration d’une variable:

  • Dans le corps de la fonction a(): x désigne la variable déclarée avec var x = 3 donc partout x désigne la même variable.
  • Dans le corps de parent(): window.x est indéfinie puisqu’on a jamais affecté de valeur à la variable globale x.

Si on considère un code semblable utilisant un objet de type Function:

function parent() {
  var a = new Function("x = 2; console.log('x=',x);");
  var x = 3;

  a(); // x= 2

  console.log('x=',x,'window.x=',window.x); // x=3 window.x=2
}

parent();

La portée parente de l’objet Function n’est pas la fonction parent() mais la portée globale, ainsi:

  • Quand l’expression est évaluée x = 2 fera référence à x dans la portée parente c’est-à-dire la portée globale. C’est la raison pour laquelle window.x renvoie la valeur 2.
  • L’expression évaluée dans le constructeur de Function() modifie x en tant que variable globale donc la variable déclarée par var x = 3 n’est pas modifiée et console.log('x=',x) dans le corps de parent() renvoie 3.

De même, la portée parente de eval() correpond, par défaut, à la portée dans laquelle eval() est exécutée. Ce qui est contraire au comportement de l’objet Function puisque sa portée parente est la portée globale.

this

this est un mot-clé très courant dans beaucoup de langage pour désigner l’instance courante d’une classe.

En Javascript, c’est aussi le sens de ce mot-clé dans le cadre des classes introduites à partir de ES6.

Par exemple:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  logPerson() {
    console.log(this.firstName, this.lastName);
  }
}

var person = new Person('Uncle', 'Bob');
person.logPerson(); // Uncle Bob

Dans cet exemple, this renvoie bien à l’instance courante de la classe Person.

En Javascript, le plus souvent this est utilisé dans un cadre différent des classes. Au lieu de représenter l’instance d’une classe, il désigne un contexte dans lequel le code est exécuté. Suivant les cas d’usage de this, sa valeur peut être plus difficile à prévoir que dans le cas d’une classe.

Parmi les cas les plus courants d’utilisation de this, il y a celui du corps d’une fonction (cette fonction n’appartient pas à une classe ou à un objet). Dans le cas d’une fonction:

  • En mode non strict: this désigne le contexte global.
  • En mode strict: this est indéfini sauf si on indique explicitement sa valeur.

Par exemple:

function a() {
  return this;
}

console.log(a() === window); // true

Dans ce cas, this désigne le contexte global.

Dans la cas du mode strict, this est indéfini car sa valeur n’a pas été indiquée explicitement:

function a() {
  "use strict";
  return this;
}

console.log(a()); // undefined

Dans le cas d’un objet, this dans une fonction désigne implicitement cet objet:

var obj = {
  x: 5,
  a: function() {
    return this;
  }
};

console.log(obj.a().x === 5); // true

Dans ce cas, this désigne l’objet obj.

Arrow function

D’une façon générale, les arrow functions ont les mêmes comportements que les fonctions normales c’est-à-dire que this désigne le contexte global ou l’objet englobant.

Par exemple, si on considère l’exemple suivant:

var arrowFunction = () => this; // Référence d'une arrow function

console.log(arrowFunction() === window); // true

Dans le cas de l’exemple précédent, on exécute dans le contexte global donc this dans l’arrow function désigne le contexte global.

Dans le cas d’une arrow function déclarée dans un objet, il faut distinguer l’appel à une fonction et le cas d’une référence vers une fonction.

Par exemple, si on place cette fonction dans un objet:

var obj = {
  x: 5,
  a: function() {
    var arrowFunction = () => this;
    return arrowFunction; // On retourne la référence de la fonction
  }
};

var fn = obj.a(); // On récupère la référence de l'arrow function dans le contexte de l'objet obj.
console.log(fn().x === 5); // True
console.log(obj.a().x === 5); // False
console.log(obj.a()().x === 5); // True

Ainsi:

  • console.log(fn().x === 5) retourne true car fn est une référence vers l’arrow function dans le contexte de l’objet obj. Quand on exécuté fn(), on exécute l’arrow function et this désigne l’objet obj. Par suite obj.x contient la valeur 5.
  • console.log(obj.a().x === 5) retourne false car obj.a() contient la référence de l’arrow function, il faut exécuter obj.a()() pour exécuter réellement l’arrow function.

Comment affecter une valeur à this ?

On peut affecter une valeur à this avec les fonctions call(), apply() ou bind().

call() et apply()

call() et apply() permettent de contrôler la valeur de this en indiquant quel est le contexte d’exécution d’une fonction.

La syntaxe de ces méthodes est:

  • call():
    <fonction>.call(thisVar, [arg1, arg2,..., argN]);
    

    Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [arg1, arg2,..., ArgN], les arguments de la fonction à exécuter.
    En mode non strict, si thisVar est nul, alors this contient le contexte global.

  • apply():
    <fonction>.apply(thisVar, [tableau d'arguments]);
    

    Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [tableau d'arguments], les arguments de la fonction à exécuter sous forme d’un tableau.
    En mode non strict, si thisVar est nul, alors this contient le contexte global.

Si on considère l’exemple suivant:

function a(z, w) {
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

console.log(a.call(null, 3, 4)); // 10
console.log(a.apply(null, [3, 4])); // 10

Les 2 appels précédents permettent d’exécuter a() dans le contexte global puisque la valeur indiquée de this est nulle. Dans le contexte global, x et y contiennent respectivement les valeurs 1 et 2.

Si on exécute:

console.log(a.call(obj, 3, 4)); // 18
console.log(a.apply(obj, [3, 4])); // 18

this contient obj donc a() est exécuté dans le contexte de obj. x et y contiennent respectivement les valeurs 5 et 6.

Si on modifie le code pour activer le mode strict:

function a(z, w) {
  "use strict";
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

console.log(a.call(null, 3, 4)); // TypeError: this is null
console.log(a.apply(null, [3, 4])); // TypeError: this is null

Si le mode strict est activé et que this contient null, sa valeur n’est pas remplacée par le contexte global. a() ne peut pas effectuer l’addition car this est nul.

bind()

A partir de ES5, bind() permet aussi de préciser une valeur de this en générant une référence vers une nouvelle fonction. Cette nouvelle fonction sera liée au contexte indiqué lors de l’exécution de bind().
La syntaxe de bind() est:

let <référence fonction> = <fonction>.bind(thisVar, [arg1, arg2,..., argN]);

Avec <fonction> la fonction à exécuter; thisVar, la valeur de this et [arg1, arg2,..., ArgN], les arguments de la fonction à exécuter.

Avec bind(), le cas d’usage est un peu différent de call() et apply(). Si on reprend l’exemple précédent:

function a(z, w) {
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

var boundA = a.bind(null, 3, 4); // On définit une nouvelle référence sans indiquer de valeur pour this
console.log(boundA()); // 10

Si on ne précise pas de valeur pour this, c’est le contexte global qui est utilisé. Dans cet exemple, dans le corps de a(), this fait référence au contexte global, x et y ont pour valeur, respectivement, 1 et 2.

Si on indique une autre valeur pour this:

var boundA = a.bind(obj, 3, 4);
console.log(boundA()); // 18

Dans cet appel, this fait référence à l’objet obj donc x et y ont pour valeur, respectivement 5 et 6.

Enfin dans le cas du mode strict, si on ne précise pas de valeur pour this, sa valeur restera nulle. Si on reprend l’exemple précédent:

function a(z, w) {
  "use strict";
  return this.x + this.y + z +w;
}

var obj = {
  x: 5,
  y: 6
};

x = 1;
y = 2;

var boundA = a.bind(null, 3, 4);
console.log(boundA()); // TypeError: this is null

Avec le mode strict, this est nul dans le corps de la fonction a() d’où l’erreur.

setTimeout()

Avec setTimeout(), la valeur par défaut de this est le contexte global. Ce comportement peut prêter à confusion quand on utilise this dans une fonction exécutée par setTimeout().

Par exemple:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x);
  }
};

var x = 3;

obj.a(); // 5

Dans ce dernier appel, this fait référence à l’objet obj puisque a() se trouve dans cet objet.

En revanche si on exécute le code suivant, this fait référence au contexte global:

setTimeout(obj.a, 100); //3

C’est la 3 qui s’affiche car c’est la variable x déclarée dans le contexte global qui est utilisée.

La solution peut consister à utiliser une arrow function (disponible à partir de ES6):

setTimeout(() => obj.a(), 100); // 5

Cet appel permet de forcer l’appel dans le contexte de l’objet obj.

On peut aussi utiliser bind() (à partir de ES5) pour indiquer explicitement la valeur de this:

var boundA = obj.a.bind(obj);
setTimeout(boundA, 100); // 5

Enfin, on peut utiliser une variable locale et encapsuler l’appel à setTimeout dans la fonction déclarée dans l’objet obj:

var obj = {
  x: 5,
  a: function() {
    var self = this;
    setTimeout(function() {
      console.log(self.x);
    }, 100);
  }
};

var x = 3;

obj.a(); // 5

Closure

Dans le cadre des closures (i.e. fermetures), le comportement de this peut prêter à confusion car Javascript perd la portée de this quand il est utilisé dans une fonction qui se trouve dans une autre fonction.

Qu’est ce qu’une closure ?

Avant de rentrer dans les détails de this dans le cadre d’une closure, on peut rappeler la définition d’une closure (les closures ne sont pas spécifiques à Javascript):

D’après MDN web docs:

“Une fermeture correspond à la combinaison entre une fonction et l’environnement lexical au sein duquel elle a été déclarée. En d’autres termes, une fermeture donne accès à la portée d’une fonction externe à partir d’une fonction interne. En Javascript, une fermeture est créée chaque fois qu’une fonction est créée.

L’exemple d’une closure qui peut surprendre est celui intervenant dans le cadre d’une boucle:

for (var i = 1; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

A l’exécution, on s’attendrait à voir s’afficher 1, 2, 3, etc… toutefois on voit 5 fois s’afficher 6. Ce comportement s’explique par le fait que la fonction anonyme se trouvant dans setTimeout() utilise la portée de la fonction externe, or dans la portée externe i varie de 1 à 6. Étant donné que setTimeout() retarde l’exécution de la fonction anonyme pendant 100 ms, les valeurs affichées correspondent à la dernier valeur de i.

Pour empêcher ce comportement, il faudrait que la fonction anonyme utilise une variable dont la portée ne dépend pas de la fonction externe. Par exemple, si on utilise une IIFE exécutée à chaque boucle et si on utilise une variable locale à l’IIFE, on pourra utiliser une variable dont la valeur est spécifique à chaque boucle:

for (var i = 1; i < 5; i++) {
  // IIFE exécutée à chaque boucle
  (function() {
    var j = i; // Variable locale
    setTimeout(function() {
      console.log(j); // On utilise la variable locale
    }, 100);
  })();
}

A l’exécution, on affiche les différentes valeurs: 1, 2, 3, etc…

Conséquences avec this

Comme on l’a indiqué plus haut:

En Javascript, une fermeture est créée chaque fois qu’une fonction est créée“.

Ainsi dans le cadre de l’exemple suivant, la closure (i.e. fermeture) entraîne le comportement de la portée statique: pour connaître la valeur d’une variable, le moteur vérifie la portée courante puis les portées parentes jusqu’à ce qu’il trouve la déclaration.

Par exemple:

function a() {
  var x = 5;
  var nestedA = function() {
    console.log(x); // 5
  }
  
  nestedA();
}

a();

La valeur affichée sera 5 dans le corps de nestedA() car cette fonction a accès à la portée de la fonction externe c’est-à-dire celui de a(). x étant déclarée dans le corps de a() alors la valeur affichée sera 5.

On pourrait s’attendre à ce que le comportement de this soit identique, par exemple:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    var nestedA = function() {
      console.log(this.x); // undefined
    };
    nestedA();
  }
};

obj.a();

On pourrait s’attendre à ce que this.x dans nestedA() contienne la valeur 5. Ce n’est pas le cas car la portée de this est perdue quand une fonction se trouve dans une autre fonction. Dans nestedA(), this ne correspond pas à l’objet obj (comme c’est la cas dans le corps de a()) mais à la portée globale. Ainsi, this.x dans nestedA() est indéfinie.

Le comportement est le même si on utilise une IIFE:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    (function() {
      console.log(this.x); // undefined
    })();
  }
};

obj.a();

Par contre avec les arrow functions implémentées à partir de ES6, le comportement est plus standard:

var obj = {
  x: 5,
  a: function() {
    console.log(this.x); // 5

    var nestedA = () => {
      console.log(this.x); // 5
    };
    nestedA();
  }
};

obj.a();

Dans le cadre des arrow functions, this correspond bien à l’objet obj.

Hoisting avec var

L’utilisation de var pour déclarer des variables entraîne un comportement qui peut prêter à confusion: l’hoisting (i.e. hisser). Le principe de ce comportement est le suivant:

  1. Le moteur Javascript effectue 2 passes sur le code avant de l’exécuter:
    • Une 1ère passe pour référence toutes les déclarations de variables, de fonctions et les définitions des fonctions.
    • Une 2e passe permet d’affecter à ces objets leur valeur d’initialisation (s’il y en a une).
  2. Ces 2 passes entraînent que l’ordre de traitement des lignes de code par le moteur Javascript n’est pas toute à fait le même que celui du script:
    • Les déclarations sont traitées avant les affectations (même si dans le script l’affectation d’une variable est placée avant la déclaration).
    • Les déplacements des lignes traitées sont effectués à l’intérieur d’une même portée et se limite à la portée d’une fonction.
    • Les déclarations de fonction sont déplacées avant les autres objets.

Par exemple, si on exécute le code suivant:

function a() {
  x = 5;
  var x;
  console.log(x); // 5
  console.log(window.x); // undefined
}

a();

On pourrait penser qu’étant donné l’ordre des lignes dans a(), x = 5 correspondrait à l’affectation de 5 à la variable globale x. Mais le comportement de hoisting entraîne la modification de l’ordre de traitement des lignes par le moteur Javascript. Dans la réalité, les lignes sont exécutées de cette façon:

function a() {
  var x;
  x = 5;
  console.log(x); // 5
  console.log(window.x); // undefined
}

a();

A l’intérieur de la portée de la fonction a(), var x est traité avant x = 5.

Pas d’hoisting avec let ou const

Si on exécute:

function a() {
  x = 5; // ReferenceError: can't access lexical declaration 'x' before initialisation
  let x;
  console.log(x);
  console.log(window.x);
}

a();

Une erreur explicite indique qu’on peut pas inverser l’ordre des lignes, la déclaration doit se trouver avant l’initialisation.

Hoisting valable pour les fonctions anonymes et les arrow functions
Le même comportement d’hoisting est valable pour les fonctions anonymes (i.e. function expressions), par exemple:

a = function() {
  return 'from function expression';
}

var a;

console.log(a()); // from function expression
console.log(window.a()); // window.a is not a function

Même comportement que précédemment, la déclaration de la fonction a() est déplacée par hoisting.

Le comportement est le même pour les arrow functions:

a = () => {
  return 'from arrow function';
}

var a;

console.log(a()); // from arrow function
console.log(window.a()); // window.a is not a function

Les déclarations de fonctions sont déplacées avant les autres objets
Les déclarations des fonctions sont traitées prioritairement par rapport aux déclarations de variables.
Si on exécute le code suivant:

console.log(a()); // from a()

a = function() {
  return 'from function expression';
}

var a;

function a() {
  return 'from a()';
}

L’hoisting déplace le code de cette façon:

function a() {
  return 'from a()';
}

console.log(a()); // from a()

a = function() {
  return 'from function expression';
}

En résumé…

D’une façon générale, il faut éviter de déclarer des variables dans la portée globale en javascript. Pour que la portée d’une variable soit limitée, on utilise les mots-clé var, let ou const:

  • var est compatible avec toutes les versions de Javascript toutefois il entraîne certains comportements qui ne sont pas standards.
  • let est apparu à partir de ES6. La portée d’une variable déclarée avec ce mot-clé est standard par rapport aux autres langages.
  • const est similaire à let à la différence qu’il n’autorise pas de nouvelles affectations à une variable.

La portée d’une variable se limite à un bloc de code pour let et const. Avec var, la portée correspond à une fonction, un bloc de code ne limite pas la portée.

Portée statique (i.e. lexical scope)
Le moteur Javascript utilise la portée statique pour déterminer la déclaration d’une variable. Suivant les différents mots-clé utilisés la notion de portée ne sera pas la même toutefois la recherche de la déclaration d’une variable se fait de la même façon:

  • Lorsque le moteur d’exécution exécute la déclaration d’une variable, la portée de la variable sera celui dans lequel se trouve la déclaration.
  • Lorsque le moteur d’exécution cherche la valeur d’une variable, il recherche sa déclaration dans la portée courante. Si aucune déclaration n’est trouvée, le moteur cherche dans les portées parentes. Si aucune déclaration n’est trouvée alors la variable appartient à la portée globale.
  • Si une variable est déclarée dans une portée parente et qu’une variable est déclarée avec le même nom dans une portée enfant, la variable dans la portée parente sera occultée par celle de la portée enfant. Ce comportement correspond au shadowing.

eval() et new Function()
On peut modifier le comportement de la portée statique avec eval() qui permet d’évaluer une expression sous forme de chaîne de caractères:

  • La portée de l’expression évaluée par eval() est celui dans lequel eval() est exécuté. L’expression peut être construite dans une portée différente, toutefois c’est au moment de l’exécution que la portée de l’expression sera confondue avec la portée dans laquelle eval() est exécutée.
  • En mode strict, la portée de l’expression évaluée par eval() est limitée à eval(), elle n’est pas confondue avec la portée dans laquelle eval() est exécutée.

Le constructeur new Function() permet aussi d’évaluer une expression sous forme de chaîne de caractères. La différence avec eval() est que la portée parente d’un objet de type Function est la portée globale et non la portée dans laquelle new Function() est exécutée.

this
La valeur de this dépend du contexte dans lequel il est utilisé:

  • Dans une classe, this est l’instance de cette classe.
  • Dans un objet, this est l’instance de cet objet.
  • Dans une fonction n’appartenant pas à un objet:
    • En mode non strict, this est le contexte global
    • En mode strict, this est indéfini.

On peut affecter explicitement une valeur à this quand on exécute une fonction en utilisant:

  • call(): <fonction>.call(thisVar, [arg1, arg2,..., argN]);
  • apply(): <fonction>.apply(thisVar, [tableau d'arguments]);
  • bind() qui permet de créer une nouvelle référence vers une fonction: let <référence fonction> = <fonction>.bind(thisVar, [arg1, arg2,..., argN]);

Si on exécute une fonction avec setTimeout(), par défaut, this fait référence au contexte global.

Hoisting

  • Si on déclare une variable avec var, le comportement d’hoisting du moteur Javascript peut entraîner un déplacement des lignes de code d’un script pour prendre en compte la déclaration d’une variable avant son initialisation. Même si dans le script initial, l’initialisation se trouve avant la déclaration, le moteur prendra en compte d’abord la déclaration.
  • Il n’y a pas d’hoisting si on utilise let ou const.
  • L’hoisting est valable aussi pour les fonctions anonymes ou les arrow functions.
  • Les déclarations de fonctions sont déplacées avant les autres objets.
Références
5 responses... add one

scope ne veut rien dire en français, on parle simplement de portée d’une variable

Ca peut paraître facile mais quand on rédige un article en français se pose souvent la question d’utiliser le terme français parfois peu utilisé ou de faire des anglicismes et de garder le terme en anglais. A la réflexion, effectivement utiliser le terme “portée” plutôt que “scope” semble plus approprié

Leave a Reply