Introduzione
Quando si lavora con JavaScript, è comune utilizzare callback, timer, listener per gli eventi e funzioni asincrone. In questi contesti, comprendere scope e hoisting aiuta a evitare bug difficili da individuare, soprattutto quando una funzione viene eseguita in un momento successivo rispetto alla sua dichiarazione.
Una callback può infatti “ricordare” le variabili disponibili nel punto in cui è stata creata. Questo comportamento è legato allo scope lessicale e alle closure. Allo stesso tempo, l’ordine delle dichiarazioni può produrre risultati diversi a seconda che si utilizzino var, let, const o dichiarazioni di funzione.
In questo tutorial analizzeremo un caso pratico: la creazione di messaggi temporizzati con setTimeout. Vedremo perché un ciclo con var può stampare valori inattesi e come risolvere il problema usando let o una funzione che crea uno scope separato.
Codice completo
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Scope nelle callback</title>
</head>
<body>
<button id="avvia">Avvia messaggi</button>
<pre id="output"></pre>
<script>
"use strict";
const pulsante = document.querySelector("#avvia");
const output = document.querySelector("#output");
function stampaMessaggio(testo) {
output.textContent += testo + "n";
}
function avviaConVar() {
output.textContent = "Con var:n";
for (var indice = 1; indice <= 3; indice++) {
setTimeout(function () {
stampaMessaggio("Messaggio numero " + indice);
}, indice * 500);
}
}
function avviaConLet() {
output.textContent += "nCon let:n";
for (let indice = 1; indice <= 3; indice++) {
setTimeout(function () {
stampaMessaggio("Messaggio numero " + indice);
}, indice * 500);
}
}
function creaCallback(numero) {
return function () {
stampaMessaggio("Callback isolata numero " + numero);
};
}
function avviaConFunzione() {
output.textContent += "nCon funzione di fabbrica:n";
for (var indice = 1; indice <= 3; indice++) {
setTimeout(creaCallback(indice), indice * 500);
}
}
pulsante.addEventListener("click", function () {
avviaConVar();
setTimeout(function () {
avviaConLet();
}, 2000);
setTimeout(function () {
avviaConFunzione();
}, 4000);
});
</script>
</body>
</html> Spiegazione
Il problema di var nelle callback
Nel primo esempio il ciclo utilizza var:
for (var indice = 1; indice <= 3; indice++) {
setTimeout(function () {
console.log(indice);
}, indice * 500);
} var ha uno scope di funzione, non uno scope di blocco. Il blocco del ciclo for non crea quindi una nuova variabile indice a ogni iterazione. Tutte le callback fanno riferimento alla stessa variabile.
Il ciclo termina rapidamente e, al termine, indice vale 4. Le callback vengono eseguite solo dopo il ritardo specificato, quando il ciclo è già concluso. Per questo motivo il risultato sarà simile a:
Messaggio numero 4
Messaggio numero 4
Messaggio numero 4 Il comportamento non dipende da un errore di setTimeout. Il timer conserva la callback, ma la callback legge la variabile quando viene eseguita. Poiché la variabile è condivisa, tutte le funzioni osservano il valore finale.
Perché let risolve il problema
Con let, JavaScript crea uno scope distinto per ogni iterazione del ciclo:
for (let indice = 1; indice <= 3; indice++) {
setTimeout(function () {
console.log(indice);
}, indice * 500);
} Ogni callback riceve quindi un proprio ambiente lessicale. La prima callback conserva il valore 1, la seconda il valore 2 e la terza il valore 3. Il risultato sarà quello atteso:
Messaggio numero 1
Messaggio numero 2
Messaggio numero 3 Questo è uno dei motivi pratici per cui let e const sono generalmente preferibili a var nel codice moderno.
La funzione di fabbrica e la closure
La funzione creaCallback rappresenta un’alternativa utile:
function creaCallback(numero) {
return function () {
console.log(numero);
};
} Ogni chiamata a creaCallback crea un nuovo scope locale. La funzione restituita mantiene un riferimento alla variabile numero, anche dopo la fine dell’esecuzione di creaCallback. Questo meccanismo prende il nome di closure.
Nel ciclo, chiamando creaCallback(indice), generiamo tre ambienti separati. Anche se il ciclo usa var, ogni callback riceve una copia logica del valore corrente:
setTimeout(creaCallback(indice), indice * 500); Il ruolo dell’hoisting
L’hoisting descrive il comportamento con cui JavaScript rende disponibili alcune dichiarazioni prima della loro posizione effettiva nel codice. Le dichiarazioni di funzione vengono normalmente sollevate completamente:
saluta();
function saluta() {
console.log("Ciao!");
} Questo codice funziona perché la dichiarazione di saluta è disponibile durante la fase di preparazione dell’ambiente di esecuzione.
Le variabili dichiarate con var vengono invece sollevate, ma inizializzate a undefined:
console.log(stato); // undefined
var stato = "pronto"; Con let e const, la dichiarazione esiste nello scope, ma non può essere utilizzata prima dell’inizializzazione. L’accesso anticipato produce un ReferenceError. Nelle callback asincrone questo errore può apparire quando la funzione viene eseguita, non necessariamente quando viene registrata.
Best practice
- Preferisci
constquando il riferimento non deve essere riassegnato. - Usa
letper i contatori e le variabili che cambiano, soprattutto nei cicli con callback. - Evita
varnel nuovo codice, salvo esigenze specifiche di compatibilità o comprensione di codice legacy. - Ricorda che una callback legge le variabili quando viene eseguita, ma conserva lo scope in cui è stata creata.
- Per valori complessi o logiche ripetute, valuta una funzione di fabbrica che crei closure esplicite.
- Non fare affidamento sull’hoisting per rendere il codice più breve: dichiara funzioni e variabili prima del loro utilizzo per migliorare la leggibilità.
- Usa nomi descrittivi per le variabili catturate dalle closure, così il loro ciclo di vita risulta più comprensibile.
- Quando esegui il debug, controlla sia il momento in cui una callback viene registrata sia quello in cui viene eseguita.
Riepilogo
Scope e hoisting diventano particolarmente importanti quando una funzione viene eseguita in ritardo, come accade con setTimeout o con gli eventi del browser. Una callback mantiene il riferimento allo scope in cui è stata creata, ma il valore osservato dipende dal modo in cui la variabile è stata dichiarata.
var condivide la stessa variabile tra le iterazioni di un ciclo, mentre let crea uno scope separato per ogni iterazione. Le funzioni di fabbrica permettono inoltre di ottenere lo stesso risultato tramite closure. Comprendere questi meccanismi consente di scrivere codice asincrono più prevedibile, facile da testare e meno soggetto a errori temporali.
