Come funziona lo scope e la variabile 'this' in javascript?

Scope, contesti d’esecuzione e molto altro sono solo alcune delle parti poco comprese di Javascript. Con questo articolo spero di riuscire a dare qualche delucidazione in più su come funzionano e su come possiamo utilizzare queste funzionalità a nostro vantaggio.

Questi argomenti non sono molto facili da capire in breve tempo, quindi il mio consiglio è quello di copiare gli esempi di codice scritti in questo articolo e di provare ad eseguirli nella console dei comandi del browser. Fate anche qualche test, smanettate e vedrete che pian piano tutto diventerà più chiaro!

Definizioni

  1. Scope: Lo scope è l’ambito di visibilità di una certa variabile, o l’insieme di variabili utilizzabili all’interno della funzione.
  2. this: Questa è una variabile speciale, che ci permette di indicare quale oggetto è il “proprietario” del codice che viene eseguito.
  3. Contesto di esecuzione: E’ l’insieme delle informazioni che sono necessarie per eseguire correttamente il codice.

Chiariamo subito un concetto fondamentale: la variabile this ha davvero poco a che fare con lo scope (cioè l’ambito di visibilità delle variabili). Questo è un argomento spesso frainteso, ma avere chiaro il funzionamento di questo sistema può aiutarci a superare errori che metterebbero molti programmatori in serie difficoltà.

Il contesto d’esecuzione

Un contesto d’esecuzione contiene tutte le informazioni necessarie per tenere traccia del progresso dell’esecuzione del codice associato.

Specifiche tecniche di EcmaScript

Questa informazione ci permette di capire che tutto ciò che riguarda l’esecuzione di una particolare parte di codice è contenuto nel contesto d’esecuzione. Questo include anche le variabili disponibili insieme ai loro riferimenti e il valore della variabile this, oltre ad altre cose.

Non è necessario dilungarsi su questo argomento in quanto non si ha modo di interagire direttamente con i contesti d’esecuzione, però è comunque utile sapere cosa s’intende quando se ne parla, soprattutto dato che viene spesso confuso con la variabile this o con lo scope.

Lo Scope

Il termine “scope” si può letteralmente tradurre con “ambito” o, per essere più specifici, “ambito di visibilità delle variabili”. Esistono diversi tipi di ambiti di visibilità:

  • Scope globale
  • Scope di funzione
  • Scope di blocco

Lo scope globale è quello dove risiedono le variabili (comprese anche funzioni e classi) globali, cioè quelle che vengono definite all’esterno di qualsiasi altra funzione o classe.

Uno scope di funzione viene generato ogni volta che viene creata una funzione. Questa è una cosa spesso fraintesa: per quanto riguarda lo scope, non ha importanza come la funzione viene creata, e nemmeno se è una funzione a freccia o classica. La particolarità delle funzioni a freccia verrà esaminata successivamente nell’articolo.

Lo scope di blocco è una “novità” (fra virgolette, dato che ormai sono passati diversi anni!) introdotta con la versione ES6 di javascript. In pratica ne viene creato uno ogni volta che si definisce una variabile dentro ad un blocco (if, for, while o simili) utilizzando le parole chiave let o const. Non funziona se utilizziamo la parola chiave var perché javascript sposta la definizione di queste variabili in cima alla funzione, quindi al di fuori del blocco (questo si chiama hoisting, ma va al di là dello scopo di questo articolo).

Ecco un piccolo esempio che comprende tutti i tipi di scope:

let a = 1; // Variabile nello scope globale

// Anche 'funzione1' viene definita nello scope globale, qui non c'è differenza fra funzioni e variabili
function funzione1() {
  let b = 2; // Variabile nello scope della funzione 'funzione1'

  if (b > a) {
    let c = 3; // Variabile nello scope di questo blocco (sezione di codice racchiusa dalle parentesi graffe)
    var d = 4; // Variabile nello scope della funzione 'funzione1', perché abbiamo usato il termine "var" per definirla
  }
}

Ogni variabile creata in uno scope, è disponibile solo al suo interno e all’interno delle funzioni che vengono definite in quell’ambito. Inoltre, quando viene creata una funzione, quest’ultima mantiene un riferimento allo scope nel quale è stata creata, così da avere accesso a tutte le sue variabili a prescindere da quando o dove viene chiamata: questo genere di funzioni vengono chiamate closures.

Riprendendo l’esempio fatto sopra:

let a = 1;

function foo() {
  let b = 2;

  if (b > a) {
    let c = 3;

    console.log(a); // Disponibile perché è nello scope globale
    console.log(b); // Disponibile perché questo blocco è all'interno della funzione foo, e quindi ha un riferimento al suo scope
    console.log(c); // Disponibile perché è definita in questo scope
  }

  console.log(c); // Non disponibile, perché è definita in un altro scope, che non è un genitore di quello corrente
}

console.log(c); // Non disponibile, perché è definita in un altro scope, che non è un genitore di quello corrente

Esempio di una closure:

function contenitore() {
  // Qui viene creato lo scope della funzione "contenitore"
  let a = 10;

  let funzioneInterna = function () {
    // Qui viene creato lo scope della funzione "funzioneInterna"

    // Dato che la funzione viene definita dentro allo scope della funzione "contenitore", mantiene un riferimento a quello scope
    console.log("a: ", a);
  };

  // Ritorniamo la funzione appena creata per poterla eseguire altrove
  return funzioneInterna;
}

// Anche se viene eseguita successivamente e in un altro contesto, la closure mantiene il riferimento alla variabile 'a'
let interna = contenitore();
interna(); // --> a: 10

Quindi, quando richiamiamo una variabile, possiamo immaginarci che succeda questo:

  1. Il browser controlla se la variabile richiesta è nello scope della funzione o del blocco corrente
  2. Se non è presente, va a cercare nel genitore dello scope
  3. Se non è presente, continua come nel punto 2 fino a raggiungere lo scope globale
  4. Se non è presente nemmeno lì, genera un errore “Variabile non definita”.

Qui è anche importante rendere chiara una cosa: lo scope viene generato alla dichiarazione della funzione, non nel momento dell’esecuzione.

Vediamo ora in che modo comprendere il concetto di “scope” ci può aiutare a risolvere alcuni problemi tipici di javascript.

Problema: Variabili globali

Spesso si sente dire che non è buono usare le variabili globali. Generalmente questo è vero, perché queste variabili, essendo memorizzate nello scope globale, potrebbero essere lette erroneamente da altri script presenti nella pagina e creare dei conflitti. Uno dei modi per eliminare il problema, è quello di creare uno scope specifico per quello script. Il modo più comune per fare questa cosa è tramite l’utilizzo di una IIFE (funzione che viene richiamata immediatamente):

// Creiamo una funzione (in questo esempio una funzione a freccia)
(() => {
  let variabile = "Io non inquino lo scope globale 🙂";

  // Tutto il codice dello script...
})(); // Eseguiamo subito la funzione

Comunque, esiste un metodo migliore: se non usiamo mai la keyword var per definire le nostre variabili, potremmo semplicemente creare un blocco (vedi l’articolo 5 chicche di Javascript):

{
  let variabile = "Nemmeno io inquino lo scope globale 😉";
}

Per questo specifico caso, fra le due opzioni trovo decisamente più pulita la seconda. Se vi preoccupa il fatto che su browser molto vecchi (per esempio Internet Explorer) potrebbe non funzionare, allora piuttosto vi consiglio di utilizzare un compilatore come Babel, in grado di trasformare codice recente per farlo andare su qualsiasi browser.

Problema: Callback create all’interno di cicli

Prendiamo questo codice come esempio:

let i = 0;
for (i = 0; i < 10; i++) {
  setTimeout(() => console.log(i), i * 1000);
}

Forse potremmo pensare che questo codice stampi “1”, “2”, “3”, … fino a “9” nell’arco di 10 secondi. Eppure, provate ad eseguirlo nella console del browser, e noterete che l’unico numero che viene stampato è il numero “10”! Perché succede questo?

La colpa è dello scope. La funzione che passiamo a setTimeout fa riferimento ad una variabile presente nello scope globale, la variabile i. Quando creiamo una funzione, non viene fatta una “foto” di come sono le variabili in quel momento, ma viene solo creato un collegamento a quello scope. Mentre aspettiamo che passi il tempo stabilito nel setTimeout, il ciclo si è già concluso e la variabile i avrà ora il valore “10”, quindi questo è quello che verrà stampato.

Il problema in sostanza è che abbiamo 10 funzioni che si collegano ad una sola variabile, il cui valore sarà già cambiato prima ancora che queste siano state eseguite.

Per evitare questo problema, ecco una possibile soluzione:

for (let i = 0; i < 10; i++) {
  setTimeout(() => console.log(i), i * 1000);
}

“Ma è uguale a prima?!”

In effetti è molto simile, ma c’è una lieve differenza: stavolta abbiamo definito la variabile all’interno del for, non all’esterno. Dato che l’abbiamo fatto con la parola chiave let, si è creato uno scope di blocco. Ogni volta che viene eseguito un ciclo del for, viene creato un nuovo scope. Così alla fine abbiamo 10 scope diversi, dove la variabile i ha valori che vanno dallo 0 al 9. Dato che ogni funzione ha un riferimento alla “sua” variabile i, il codice ora funzionerà come previsto.

Forse penserete: “Non si può proprio dire che la variabile i sia dichiarata dentro il for…”

In effetti non è dentro le parentesi graffe, ma è comunque buono immaginare che lo sia. La creazione dello scope e l’assegnamento della variabile nel ciclo for è qualcosa di sorprendentemente complicato! Se avete voglia di approfondire l’argomento, questo video di HTTP 203 dà una spiegazione moooolto approfondita (lo consiglio solo a chi ha già esperienza di javascript e di programmazione, se siete nuovi lasciate perdere, non farà che confondervi di più!).

In sostanza, il consiglio è questo: definite sempre la variabile del ciclo come nel codice della soluzione e non sbaglierete!

La parola chiave this

La parola chiave this è una variabile speciale, disponibile in ogni punto del codice javascript. Il suo valore può essere soltanto un oggetto, oppure null. Questa variabile vuole indicare quale oggetto è il “responsabile” o il “proprietario” del codice che viene eseguito. Per esempio, all’interno dei metodi di una classe, la variabile this punterà all’istanza della classe che stiamo utilizzando (quando il metodo viene eseguito normalmente).

Una cosa da tenere a mente: la variabile this non viene assegnata quando creiamo una funzione, ma nel momento in cui la utilizziamo. Ciò significa che all’interno di una funzione, la variabile this potrebbe avere molti valori diversi in base alla circostanza in cui viene chiamata. Questo spesso crea problemi che non sono molto facili da ispezionare.

Facciamo un esempio pratico:

class GestoreEventi {
  messaggio = "Hai interagito con la finestra del browser";

  click() {
    console.log(this.messaggio);
  }
}

In questo codice, stiamo creando una classe che ha un attributo e un metodo. Se avviamo il metodo “a mano”, il risultato sarà ciò che ci aspettiamo:

let gestore = new GestoreEventi();

gestore.click(); // --> Hai interagito con la finestra del browser

Invece, proviamo ora a collegare quella funzione all’evento “click” della finestra e vediamo cosa succede quando clicchiamo:

let gestore = new GestoreEventi();

window.addEventListener("click", gestore.click); // --> Quando premiamo nella finestra, vedremo il valore "undefined"

Questo succede perché quando la avviamo normalmente, la richiamiamo “attraverso” l’istanza della classe. L’istanza sarà la proprietaria della funzione, e quindi la variabile this punterà all’istanza come ci aspetteremmo. Invece, quando passiamo la funzione ad addEventListener, questa funzione viene “estratta”, per così dire, dall’istanza e viene eseguita da un’altra parte. L’oggetto this punterà ad un altro oggetto che dipende da come funzione viene eseguita, in questo specifico caso ha il valore di undefined.

Quando siamo nello scope globale (vedi sopra), il valore della variabile this è un oggetto che viene anche chiamato il globalThis. Quando si scrive codice che è al di fuori di qualsiasi oggetto o classe, la variabile this assumerà questo valore che, nel caso di un browser web, è un riferimento all’oggetto Window. Quando richiamiamo una funzione direttamente dallo scope globale, generalmente gli viene assegnato come valore del this il globalThis. Comunque, questo comportamento cambia quando si ha a che fare con del codice scritto in modalità “strict”.

Senza andare troppo nei dettagli, non utilizzate mai il this se non siete certi che abbia un valore sensato (un oggetto, una classe o nel caso stiate creando un costruttore).

Un esempio che potremmo fare per dimostrare ulteriormente che non ha importanza dove una funzione viene definita, potrebbe essere questo:

let funzioneIndipendente = function () {
  console.log("This: ", this);
};

// Eseguita da sola, prende il parametro del globalThis, o undefined (se si è in modalità "strict")
funzioneIndipendente(); // This: Window

let oggetto = {
  foo: funzioneIndipendente,
};

// Dato che la richiamiamo attraverso un oggetto, il valore del this sarà un riferimento all'oggetto.
oggetto.foo(); // --> This: oggetto

Come possiamo vedere, la funzione era stata definita fuori dall’oggetto. Eppure, quando viene assegnata ad una proprietà e viene chiamata attraverso l’oggetto, il valore della variabile this cambia di conseguenza.

Un’altra circostanza in cui dovremo utilizzare la variabile this è nel caso della creazione di una funzione costruttore. Comunque, personalmente non vi consiglio di creare queste funzioni. E’ meglio utilizzare le nuove classi disponibili con ES6 (che in fin dei conti sono solo un modo più carino di scrivere la stessa cosa). Comunque vi lascio questo riferimento ad un articolo di HTML.it se volete approfondire l’argomento.

Come manipolare il valore di this

Abbiamo alcuni modi per gestire il valore della variabile this. Questi sono i più comuni:

Il metodo bind()

Tutte le funzioni contengono un metodo chiamato bind. Questo metodo ci permette di ottenere una funzione che, ogni volta che verrà chiamata, avrà come valore della variabile this un oggetto a nostra scelta.

Questo metodo richiede un parametro obbligatorio: l’oggetto che verrà utilizzato come valore della variabile this. Gli si può anche passare altri parametri, che verranno poi reindirizzati direttamente alla funzione originale.

Il valore di ritorno è la stessa funzione, ma con la variabile this già definita, che punta all’oggetto che gli abbiamo indicato noi.

Sistemiamo il problema indicato sopra (quello della classe GestoreEventi) usando il metodo bind:

let gestore = new GestoreEventi();

// Otteniamo la funzione "gestore.click" assegnandole un parametro "this" specifico
let eventoClick = gestore.click.bind(handler);

window.addEventListener("click", eventoClick);

Quindi, come si può vedere in questo codice, abbiamo passato all’event listener la funzione che è stata ritornata dall’esecuzione del metodo bind. Dato che come primo parametro gli abbiamo passato l’istanza della nostra classe, ora siamo tranquilli che il valore della variabile this punterà sempre alla nostra istanza, a prescindere da dove usiamo la nuova funzione eventoClick.

I metodi call() e apply()

Il metodo call permette di fare qualcosa di simile al metodo bind. La differenza è che, al posto di ritornare una funzione che verrà eseguita in seguito, questo metodo eseguirà immediatamente la funzione, assegnando alla variabile this l’oggetto che verrà passato come primo parametro.

Questo metodo non si sposa bene con l’esempio fatto sopra, quindi facciamo una piccola modifica che non utilizza addEventListener:

let gestore = new GestoreEventi();

// Se la eseguiamo attraverso l'istanza, non ci sono problemi
gestore.click(); // --> "Hai interagito con la finestra del browser"

// Se invece la estraiamo dall'istanza e la eseguiamo per conto suo, il problema si ripresenta
let eventoClick = gestore.click;

eventoClick(); // --> undefined (il codice scritto nella definizione di una classe è sempre in modalità "strict")

// Utilizzando il metodo 'call' gli impostiamo il valore corretto.
eventoClick.call(gestore); // --> "Hai interagito con la finestra del browser"

Come possiamo notare, appena richiamiamo il metodo call la funzione viene subito eseguita. Ma se la funzione dovesse richiedere dei parametri, come facciamo a passarglieli?

Nel caso della funzione call, è sufficiente passarli come si farebbe normalmente, accodandoli subito dopo il primo parametro:

funzione.call(valoreThis, parametro1, parametro2, parametro3);

Qui entra anche in gioco il metodo apply. Questo metodo fa la stessa identica cosa che fa anche call, l’unica differenza sta proprio nel modo in cui vengono passati i parametri alla funzione. Mentre con call bisogna passare un elenco di parametri separati dalla virgola, nel caso del metodo apply bisogna passare un array di argomenti:

funzione.apply(valoreThis, [parametro1, parametro2, parametro3]);

A livello pratico non fa differenza usare l’una o l’altra, soprattutto dato che scompattare un array in un elenco di parametri separati è diventato molto facile con l’introduzione dell’operatore spread.

Le funzioni a freccia

Per quanto riguarda il valore della variabile this, le funzioni a freccia sono molto particolari e ci offrono un metodo pratico per superare molti dei problemi visti finora.

La loro particolarità sta nel fatto che non possono ricevere una variabile this propria. Il valore della variabile this sarà ereditato dallo scope nel quale vengono create.

Riprendiamo l’esempio della classe e vediamo come risolverlo con le funzioni a freccia:

class GestoreEventi {
  messaggio = "Hai interagito con la finestra del browser";

  // click è ora una funzione a freccia
  click = () => {
    console.log(this.messaggio);
  };
}

let gestore = new GestoreEventi();

// ora non darà più alcun problema
window.addEventListener("click", gestore.click);

Dato che nello scope di una classe il valore della variabile this è un riferimento all’istanza, creare il metodo click come una funzione a freccia risolve il nostro problema.

Anche se sembra una soluzione perfetta, non dobbiamo iniziare ad usare funzioni a freccia ovunque credendo che non avremo mai problemi. Prendiamo il seguente codice come esempio:

let utente = {
  nome: "Andrea",
  cognome: "Dragotta",

  stampaNomeIntero: () => {
    console.log(this.nome + " " + this.cognome);
  },
};

// Questo stamperà "undefined undefined"
utente.stampaNomeIntero();

Cosa è successo? In genere quando chiamiamo una funzione attraverso un oggetto, il valore della variabile this al suo interno viene impostato ad un riferimento all’oggetto. Ma dato che in questo caso abbiamo utilizzato una funzione a freccia, il valore della variabile this è quello dello scope nell quale si trovava alla definizione. In questo caso si trovava nello scope globale, quindi il valore era l’oggetto Window, che come sappiamo non ha né una proprietà chiamata “nome” né una chiamata “cognome”.

Per questo motivo è importante avere ben chiaro in mente come funziona la variabile this, così da poter facilmente risolvere questi problemi.

Una piccola confessione

Come potrete immaginare, questo articolo mostra una versione leggermente semplificata di quello che succede davvero nel browser (🤯). La verità è che se si vuole capire alla perfezione il modo in cui vengono creati gli scope, il funzionamento della variabile this e tutto il resto, è necessario andare ad esplorare le specifiche tecniche di EcmaScript.

Io ho deciso di scrivere quest’articolo proprio dopo aver guardato in profondità nelle specifiche, e quindi posso indicarvi alcuni punti da guardare… nel caso vogliate vedere “quant’è profonda la tana del bianconiglio”.

Altri riferimenti