JavaScript Promise: introduzione

Le promesse semplificano i calcoli differiti e asincroni. Una promessa rappresenta un'operazione che non è ancora stata completata.

Jake Archibald
Jake Archibald

Sviluppatori, preparatevi a un momento cruciale nella storia dello sviluppo web.

[Inizia il rullo di tamburi]

Le promesse sono arrivate in JavaScript.

[Fireworks explode, glittery paper rains from above, the crowd goes wild]

A questo punto rientri in una di queste categorie:

  • La gente ti acclama, ma non sai cosa sta succedendo. Forse non sai nemmeno cosa sia una "promessa". Faresti spallucce, ma il peso della carta glitterata ti grava sulle spalle. In caso contrario, non preoccuparti, anche a me ci è voluto molto tempo per capire perché dovevo preoccuparmi di queste cose. Probabilmente è meglio iniziare dall'inizio.
  • Dai un pugno all'aria. Era ora, giusto? Hai già utilizzato queste cose di Promise, ma ti infastidisce il fatto che tutte le implementazioni abbiano un'API leggermente diversa. Qual è l'API per la versione ufficiale di JavaScript? Ti consigliamo di iniziare con la terminologia.
  • Lo sapevi già e fai beffe di chi si agita come se fosse una novità. Goditi un momento di superiorità, poi vai direttamente al riferimento all'API.

Supporto dei browser e polyfill

Supporto dei browser

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Origine

Per rendere conformi alle specifiche i browser che non dispongono di un'implementazione completa delle promesse o per aggiungere le promesse ad altri browser e a Node.js, consulta il polyfill (2000 KB compressi con gzip).

Che cosa succede?

JavaScript è a thread singolo, il che significa che due bit di script non possono essere eseguiti contemporaneamente, ma devono essere eseguiti uno dopo l'altro. Nei browser, JavaScript condivide un thread con una serie di altri elementi che variano da browser a browser. Tuttavia, in genere JavaScript si trova nella stessa coda di disegno, aggiornamento degli stili e gestione delle azioni utente (ad esempio evidenziazione del testo e interazione con i controlli dei moduli). L'attività in una di queste cose ritarda le altre.

Come essere umano, sei multithread. Puoi digitare con più dita, puoi guidare e tenere una conversazione contemporaneamente. L'unica funzione di blocco che dobbiamo gestire è lo starnuto, durante il quale tutte le attività in corso devono essere sospese per la durata dello starnuto. È piuttosto fastidioso, soprattutto quando guidi e cerchi di tenere una conversazione. Non scrivere codice che sia complicato.

Probabilmente hai utilizzato eventi e callback per aggirare il problema. Ecco gli eventi:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

Non è affatto male. Riceviamo l'immagine, aggiungiamo un paio di ascoltatori, quindi JavaScript può interrompere l'esecuzione finché non viene chiamato uno di questi ascoltatori.

Purtroppo, nell'esempio riportato sopra, è possibile che gli eventi si siano verificati prima che iniziassimo a cercarli, quindi dobbiamo aggirare il problema utilizzando la proprietà "complete" delle immagini:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

Questo non rileva le immagini che hanno generato errori prima che avessimo la possibilità di ascoltarle. Purtroppo il DOM non ci offre un modo per farlo. Inoltre, sto caricando un'immagine. Le cose si complicano ulteriormente se vogliamo sapere quando è stato caricato un insieme di immagini.

Gli eventi non sono sempre la soluzione migliore

Gli eventi sono ideali per le azioni che possono verificarsi più volte nello stesso oggetto, ad esempio keyup, touchstart e così via. Con questi eventi non ti interessa molto cosa è successo prima di collegare l'ascoltatore. Tuttavia, per quanto riguarda il risultato positivo/negativo asincrono, è preferibile qualcosa di simile a quanto segue:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

È ciò che fanno le promesse, ma con una denominazione migliore. Se gli elementi immagine HTML avessero un metodo "ready" che restituisse una promessa, potremmo fare quanto segue:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

Nella loro forma più semplice, le promesse sono un po' come i listener di eventi, tranne che:

  • Una promessa può avere esito positivo o negativo una sola volta. Non può avere esito positivo o negativo due volte, né può passare da esito positivo a esito negativo o viceversa.
  • Se una promessa è andata a buon fine o non è riuscita e in un secondo momento aggiungi un callback di successo/errore, verrà chiamato il callback corretto, anche se l'evento si è verificato in precedenza.

Questo è estremamente utile per l'esito di successo/errore asincrono, perché non ti interessa molto il momento esatto in cui qualcosa è diventato disponibile, ma ti interessa di più reagire al risultato.

Terminologia di Promise

Domenic Denicola ha letto la prima bozza di questo articolo e mi ha dato un voto "F" per la terminologia. Mi ha messo in punizione, mi ha costretto a copiare Stati e destini 100 volte e ha scritto una lettera preoccupata ai miei genitori. Nonostante ciò, mi confondo ancora molto con la terminologia, ma ecco le nozioni di base:

Una promessa può essere:

  • fulfilled: l'azione relativa alla promessa è riuscita
  • rejected: l'azione relativa alla promessa non è riuscita
  • pending: non è stata ancora soddisfatta o rifiutata
  • settled: ha soddisfatto o rifiutato

La specifica utilizza anche il termine thenable per descrivere un oggetto simile a una promessa, in quanto ha un metodo then. Questo termine mi ricorda l'ex allenatore della nazionale di calcio inglese Terry Venables, quindi lo userò il meno possibile.

Le promesse arrivano in JavaScript.

Le promesse esistono da un po' di tempo sotto forma di librerie, ad esempio:

Le promesse riportate sopra e quelle di JavaScript condividono un comportamento comune e standardizzato chiamato Promises/A+. Se sei un utente jQuery, hai a disposizione qualcosa di simile chiamato Deferreds. Tuttavia, i Deferred non sono conformi a Promise/A+, il che li rende leggermente diversi e meno utili, quindi fai attenzione. jQuery ha anche un tipo Promise, ma si tratta solo di un sottoinsieme di Deferred e presenta gli stessi problemi.

Sebbene le implementazioni delle promesse seguano un comportamento standardizzato, le API complessive sono diverse. Le promesse JavaScript sono simili nell'API a RSVP.js. Ecco come creare una promessa:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

Il costruttore della promessa accetta un argomento, un callback con due parametri, resolve e reject. Fai qualcosa all'interno del callback, magari in modo asincrono, quindi chiama resolve se è andato tutto a buon fine, altrimenti chiama reject.

Come per throw in JavaScript, è consuetudine, ma non obbligatorio, rifiutare con un oggetto Error. Il vantaggio degli oggetti Error è che acquisiscono una traccia dello stack, rendendo gli strumenti di debug più utili.

Ecco come utilizzare questa promessa:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() accetta due argomenti, un callback per un caso di successo e un altro per il caso di errore. Entrambi sono facoltativi, quindi puoi aggiungere un callback solo per il caso di esito positivo o negativo.

Le promesse JavaScript sono iniziate nel DOM come "Futures", sono state rinominate in "Promises" e finalmente sono passate a JavaScript. Averli in JavaScript anziché nel DOM è fantastico perché saranno disponibili in contesti JS non del browser come Node.js (è un'altra questione se li utilizzeranno nelle loro API di base).

Sebbene si tratti di una funzionalità JavaScript, il DOM non ha paura di utilizzarle. Infatti, tutte le nuove API DOM con metodi di successo/errore asincroni utilizzeranno le promesse. Questo sta già accadendo con Gestione delle quote, Eventi di caricamento dei caratteri, ServiceWorker, Web MIDI, Stream e altro ancora.

Compatibilità con altre librerie

L'API delle promesse JavaScript tratterà qualsiasi elemento con un metodo then() come simile a una promessa (o thenable in linguaggio delle promesse sigh), quindi se utilizzi una libreria che restituisce una promessa Q, non c'è problema, funzionerà bene con le nuove promesse JavaScript.

Tuttavia, come ho detto, i ritardi di jQuery sono un po'… poco utili. Fortunatamente puoi trasmetterli a promesse standard, operazione che vale la pena eseguire il prima possibile:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

In questo caso, $.ajax di jQuery restituisce un Deferred. Poiché ha un metodo then(), Promise.resolve() può trasformarlo in una promessa JavaScript. Tuttavia, a volte i ritardi passano più argomenti ai relativi callback, ad esempio:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

mentre le promesse JS ne ignorano tutte tranne la prima:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

Fortunatamente, in genere è quello che vuoi o almeno ti consente di accedere a ciò che vuoi. Inoltre, tieni presente che jQuery non segue la convenzione di trasmissione di oggetti Error ai rifiuti.

Codice asincrono complesso semplificato

Bene, scriviamo un po' di codice. Supponiamo di voler:

  1. Avvia una rotellina per indicare il caricamento
  2. Recuperare alcuni dati JSON per una storia, che ci forniscono il titolo e gli URL di ogni capitolo
  3. Aggiungi un titolo alla pagina
  4. Recupera ogni capitolo
  5. Aggiungere la storia alla pagina
  6. Interrompere la rotazione della rotellina

… ma anche di comunicare all'utente se si è verificato un problema. A questo punto dovremo anche interrompere la rotellina, altrimenti continuerà a girare, avrà le vertigini e entrerà in conflitto con un'altra UI.

Ovviamente, non utilizzeresti JavaScript per pubblicare una storia, poiché la pubblicazione in HTML è più rapida, ma questo pattern è abbastanza comune quando si tratta di API: recupero di più dati, poi fai qualcosa al termine dell'operazione.

Per iniziare, occupiamoci del recupero dei dati dalla rete:

Promisifying XMLHttpRequest

Le API precedenti verranno aggiornate per utilizzare le promesse, se possibile in modo compatibile con le versioni precedenti. XMLHttpRequest è un candidato ideale, ma nel frattempo scriviamo una semplice funzione per effettuare una richiesta GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

Ora usiamolo:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

Ora possiamo effettuare richieste HTTP senza digitare manualmente XMLHttpRequest, il che è fantastico, perché meno devo vedere l'irritante maiuscola di XMLHttpRequest, più felice sarà la mia vita.

Catena

then() non è la fine della storia, puoi concatenare then per trasformare i valori o eseguire altre azioni asincrone una dopo l'altra.

Trasformazione dei valori

Puoi trasformare i valori semplicemente restituendo il nuovo valore:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

Come esempio pratico, torniamo a:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

La risposta è in JSON, ma al momento la riceviamo come testo normale. Potremmo modificare la nostra funzione get per utilizzare il JSON responseType, ma potremmo anche risolvere il problema nel mondo delle promesse:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

Poiché JSON.parse() accetta un singolo argomento e restituisce un valore trasformato, possiamo creare una scorciatoia:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

In realtà, potremmo creare una funzione getJSON() molto facilmente:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() restituisce comunque una promessa, che recupera un URL e poi analizza la risposta come JSON.

Mettere in coda le azioni asincrone

Puoi anche concatenare i then per eseguire azioni asincrone in sequenza.

Quando restituisci qualcosa da una chiamata di callback then(), è un po' magico. Se restituisci un valore, il successivo then() viene chiamato con quel valore. Tuttavia, se restituisci qualcosa di simile a una promessa, il then() successivo lo attende e viene chiamato solo quando la promessa viene risolta (risultato positivo/negativo). Ad esempio:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

Qui viene inviata una richiesta asincrona a story.json, che fornisce un insieme di URL da richiedere, quindi viene richiesto il primo. È qui che le promesse iniziano davvero a distinguersi dai semplici pattern di callback.

Puoi anche creare un metodo di scorciatoia per ottenere i capitoli:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

Non scarichiamo story.json finché non viene chiamato getChapter, ma le volte successive in cui viene chiamato getChapter riutilizziamo la promessa della storia, quindi story.json viene recuperato una sola volta. Evviva Promises!

Gestione degli errori

Come abbiamo visto in precedenza, then() accetta due argomenti, uno per il successo e uno per l'errore (o per l'attuazione e il rifiuto, in termini di promesse):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

Puoi anche utilizzare catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

catch() non ha nulla di speciale, è solo un'alternativa a then(undefined, func), ma è più leggibile. Tieni presente che i due esempi di codice riportati sopra non si comportano allo stesso modo. L'ultimo è equivalente a:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

La differenza è sottile, ma estremamente utile. I rifiuti delle promesse passano вперед al then() successivo con un callback di rifiuto (o catch(), poiché è equivalente). Con then(func1, func2), verrà chiamato func1 o func2, mai entrambi. Tuttavia, con then(func1).catch(func2), entrambi verranno chiamati se func1 rifiuta, poiché sono passaggi distinti della catena. Prendi quanto segue:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

Il flusso riportato sopra è molto simile al normale try/catch di JavaScript. Gli errori che si verificano all'interno di un "try" passano immediatamente al blocco catch(). Ecco il quanto sopra come diagramma di flusso (perché adoro i diagrammi di flusso):

Segui le linee blu per le promesse soddisfatte o quelle rosse per quelle rifiutate.

Promise ed eccezioni JavaScript

I rifiuti si verificano quando una promessa viene rifiutata esplicitamente, ma anche implicitamente se viene generato un errore nel callback del costruttore:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Ciò significa che è utile eseguire tutte le operazioni relative alle promesse all'interno del callback del costruttore della promessa, in modo che gli errori vengano rilevati automaticamente e diventino rifiuti.

Lo stesso vale per gli errori generati nei callback then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Gestione degli errori nella pratica

Con la nostra storia e i nostri capitoli, possiamo utilizzare la funzione catch per mostrare un errore all'utente:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Se il recupero di story.chapterUrls[0] non va a buon fine (ad es. codice HTTP 500 o utente offline), viene saltato tutto il codice seguente dei callback di successo, incluso quello in getJSON() che tenta di analizzare la risposta come JSON, nonché il callback che aggiunge il capitolo 1.html alla pagina. Passa invece al callback catch. Di conseguenza, se una delle azioni precedenti non va a buon fine, alla pagina verrà aggiunto il messaggio "Impossibile mostrare il capitolo".

Come per try/catch di JavaScript, l'errore viene rilevato e il codice successivo prosegue, quindi la rotellina è sempre nascosta, che è ciò che vogliamo. Il codice sopra riportato diventa una versione asincrona non bloccante di:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

Ti consigliamo di eseguire catch() semplicemente per scopi di logging, senza recuperare dall'errore. Per farlo, è sufficiente lanciare di nuovo l'errore. Potremmo farlo nel metodo getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

Siamo riusciti a recuperare un capitolo, ma ne vogliamo tutti. Diamoci da fare.

Parallelismo e sequenziamento: il meglio di entrambi

Pensare in modo asincrono non è facile. Se hai difficoltà a risolvere il problema, prova a scrivere il codice come se fosse sincrono. In questo caso:

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

Va bene. Ma si sincronizza e blocca il browser durante il download. Per eseguire questa operazione in modo asincrono, utilizziamo then() per far sì che le cose avvengano una dopo l'altra.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Ma come possiamo eseguire un ciclo sugli URL dei capitoli e recuperarli in ordine? Non funziona:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach non è compatibile con l'aggiornamento asincrono, quindi i nostri capitoli verranno visualizzati nell'ordine in cui vengono scaricati, ovvero in pratica come è stato scritto Pulp Fiction. Non siamo in Pulp Fiction, quindi cerchiamo di risolvere il problema.

Creazione di una sequenza

Vogliamo trasformare l'array chapterUrls in una sequenza di promesse. Possiamo farlo utilizzando then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

È la prima volta che vediamo Promise.resolve(), che crea una promessa che si risolve in qualsiasi valore specificato. Se gli passi un'istanza di Promise, lo restituirà semplicemente (nota:si tratta di una modifica alle specifiche che alcune implementazioni non rispettano ancora). Se gli passi qualcosa di simile a una promessa (che ha un metodo then()), viene creato un Promise autentico che soddisfa/rifiuta nello stesso modo. Se passi un altro valore, ad es. Promise.resolve('Hello'), crea una promessa che viene soddisfatta con quel valore. Se la chiami senza un valore, come sopra, viene soddisfatta con "undefined".

Esiste anche Promise.reject(val), che crea una promessa rifiutata con il valore che le assegni (o undefined).

Possiamo riordinare il codice precedente utilizzando array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

L'operazione è la stessa dell'esempio precedente, ma non è necessaria la variabile "sequence" separata. Il nostro callback di riduzione viene chiamato per ogni elemento dell'array. "sequence" è Promise.resolve() la prima volta, ma per il resto delle chiamate "sequence" è il valore restituito dalla chiamata precedente. array.reduce è molto utile per ridurre un array a un singolo valore, che in questo caso è una promessa.

Riassumiamo:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Eccola qui, una versione completamente asincrona della versione sincronizzata. Ma possiamo fare meglio. Al momento la nostra pagina viene scaricata come segue:

I browser sono abbastanza bravi a scaricare più elementi contemporaneamente, quindi stiamo perdendo prestazioni scaricando i capitoli uno dopo l'altro. Vogliamo scaricarli tutti contemporaneamente ed elaborarli quando sono arrivati tutti. Fortunatamente esiste un'API per questo:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all prende un array di promesse e crea una promessa che si adempie quando tutte sono completate correttamente. Viene restituita un'array di risultati (qualsiasi sia il valore a cui sono state soddisfatte le promesse) nello stesso ordine delle promesse passate.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

A seconda della connessione, il caricamento può essere più veloce di alcuni secondi rispetto al caricamento uno alla volta e richiede meno codice rispetto al nostro primo tentativo. I capitoli possono essere scaricati in qualsiasi ordine, ma vengono visualizzati sullo schermo nell'ordine corretto.

Tuttavia, possiamo ancora migliorare il rendimento percepito. Quando arriverà il primo capitolo, dobbiamo aggiungerlo alla pagina. In questo modo l'utente può iniziare a leggere prima che il resto degli capitoli sia disponibile. Quando arriva il terzo capitolo, non lo aggiungeremo alla pagina perché l'utente potrebbe non rendersi conto che manca il secondo capitolo. Quando arriverà il secondo capitolo, potremo aggiungere i capitoli 2 e 3 e così via.

Per farlo, recuperiamo i dati JSON per tutti i capitoli contemporaneamente, quindi creiamo una sequenza per aggiungerli al documento:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Ecco fatto, il meglio di entrambi. Il caricamento di tutti i contenuti richiede lo stesso tempo, ma l'utente riceve prima il primo bit di contenuti.

In questo esempio banale, tutti i capitoli vengono pubblicati all'incirca nello stesso momento, ma il vantaggio di mostrarne uno alla volta sarà esagerato con più capitoli più grandi.

Se esegui la procedura precedente con eventi o callback in stile Node.js, il codice è circa il doppio, ma soprattutto non è così facile da seguire. Tuttavia, non è tutto. Se le combini con altre funzionalità di ES6, le promesse diventano ancora più semplici.

Tappa bonus: funzionalità ampliate

Da quando ho scritto questo articolo, la possibilità di utilizzare le promesse è molto migliorata. Da Chrome 55, le funzioni asincrone hanno consentito di scrivere codice basato su promesse come se fosse sincrono, ma senza bloccare il thread principale. Puoi approfondire l'argomento nel mio articolo sulle funzioni asincrone. Nei principali browser è supportato ampiamente sia le promesse sia le funzioni asincrone. Puoi trovare i dettagli nella documentazione di MDN relativa alle Promise e alle funzioni asincrone.

Un grazie speciale ad Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans e Yutaka Hirano che hanno corretto questo testo e fornito correzioni/consigli.

Grazie anche a Mathias Bynens per aver aggiornato varie parti dell'articolo.