Le scope des variables en Javascript

Le scope (ou la portée) 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. Le scope des objets est loin d’être un sujet trivial en Javascript car suivant la façon dont on les déclare 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 des scopes 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 au scope suivant l’utilisation de var, let ou const. Ensuite, on montrera quelle syntaxe permet de modifier les règles liées à la gestion du scope 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 dans le scope global, 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. Le scope 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';

Lexical scope vs dynamic scope

Quand Kyle Simpson présente le scope des objets en Javascript dans You don’t know JS, il explique le comportement de Javascript en passant par le lexical scope.
Le lexical scope ou static scope s’oppose au 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. Le lexical scope 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 du scope des variables, tous ces langages ont des comportements similaires:

  • Les scopes s’imbriquent: un scope peut se trouver à l’intérieur d’un autre scope. Les variables définies dans le scope parent sont accessibles dans le scope enfant.
  • Le scope le plus général est le scope global: dans le cas de Javascript, une variable globale est accessible partout.
  • La plupart du temps, un scope ne peut pas appartenir à plus d’un seul scope parent.

En Javascript, les règles qu’imposent le lexical scope sont les suivantes:

  • Règle 1: lorsque le moteur d’exécution exécute la déclaration d’une variable, le scope de la variable sera celui dans lequel 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 le scope courant. Si aucune déclaration n’est trouvée, le moteur cherche dans les scopes parent. Si aucune déclaration n’est trouvée alors la variable appartient au scope global.
  • Règle 3: si une variable est déclarée dans un scope parent et qu’une variable est déclarée avec le même nom dans un scope enfant, la variable dans le scope parent sera occultée par celle du scope enfant. Ce comportement correspond au shadowing.

Pour se rendre compte de la différence entre le lexical scope et le dynamic scope, 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 le scope utilisé, le comportement est le suivant:

Lexical scope

Dynamic scope

Le dynamic scope utilise des piles durant l’exécution pour garder les valeurs des variables de façon dynamique.
Javascript a le comportement correspondant au lexical scope, il n’utilise donc pas le dynamic scope..
  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 le scope de la méthode a(), y’a-t-il une déclaration d’une variable x dans le scope correspondant au corps de la méthode a() ? ⇒ non donc on cherche dans le scope parent 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 le scope 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 le scope 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 scopes dans lesquels 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 son scope: suivant le mot-clé utilisé, le scope de la variable sera très différent:

  • var permet de déclarer une variable dans le scope global ou dans le scope d’une fonction.
  • const et let permettent de déclarer une variable dans le scope 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>

Scope avec let et const

La gestion du scope 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 le scope 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, le scope d’une variable est limité au bloc de code dans lequel 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.

Scope avec var

Contrairement à let et const, le scope des objets avec var correspond aux fonctions. Les blocs de code ne limitent pas les scopes 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);

Le scope est le même pour les variables a et b même si b est déclaré 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 scope n’est pas modifiée suivant le type de fonction. Dans le cas de var, le scope 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 du lexical scope 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 du lexical scope ?

Il est possible de modifier le comportement du lexical scope en utilisant eval() ou le constructeur 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'.

Le scope d’une variable utilisée dans une expression évaluée par eval() dépend du scope dans lequel l’expression est évaluée et non en fonction du scope dans lequel 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

Le scope utilisé 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 le scope dans lequel 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 un scope spécifique qui ne déborde pas du scope dans lequel eval() est exécuté.

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, le scope 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(), le scope est spécifique à l’expression évaluée, il ne déborde pas dans le scope dans lequel eval() est exécuté.

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. Le scope de l’expression évaluée par eval() est limité, 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 scope, cet objet ne se comporte ni comme une fonction normale, ni comme eval(): le scope parent d’un objet de type Function est le scope global et non le scope de la fonction parente.

Par exemple dans le cadre général, si une fonction est définie dans une autre fonction dite parente, le scope parent de la fonction est le scope de la fonction parente. C’est la règle du lexical scope.

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 du lexical scope 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();

Le scope parent de l’objet Function n’est pas la fonction parent() mais le scope global, ainsi:

  • Quand l’expression est évaluée x = 2 fera référence à x dans le scope parent c’est-à-dire le scope global. 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, le scope parent de eval() correpond, par défaut, au scope dans lequel eval() est exécuté. Ce qui est contraire au comportement de l’objet Function puisque son scope parent est le scope global.

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 le scope 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 le plus courant d’une closure 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 le scope de la fonction externe, or dans le scope 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 le scope 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 du lexical scope: pour connaître la valeur d’une variable, le moteur vérifie le scope courant puis les scopes parent 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 au scope 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 le scope de this est perdu 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 au scope global. 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’un même scope et se limite au scope 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 du scope 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 le scope global 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.

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

Lexical scope
Le moteur Javascript utilise le lexical scope pour déterminer la déclaration d’une variable. Suivant les différents mots-clé utilisés la notion de scope 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, le scope 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 le scope courant. Si aucune déclaration n’est trouvée, le moteur cherche dans les scopes parent. Si aucune déclaration n’est trouvée alors la variable appartient au scope global.
  • Si une variable est déclarée dans un scope parent et qu’une variable est déclarée avec le même nom dans un scope enfant, la variable dans le scope parent sera occultée par celle du scope enfant. Ce comportement correspond au shadowing.

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

  • Le scope de l’expression évaluée par eval() est celui dans lequel eval() est exécuté. L’expression peut être construite dans un scope différent, toutefois c’est au moment de l’exécution que le scope de l’expression sera confondu avec le scope dans lequel eval() est exécuté.
  • En mode strict, le scope de l’expression évaluée par eval() est limité à eval(), il n’est pas confondu avec le scope dans lequel eval() est exécuté.

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 le scope parent d’un objet de type Function est le scope global et non le scope dans lequel new Function() est exécuté.

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
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Leave a Reply