Segui su:
Programmazione Web Italia

JavaScript Promise: impara a gestire efficacemente gli eventi asincroni!

JavaScript Promise: impara a gestire efficacemente gli eventi asincroni!

Ogni promessa è debito... Ma quanto è romantico il termine Promise in JavaScript? Sapere, poi, che dietro a questa parola si nasconde il fatto di non fermare l'esecuzione single-thread del JavaScript Engine mentre si attende l'esito di una operazione asincrona rende tutto più commovente!   ;) Marisa dice...

Introduzione alle operazioni asincrone

Cos'è una operazione asincrona?

Iniziamo prima definendo che cos'è la programmazione sincrona: in tale contesto il programma esegue tutte le istruzioni che lo compongono in maniera lineare una dopo l'altra. In altri termini, l'esecuzione attende di aver portato a termine una istruzione prima di passare a quella immediatamente successiva. Un esempio banale è il seguente, dove ogni log viene stampato a console esattamente come ce lo aspettiamo: "1", "2", "3"

In un codice sincrono le istruzioni vengono eseguite in ordine una dopo l'altra

console.log(1); // Prima istruzione
console.log(2); // Seconda istruzione
console.log(3); // Terza istruzione

La programmazione asincrona, invece, non è lineare e prevede che una istruzione venga eseguita dopo un'altra anche se definita precedentemente all'interno del codice. Questo è necessario quando vengono eseguiti compiti che prevedono un certo lasso di tempo per essere eseguiti (per esempio: una richiesta HTTP per ottenere delle risorse remote, una query ad un database o una operazione di I/O verso il disco rigido...). In questo caso è preferibile che l'esecuzione continui con il resto del codice senza dover attendere il risultato della prima operazione. Appena il risultato è pronto (per esempio: la richiesta HTTP ha restituito un JSON) sarà possibile riprendere tutte quelle istruzioni, che stavano in attesa, e che riguardavano l'elaborazione di questo risultato (per esempio: il JSON appena restituito può essere ora analizzato e stampato a video).

Tipici esempi di operazioni asincrone in JavaScript sono i metodi fetch() e setTimeout() che qui di seguito utilizzeremo per esemplificare del codice asincrono.

In un codice asincrono le istruzioni possono venire eseguite in ordine differente rispetto alla loro posizione

console.log(1); // Prima istruzione
setTimeout(()=> {
  console.log(2);  // Terza istruzione
}, 1000)
console.log(3); // Seconda istruzione

In questo esempio il risultato a schermo sarà: "1", "3", "2", in quanto l'engine JavaScript rende più efficiente l'esecuzione dell'intero programma evitando di attendere 1000 millisecondi (il valore 1000 all'interno del codice) ma proseguendo, invece, direttamente alla istruzione successiva, per poi riprendere l'esecuzione messa in attesa una volta passato il lasso di tempo definito.

Essenzialmente, si tratta solo di rendere più efficiente l'esecuzione dei nostri programmi e non rimanere in attesa di risorse che necessariamente avranno bisogno di un certo tempo prima di essere disponibile.

Cos'è una Promise in JavaScript?

In JavaScript, le Promise rappresentano un modo per gestire efficacemente le operazioni asincrone come quelle descritte in precedenza. Si tratta di oggetti che rappresentano delle operazioni ancora da completare, ma che (promettono!) termineranno in un prossimo futuro con due possibili risultati: Promise risolta o Promise rifiutata.

Creazione di una Promise

Sintassi di una Promise JS

Una Promise in JS si crea (come ogni oggetto JavaScript) attraverso il costruttore new Promise e una funzione che prevede due parametri: resolve e reject, che rappresentano i due possibili stati finali della Promise stessa. Proviamo a riscrivere l'esempio del setTimeout non più attraverso una callback, ma attraverso una Promise.

Una semplice Promise utilizzata al posto della callback

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true; // Può essere un risultato di un'operazione reale
    if (success) {
      resolve("Operazione completata con successo!"); //Passa il valore alla Promise
    } else {
      reject("Operazione fallita."); // Passa il motivo del fallimento
    }
  }, 1000);
});

L'oggetto Promise così creato sarà utilizzato secondo la seguente sintassi.

Utilizziamo la Promise appena creata

myPromise
  .then((result) => {
    console.log(result); // Output: "Operazione completata con successo!"
  })
  .catch((error) => {
    console.error(error); // Output: "Operazione fallita."
  })
  .finally(() => {
     console.log("Operazione conclusa."); // Questo codice eseguito in ogni caso
});

Analizziamo meglio il codice appena creato...

Stati di una Promise

Una Promise può trovarsi in uno dei seguenti stati.

  • Pending (in attesa): lo stato iniziale, l'operazione non è ancora completata
  • Fulfilled (completata): l'operazione è completata con successo e la Promise ha restituito un valore
  • Rejected (rifiutata): l'operazione è fallita e la Promise restituisce un motivo circa il fallimento

I metodi principali di una Promise

Per utilizzare il risultato di una Promise, si usano i metodi then, catch e finally.

  • then: viene chiamato quando la Promise è completata con successo
  • catch: viene chiamato quando la Promise è rifiutata
  • finally: viene chiamato indipendentemente dal risultato della Promise

Un esempio concreto di creazione Promise

Una Promise più concreta: fetch di risorse remote

// Funzione che restituisce una Promise per eseguire il fetch di risorse remote
function fetchRemoteResource(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }
        return response.json();
      })
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}
 
// Utilizzo della funzione fetchRemoteResource
const url = 'https://api.example.com/data';
 
fetchRemoteResource(url)
  .then(data => {
    console.log('Dati ricevuti:', data);
  })
  .catch(error => {
    console.error('Errore durante il fetch:', error);
  });
  .finally(() => {
     console.log("Operazione conclusa!");
  });

Composizione di Promise

Concatenazione di Promise (Promise chaining)

Le Promises possono essere concatenate per gestire sequenze di operazioni asincrone. Ogni then restituisce una nuova Promise, permettendo di concatenare più operazioni. Ecco un esempio di concatenazione di Promises.

Concatenazione di Promise: codice semplificato tratto da esempio in https://javascript.info/promise-chaining

fetch('/article/promise-chaining/user.json') //Ottengo i dati raw di un utente
  .then(response => response.json()) // I dati sono codificati in JSON
  .then(user => fetch(`https://api.github.com/users/${user.name}`)) // Utilizzo il nome utente per caricare ulteriori dati
  .then(response => response.json()) // Ulteriore codifica JSON
  .then(githubUser => { // Con i dati utente completi posso aggiornare il DOM della pagina (aggiunta di una immagine)
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);
  });

Promise combinators

E' possibile la gestione di più Promise contemporaneamente attraverso dei combinatori.

  • Promise.all: si attiva quando tutte le Promise hanno avuto esito fullfilled o almeno una di esse ha esito reject
  • Promise.race: si attiva appena una delle Promise ha avuto esito fulfilled o rejected
  • Promise.allSettled: si attiva quando tutte le promise hanno avuto esito o fulfilled o rejected
  • Promise.any: simile a Promise.race, ma si attiva appena una Promise ha avuto esito fulfilled (ma non rejected)

Darò un esempio di codice del primo caso solamente, per avere una idea di cosa significhi gestire molteplici Promise.

Promise.all - ricordiamo al lettore che fetch ritorna una Promise

const promises = [ // Le Promise da gestire sono raccolte in questo array
  fetch('https://api.example.com/data1'),
  fetch('https://api.example.com/data2'),     
  fetch('https://api.example.com/data3'),
] 
Promise.all(promises) // Quando tutte le Promise sono fulfilled o nell'eventualità di un esito rejected...
  .then(results => {  //... i risultati delle Promise sono raccolti nell'array "results" nell'ordine in cui le Promises sono state passate
    console.log('Dati ricevuti da API 1:', results[0]);
    console.log('Dati ricevuti da API 2:', results[1]);
    console.log('Dati ricevuti da API 3:', results[2]);
  })
  .catch(error => { // nell'eventualità di esito rejected di almento una delle Promise gestite si esegue il metodo catch
    console.error('Errore durante il fetch:', error);
  });

Promise.withResolvers() - metodo ancora sperimentale

Il metodo Promise.withResolvers() è un’API sperimentale di JavaScript che permette di creare una Promise già associata ai metodi resolve e reject. Questo consente di gestire più agevolmente la risoluzione o il rifiuto della Promise stessa senza la necessità di definire resolve e reject all'interno dell'esecuzione di una nuova Promise. La sintassi è semplice...

Promise.withResolvers() - sintassi

const { promise, resolve, reject } = Promise.withResolvers();

Qui un esempio concreto di utilizzo.

Promise.withResolvers() - esempio di utilizzo

function fetchData() {
  const { promise, resolve, reject } = Promise.withResolvers();
  // Simulazione di una richiesta asincrona
  setTimeout(() => {
    const success = Math.random() > 0.5;
    if (success) {
      resolve("Dati ricevuti con successo!");
    } else {
      reject("Errore nella ricezione dei dati.");
    }
  }, 1000);
  return promise;
}
 
fetchData()
  .then(data => console.log(data))
  .catch(error => console.error(error));

I vantaggi di tale metodo sono riassumibili nella maggiore chiarezza e leggibilità del codice prodotto. Ma, attenzione, essendo sperimentale, il metodo Promise.withResolvers() potrebbe non essere supportato in tutti gli ambienti e potrebbe cambiare in future versioni di JavaScript.

Migliorare la leggibilità delle Promise: uso di async/await

L'introduzione delle funzione async e await introducono una modalità differente di lavorare con le Promise e hanno lo scopo di semplificare la scrittura e la gestione delle operazioni asincrone, rendendo il codice risultante simile a quello sincrono.

async - Dichiarare una funzione asincrona

Per dichiarare una funzione asincrona, si utilizza la parola chiave async prima della definizione della funzione. Una funzione async restituisce sempre una Promise, anche se al suo interno il codice non usa esplicitamente la keyword Promise.

Definire di una funzione asincrona con async

async function esempioAsync() {
  return "Risultato";
}   
 
// L'esecuzione di esempioAsync() restituisce una Promise
esempioAsync().then(console.log); // Output: "Risultato"

await - Attendere il completamento di una Promise

La parola chiave await può essere usata solo all'interno di una funzione async. Blocca temporaneamente l'esecuzione del codice fino a che la Promise non si risolve, restituendo il valore risolto della Promise.

Attendere il completamento di una Promise attraverso la keyword await

async function esempioAwait() {
  const risultato = await new Promise((resolve) => {
    setTimeout(() => resolve("Completato!"), 2000);
});
console.log(risultato); // Output dopo 2 secondi: "Completato!"
}
 
esempioAwait();

Gestione degli errori nelle Promise JS

Abbiamo già visto negli esempi di codice come sia possibile (e, più che altro, obbligatorio) gestire eventuali errori e situazioni inattese attraverso i metodi catch() e finally(). Approfondiamo meglio l'argomento focalizzandoci su questo aspetto e affrontando la questione anche per quanto riguarda l'uso di async e await.

Gestione degli Errori con .catch()

Se una Promise viene rifiutata in uno dei passaggi della catena, catch() cattura l’errore e permette di gestirlo, impedendo che il programma si blocchi.

Catturare e gestire un errore con catch()

fetch("https://api.example.com/dati")
  .then((response) => {
    if (!response.ok) {
      throw new Error("Errore nella risposta"); // Genero un errore in caso di risposta negativa
    }
    return response.json();
  })
.then((data) => {
  console.log("Dati ricevuti:", data);
})
.catch((error) => {
  console.error("Errore:", error.message); // L'errore è catturato e gestito
})
.finally(() => {
  console.log("Richiesta completata."); // Questo codice viene eseguito in ogni caso
});

E' anche possibile gestire gli errori all'interno del metodo then()

Catturare e gestire un errore con catch()

fetch("https://api.example.com/dati")
  .then(
    (response) => response.json(),
    (error) => console.error("Errore:", error.message)
  );

Il metodo finally() permette di eseguire del codice al termine della Promise, indipendentemente dal fatto che essa sia stata risolta o rifiutata. Questo è utile per eseguire operazioni di pulizia (ad esempio chiudere una connessione o nascondere un loader).

Gestione degli errori in async/await con try-catch

Quando si usano async e await, la gestione degli errori diventa simile a quella del codice sincrono. Possiamo, allora, usare try-catch per catturare gli errori in blocchi di codice await. Ecco un esempio.

Gestire gli errori in async/await attraverso l'uso di try-catch

async function fetchDati() {
  try {
    const response = await fetch("https://api.example.com/dati");
    if (!response.ok) throw new Error("Errore nella risposta");
    const dati = await response.json();
    console.log("Dati ricevuti:", dati);
  } catch (error) {
    console.error("Errore:", error.message);
  } finally {
    console.log("Richiesta completata.");
  }
}
 
fetchDati();

Conclusione

Le Promise rappresentano uno dei fondamenti principali per la gestione asincrona del codice in JavaScript, migliorando significativamente la leggibilità e la gestione degli errori rispetto ai tradizionali callback. Con l'introduzione dei costrutti async/await, poi, il codice risultante risulta molto simile a quello asincrono e quindi semplice ed immediato da gestire.

In definitiva, le Promise costituiscono una pietra angolare per chi vuole padroneggiare JavaScript e sono un’abilità essenziale per chi sviluppa applicazioni avanzate e interattive.