JavaScript Asincrono per Principianti: Guida Completa a Promesse e Generatori

Principiante
JavaScript

Esplora il mondo della programmazione asincrona in JavaScript con questa guida completa su Promesse e Generatori, essenziali per costruire applicazioni web reattive e performanti. Impara a gestire operazioni complesse senza bloccare l'interfaccia utente.

Pubblicato
Tag
programmazione web Beginner javascript async await promises asincrono generators callback-hell

Introduzione all'Asincronicità in JavaScript: Promesse e Generatori

Benvenuti nel mondo affascinante della programmazione web! Se stai iniziando il tuo percorso con JavaScript, avrai presto scoperto che non tutte le operazioni sono immediate. Alcune, come il recupero di dati da un server, l'elaborazione di file o l'esecuzione di animazioni complesse, richiedono tempo. Se JavaScript eseguisse tutte queste operazioni in modo sequenziale, la tua applicazione si bloccherebbe, diventando inutilizzabile per l'utente.

È qui che entra in gioco l'asincronicità. La programmazione asincrona permette al tuo codice di avviare un'operazione "lunga" e poi continuare a eseguire altre attività, senza aspettare la fine della prima. Quando l'operazione "lunga" è finalmente completata, JavaScript può riprendere da dove aveva lasciato, gestendo il risultato.

In questa guida completa, esploreremo due strumenti fondamentali che JavaScript ci offre per gestire l'asincronicità in modo elegante ed efficiente: le Promesse (Promises) e i Generatori (Generators). Capiremo come funzionano, perché sono importanti e come usarli per scrivere codice più pulito, robusto e performante. Preparati a immergerti in concetti che trasformeranno il tuo modo di pensare alla programmazione web!

Comprendere l'Asincronicità e le sue Sfide

Prima di addentrarci nelle soluzioni, è cruciale capire il problema. JavaScript è un linguaggio single-threaded, il che significa che può eseguire un solo pezzo di codice alla volta. Immagina una singola corsia di traffico: se un camion pesante (un'operazione lunga) occupa la corsia, tutte le altre auto (altre operazioni) devono aspettare.

Nel contesto del browser, questo significa che se una funzione JavaScript impiega troppo tempo per essere eseguita, l'intera interfaccia utente si blocca: non puoi cliccare sui pulsanti, scorrere la pagina o vedere animazioni. Questo porta a un'esperienza utente frustrante e a un'applicazione che sembra "bloccata" o "non responsiva".

Storicamente, JavaScript ha gestito l'asincronicità tramite le callback. Una callback è una funzione che viene passata come argomento a un'altra funzione e viene eseguita solo dopo che l'operazione principale è stata completata. Sebbene le callback siano efficaci per operazioni singole, diventano rapidamente ingestibili quando si devono coordinare più operazioni asincrone dipendenti l'una dall'altra. Questo fenomeno è noto come "Callback Hell" o "Pyramid of Doom", dove il codice diventa profondamente indentato e difficile da leggere, mantenere e debuggare.

// Esempio di Callback Hell
getData(function(data) {
  processData(data, function(processed) {
    saveProcessedData(processed, function(result) {
      console.log('Operazione completata:', result);
    }, function(error) {
      console.error('Errore nel salvataggio:', error);
    });
  }, function(error) {
    console.error('Errore nell'elaborazione:', error);
  });
}, function(error) {
  console.error('Errore nel recupero dati:', error);
});

Come puoi vedere dall'esempio, il codice diventa rapidamente illeggibile e pieno di parentesi graffe annidate. È qui che le Promesse e, successivamente, async/await, brillano, offrendo un modo molto più pulito e strutturato per gestire queste situazioni.

Le Promesse (Promises): Un Patto per il Futuro

Le Promesse sono un oggetto JavaScript che rappresenta il completamento o il fallimento di un'operazione asincrona. Immagina una Promessa come un contratto: ti prometto che questa operazione si concluderà in futuro, o con successo (ti darò un risultato) o con un errore (ti dirò perché non ha funzionato). Nel frattempo, puoi continuare a fare altro.

Una Promessa può trovarsi in uno dei seguenti stati:

  • Pending (In sospeso): Lo stato iniziale; l'operazione non è ancora stata completata o rifiutata.
  • Fulfilled (Mantenuta): L'operazione è stata completata con successo e ha restituito un valore.
  • Rejected (Rifiutata): L'operazione è fallita e ha restituito un motivo (un errore).

Una volta che una Promessa è fulfilled o rejected, è considerata settled (risolta) e il suo stato non può più cambiare.

Creare una Promessa

Puoi creare una Promessa usando il costruttore Promise che accetta una funzione (chiamata "executor") con due argomenti: resolve e reject. Chiamerai resolve() quando l'operazione asincrona ha successo, passando il risultato. Chiamerai reject() se l'operazione fallisce, passando l'errore.

const miaPromessa = new Promise((resolve, reject) => {
  // Simula un'operazione asincrona, ad esempio un'attesa di 2 secondi
  setTimeout(() => {
    const successo = true; // Immagina che questo sia il risultato di un'operazione reale

    if (successo) {
      resolve('Dati recuperati con successo!'); // La Promessa è fulfilled
    } else {
      reject('Errore nel recupero dei dati.'); // La Promessa è rejected
    }
  }, 2000);
});

Gestire il Risultato: then(), catch(), finally()

Una volta creata una Promessa, puoi "agganciare" delle funzioni per gestire il suo risultato utilizzando i metodi then(), catch() e finally().

  • .then(onFulfilled, onRejected): Questo metodo viene chiamato quando la Promessa è fulfilled (il primo argomento) o rejected (il secondo argomento, opzionale). Generalmente, si preferisce usare catch() per la gestione degli errori per chiarezza.
  • .catch(onRejected): Questo metodo è una scorciatoia per .then(null, onRejected) e viene chiamato specificamente quando la Promessa è rejected. È il modo preferito per gestire gli errori in un flusso di Promesse.
  • .finally(onFinally): Questo metodo viene chiamato indipendentemente dal fatto che la Promessa sia stata fulfilled o rejected. È utile per eseguire del codice di pulizia, come nascondere uno spinner di caricamento.
miaPromessa
  .then(risultato => {
    console.log('Successo:', risultato); // 'Dati recuperati con successo!'
  })
  .catch(errore => {
    console.error('Fallimento:', errore); // 'Errore nel recupero dei dati.'
  })
  .finally(() => {
    console.log('Operazione completata, indipendentemente dal successo o fallimento.');
  });

Concatenare Promesse (Chaining)

Uno dei maggiori vantaggi delle Promesse è la loro capacità di essere concatenate. Il metodo then() restituisce sempre una nuova Promessa, il che ti permette di chiamare un altro .then() su di essa. Questo è fondamentale per eseguire una serie di operazioni asincrone in sequenza, una dopo l'altra, evitando il Callback Hell.

Se una funzione then() restituisce un valore, la Promessa successiva si risolverà con quel valore. Se restituisce un'altra Promessa, la Promessa successiva attenderà che quest'ultima si risolva.

function recuperaUtente(id) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id: id, nome: 'Alice' }), 1000);
  });
}

function recuperaPostUtente(utente) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ utenteId: utente.id, post: ['Post 1', 'Post 2'] }), 1000);
  });
}

recuperaUtente(1)
  .then(utente => {
    console.log('Utente recuperato:', utente);
    return recuperaPostUtente(utente); // Ritorna una nuova Promessa
  })
  .then(post => {
    console.log('Post dell'utente:', post);
    console.log('Tutto è andato a buon fine!');
  })
  .catch(errore => {
    console.error('Si è verificato un errore nella catena:', errore);
  });

Gestire più Promesse: Promise.all() e Promise.race()

JavaScript offre anche metodi statici per gestire collezioni di Promesse:

  • Promise.all(iterable): Accetta un array di Promesse e restituisce una nuova Promessa che si risolve quando tutte le Promesse nell'array si sono risolte con successo. Il valore risolto sarà un array dei risultati delle singole Promesse, nell'ordine in cui sono state passate. Se una sola delle Promesse fallisce, Promise.all() fallisce immediatamente con l'errore della prima Promessa fallita.
  • Promise.race(iterable): Accetta un array di Promesse e restituisce una nuova Promessa che si risolve o si rifiuta non appena una sola delle Promesse nell'array si risolve o si rifiuta. "Vince" la prima Promessa che si conclude, indipendentemente dal suo stato.
const promessa1 = new Promise(resolve => setTimeout(() => resolve('Uno'), 3000));
const promessa2 = new Promise(resolve => setTimeout(() => resolve('Due'), 1000));
const promessa3 = new Promise((resolve, reject) => setTimeout(() => reject('Tre fallito'), 2000));

Promise.all([promessa1, promessa2])
  .then(valori => console.log('Promise.all successo:', valori)); // ['Uno', 'Due'] dopo 3 secondi

Promise.race([promessa1, promessa2])
  .then(valore => console.log('Promise.race successo:', valore)); // 'Due' dopo 1 secondo

Promise.all([promessa1, promessa3])
  .then(valori => console.log('Questo non verrà mai stampato'))
  .catch(errore => console.error('Promise.all fallito:', errore)); // 'Tre fallito' dopo 2 secondi

Async/Await: Semplificare le Promesse

Le Promesse sono un enorme miglioramento rispetto alle callback, ma la sintassi then().catch() può comunque diventare un po' verbosa, specialmente quando si concatenano molte operazioni o si devono gestire errori in punti specifici. Qui entra in gioco Async/Await, introdotto in ES2017, che è una "sintassi zucchero" per le Promesse. In altre parole, non è un nuovo modo di gestire l'asincronicità, ma un modo più leggibile e più simile al codice sincrono per lavorare con le Promesse.

La parola chiave async

Per usare await, devi prima dichiarare la funzione che lo contiene come async. Una funzione async restituisce sempre implicitamente una Promessa. Se la funzione restituisce un valore non-Promessa, quel valore verrà automaticamente wrappato in una Promessa risolta.

async function saluta() {
  return 'Ciao, mondo!'; // Equivalente a return Promise.resolve('Ciao, mondo!');
}

saluta().then(messaggio => console.log(messaggio)); // Output: Ciao, mondo!

La parola chiave await

La parola chiave await può essere usata solo all'interno di una funzione async. await "mette in pausa" l'esecuzione della funzione async fino a quando la Promessa che await sta aspettando non si risolve o si rifiuta. Quando la Promessa si risolve, await restituisce il valore risolto. Se la Promessa si rifiuta, await lancia un errore, che può essere catturato con un blocco try...catch.

function recuperaDatiAPI(url) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`Dati da ${url} recuperati.`);
      resolve(`Risultato da ${url}`);
    }, 1500);
  });
}

async function eseguiRecuperoDati() {
  console.log('Inizio recupero dati...');
  const dati1 = await recuperaDatiAPI('api/endpoint1'); // La funzione si mette in pausa qui
  console.log('Dati 1:', dati1);

  const dati2 = await recuperaDatiAPI('api/endpoint2'); // E qui
  console.log('Dati 2:', dati2);

  console.log('Recupero dati completato.');
  return 'Tutte le operazioni sono finite!';
}

eseguiRecuperoDati().then(messaggioFinale => console.log(messaggioFinale));

console.log('Questo messaggio appare immediatamente, senza attendere il recupero dati.');

Nell'esempio sopra, eseguiRecuperoDati sembra sincrona, ma in realtà è asincrona grazie ad async e await. Il codice al di fuori di eseguiRecuperoDati continua a essere eseguito mentre la funzione attende i risultati delle Promesse.

Gestione degli errori con try...catch

Con async/await, la gestione degli errori diventa molto simile a quella del codice sincrono, utilizzando i blocchi try...catch. Se una Promessa awaittata viene rifiutata, l'errore verrà lanciato e potrà essere catturato da un blocco try...catch circostante.

async function recuperaDatiConErrore(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (url.includes('errore')) {
        reject(`Errore nel recupero da ${url}`);
      } else {
        resolve(`Dati da ${url}`);
      }
    }, 1000);
  });
}

async function gestisciOperazioni() {
  try {
    const datiA = await recuperaDatiConErrore('api/datiA');
    console.log(datiA);

    const datiB = await recuperaDatiConErrore('api/datiConErrore'); // Questa Promessa fallirà
    console.log(datiB); // Questa riga non verrà mai eseguita

  } catch (errore) {
    console.error('Si è verificato un errore:', errore);
  }
}

gestisciOperazioni();

Async/Await rende il codice asincrono molto più leggibile e manutenibile, specialmente per coloro che sono abituati a un flusso di controllo sincrono. È diventato lo standard de facto per la gestione dell'asincronicità in JavaScript moderno.

I Generatori (Generators): Funzioni Interrompibili

I Generatori sono un concetto un po' diverso dalle Promesse, ma altrettanto potente per la gestione del flusso di controllo. Una funzione generatrice è una funzione che può essere "messa in pausa" e "riavviata" in qualsiasi momento, mantenendo il suo stato. Questo è in netto contrasto con le funzioni normali, che eseguono il loro codice dall'inizio alla fine senza interruzioni.

Cos'è una funzione generatrice? function*

Una funzione generatrice viene definita usando la sintassi function* (con l'asterisco dopo function). Quando chiami una funzione generatrice, non esegue immediatamente il suo codice. Invece, restituisce un oggetto Generatore (Generator Object). Questo oggetto è un iteratore, il che significa che ha un metodo next().

La parola chiave yield

All'interno di una funzione generatrice, la parola chiave yield viene utilizzata per mettere in pausa l'esecuzione della funzione e restituire un valore. Ogni volta che yield viene incontrato, la funzione si mette in pausa e il valore dopo yield viene restituito. La prossima volta che si chiama next(), la funzione riprende l'esecuzione dal punto in cui era stata interrotta, fino al yield successivo o alla fine della funzione.

Il metodo next()

Il metodo next() dell'oggetto Generatore viene utilizzato per far avanzare l'esecuzione della funzione generatrice. Ogni chiamata a next() restituisce un oggetto con due proprietà:

  • value: Il valore restituito dall'espressione yield (o undefined se la funzione è terminata).
  • done: Un booleano che indica se la funzione generatrice ha completato la sua esecuzione (true) o se ci sono ancora yield da raggiungere (false).
function* contatore() {
  yield 1;
  yield 2;
  yield 3;
  return 'Finito!'; // Il valore di ritorno finale
}

const gen = contatore(); // Non esegue il codice, restituisce l'oggetto generatore

console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: 'Finito!', done: true } - La funzione è terminata
console.log(gen.next()); // { value: undefined, done: true } - Chiamate successive dopo la fine

Iteratori e Generatori

Un Generatore è di fatto un iteratore. Questo significa che può essere utilizzato in costrutti come il ciclo for...of, che automaticamente chiamano next() e si fermano quando done è true.

function* numeriPari(limite) {
  let num = 0;
  while (num <= limite) {
    yield num;
    num += 2;
  }
}

for (const n of numeriPari(10)) {
  console.log(n); // 0, 2, 4, 6, 8, 10
}

Usi comuni dei Generatori

Sebbene non siano così diffusi per la gestione delle operazioni asincrone pure come le Promesse e Async/Await, i Generatori sono estremamente utili in altri scenari:

  • Sequenze infinite: Possono generare sequenze di valori all'infinito, poiché i valori vengono prodotti "on demand" (lazy evaluation), senza doverli calcolare tutti in anticipo.
  • Implementazione di iteratori personalizzati: Offrono un modo semplice per rendere un oggetto "iterabile".
  • Gestione dello stato e del flusso di controllo: Possono essere usati per implementare macchine a stati o per controllare l'esecuzione di una serie di passaggi complessi, mettendo in pausa e riprendendo l'esecuzione in base a input esterni.
  • Semplificazione di codice asincrono complesso (in combinazione con librerie): Storicamente, sono stati usati in librerie come co o redux-saga per scrivere codice asincrono che assomiglia a codice sincrono, prima dell'avvento di async/await come feature nativa del linguaggio. Anche se async/await ha in gran parte soppiantato questo uso diretto per l'asincronicità, i Generatori rimangono una base potente.

Esempi Pratici: Dalla Teoria alla Realtà

Vediamo come applicare Promesse, Async/Await e Generatori in scenari più concreti.

Esempio 1: Fetch di dati da un'API con Async/Await

Questo è uno degli scenari più comuni nel web development. Useremo l'API fetch nativa di JavaScript, che restituisce una Promessa.

async function recuperaPostUtenti(userId) {
  try {
    // 1. Recupera i dati dell'utente
    const userResponse = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
    if (!userResponse.ok) {
      throw new Error(`HTTP error! status: ${userResponse.status}`);
    }
    const userData = await userResponse.json();
    console.log('Dati utente:', userData.name);

    // 2. Recupera i post associati a quell'utente
    const postsResponse = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
    if (!postsResponse.ok) {
      throw new Error(`HTTP error! status: ${postsResponse.status}`);
    }
    const postsData = await postsResponse.json();
    console.log(`Post di ${userData.name}:`);
    postsData.forEach(post => console.log(`- ${post.title.substring(0, 30)}...`));

    return { user: userData, posts: postsData };

  } catch (error) {
    console.error('Errore durante il recupero:', error);
    throw error; // Rilancia l'errore per gestirlo a un livello superiore se necessario
  }
}

// Chiamiamo la funzione
recuperaPostUtenti(1)
  .then(data => console.log('Operazione completata con successo!'))
  .catch(err => console.error('Gestione errore finale:', err));

// Prova con un ID utente inesistente per vedere la gestione degli errori
recuperaPostUtenti(9999);

Questo esempio mostra come async/await rende un flusso di lavoro asincrono multi-step leggibile quasi come un blocco di codice sincrono, gestendo gli errori in modo pulito con try...catch.

Esempio 2: Simulazione di una sequenza di animazione con un Generatore

I Generatori sono ottimi per controllare sequenze passo-passo.

function* sequenzaAnimazione() {
  console.log('Passo 1: Inizio animazione.');
  yield 'step1';

  console.log('Passo 2: Movimento iniziale.');
  yield 'step2';

  console.log('Passo 3: Effetto speciale.');
  yield 'step3';

  console.log('Passo 4: Animazione completata!');
  return 'finished';
}

const anim = sequenzaAnimazione();

document.getElementById('startAnimBtn').addEventListener('click', () => {
  const nextStep = anim.next();
  if (!nextStep.done) {
    console.log(`Eseguo: ${nextStep.value}`);
    // Qui potresti aggiornare la UI in base a nextStep.value
  } else {
    console.log('Animazione già finita.');
    alert('Animazione finita: ' + nextStep.value);
  }
});

// Il bottone 'startAnimBtn' dovrebbe essere presente nel tuo HTML:
// <button id="startAnimBtn">Avanti Animazione</button>

In questo scenario, ogni click sul bottone fa avanzare l'animazione di un passo, permettendo un controllo granulare del flusso senza dover gestire stati complessi manualmente.

Esempio 3: Lazy evaluation con un Generatore (Numeri Primi)

Un generatore può produrre una sequenza di valori solo quando richiesto, il che è efficiente per sequenze potenzialmente infinite o molto lunghe.

function* generaNumeriPrimi() {
  let num = 2;
  while (true) {
    let isPrime = true;
    for (let i = 2; i <= Math.sqrt(num); i++) {
      if (num % i === 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) {
      yield num;
    }
    num++;
  }
}

const primi = generaNumeriPrimi();

console.log(primi.next().value); // 2
console.log(primi.next().value); // 3
console.log(primi.next().value); // 5
console.log(primi.next().value); // 7
// Puoi continuare a chiamare next() per ottenere il prossimo numero primo, all'infinito.
// I numeri primi vengono calcolati solo quando richiesti, non tutti in una volta.

Errori Comuni e Come Evitarli

Quando si lavora con Promesse e Generatori, è facile incappare in alcuni errori comuni. Esserne consapevoli ti aiuterà a evitarli.

1. Dimenticare await in una funzione async

Se chiami una funzione che restituisce una Promessa all'interno di una funzione async ma dimentichi await, il codice non attenderà la risoluzione della Promessa. Invece, continuerà immediatamente, lavorando con la Promessa stessa invece del suo valore risolto. Questo porta a undefined o a Promesse non risolte.

async function fetchData() {
  const response = fetch('api/data'); // Errore: manca 'await'
  // response qui è una Promessa, non il risultato del fetch
  const data = await response.json(); // Questo potrebbe fallire o operare su una Promessa
  console.log(data);
}

// Corretto:
async function fetchDataCorrectly() {
  const response = await fetch('api/data'); // OK, attende la risposta
  const data = await response.json(); // OK, attende il parsing JSON
  console.log(data);
}

2. Non gestire i catch (o i reject delle Promesse)

Se una Promessa viene rifiutata e non c'è un blocco .catch() o un try...catch per gestirla, l'errore verrà "inghiottito" o, nel peggiore dei casi, causerà un errore non gestito che potrebbe bloccare la tua applicazione o mostrare errori generici all'utente. Assicurati sempre di avere una gestione degli errori.

// Errore: Promessa rifiutata non gestita
new Promise((resolve, reject) => {
  reject('Qualcosa è andato storto!');
}); // Questo causerà un Uncaught Promise (in promise) error nel browser/Node

// Corretto:
new Promise((resolve, reject) => {
  reject('Qualcosa è andato storto!');
}).catch(err => console.error('Errore gestito:', err));

// Con async/await:
async function doSomethingWithError() {
  try {
    await Promise.reject('Errore da async/await!');
  } catch (err) {
    console.error('Errore gestito in async/await:', err);
  }
}
doSomethingWithError();

3. Confondere il valore restituito da next() con il valore yieldato

Quando si lavora con i Generatori, gen.next() restituisce un oggetto { value, done }. Molti principianti dimenticano di accedere alla proprietà value.

function* myGenerator() {
  yield 'Hello';
}

const g = myGenerator();
console.log(g.next());      // { value: 'Hello', done: false }
console.log(g.next().value); // 'Hello' (corretto)

4. Non capire che le Promesse sono immutabili una volta risolte

Una volta che una Promessa è fulfilled o rejected, il suo stato è finale e non può cambiare. Tentare di chiamare resolve() o reject() più volte non avrà alcun effetto dopo la prima chiamata.

const p = new Promise((resolve, reject) => {
  resolve('Primo!');
  reject('Secondo!'); // Questo non avrà effetto
});
p.then(val => console.log(val)); // Output: Primo!

Prossimi Passi per Approfondire

Complimenti! Hai coperto i concetti fondamentali di Promesse e Generatori in JavaScript, strumenti indispensabili per ogni sviluppatore web moderno. Questo è solo l'inizio del tuo viaggio nella programmazione asincrona. Ecco alcuni prossimi passi per approfondire le tue conoscenze:

  1. L'Event Loop di JavaScript: Capire come funziona l'Event Loop è fondamentale per comprendere veramente il modello di concorrenza di JavaScript. Ti aiuterà a capire perché il tuo codice asincrono non blocca l'interfaccia utente e come le Promesse e le callback vengono elaborate.
  2. Promise.allSettled() e Promise.any(): Oltre a Promise.all() e Promise.race(), esplora questi altri metodi statici delle Promesse, utili per scenari specifici (ad esempio, quando vuoi sapere il risultato di tutte le Promesse, anche quelle fallite, o quando ti basta che una qualsiasi Promessa abbia successo).
  3. Generatori per la gestione dello stato: Approfondisci come i Generatori possono essere usati in architetture più complesse per la gestione dello stato, ad esempio con librerie come Redux-Saga in React, che sfrutta i Generatori per orchestrarare effetti collaterali asincroni.
  4. Web Workers: Per operazioni CPU-intensive che non possono essere gestite dall'asincronicità standard (che comunque opera sul thread principale), i Web Workers offrono la possibilità di eseguire codice JavaScript in un thread separato, liberando completamente l'interfaccia utente.
  5. RxJS (Reactive Extensions for JavaScript): Per una gestione ancora più avanzata di eventi e flussi di dati asincroni, dai un'occhiata a librerie come RxJS, che introduce il concetto di "Observable", una potente astrazione per la programmazione reattiva.

Continua a sperimentare, a scrivere codice e a porre domande. La pratica è la chiave per padroneggiare questi concetti. Buon coding!