JavaScript Promise: introduzione

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

Jake Archibald
Jake Archibald

Preparati a un momento cruciale nella storia dello sviluppo web.

[Inizio del rullo di tamburi]

Le promesse sono arrivate in JavaScript!

[I fuochi d'artificio esplodono, la carta scintillante piove dall'alto, la folla impazzisce]

A questo punto, rientri in una di queste categorie:

  • Le persone fanno il tifo per te, ma non sai esattamente qual è la causa. Forse non sai neanche che cosa sia una "promessa". Faresti le spalle, ma il peso della carta glitterata ti pesa sulle spalle. In tal caso, non preoccuparti, ci sono voluti molti anni per capire perché dovrei preoccuparmi di queste cose. È consigliabile iniziare dall'inizio.
  • Sei forte! Era l'ora, giusto? Hai già usato queste funzionalità in passato, ma infastidisce il fatto che tutte le implementazioni abbiano un'API leggermente diversa. Che cos'è l'API per la versione JavaScript ufficiale? Probabilmente vorrai iniziare con la terminologia.
  • Lo sapevate già e prendete in giro coloro che balzano su e giù come se fossero novità per loro. Prenditi un momento per approfittare della tua superiorità, poi vai direttamente al riferimento API.

Supporto del browser e polyfill

Supporto dei browser

  • 32
  • 12
  • 29
  • 8

Origine

Per portare i browser che non hanno un'implementazione completa di promesse rispettando le specifiche o per aggiungere promesse ad altri browser e Node.js, consulta il polyfill (2000 gzip).

Qual è il problema?

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 un carico di altri elementi che variano da browser a browser. In genere, però, JavaScript si trova nella stessa coda delle operazioni di disegno, aggiornamento degli stili e gestione delle azioni degli utenti (ad esempio l'evidenziazione del testo e l'interazione con i controlli del modulo). L'attività in uno di questi elementi ritarda le altre.

In quanto essere umano, il tuo account è multithread. Puoi digitare con più dita e guidare e tenere una conversazione contemporaneamente. L'unica funzione di blocco che dobbiamo gestire è gli starnuti, in cui tutta l'attività in corso deve essere sospesa per la durata dello starnuto. È davvero fastidioso, soprattutto quando guidi e cerchi di intrattenere una conversazione. Non è il caso di scrivere codice inutile.

Probabilmente, avrai utilizzato gli eventi e i callback per aggirare questa limitazione. 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 subdolo. Otteniamo l'immagine, aggiungiamo un paio di listener, quindi JavaScript può interrompere l'esecuzione finché non viene chiamato uno di questi listener.

Purtroppo, nell'esempio precedente è possibile che gli eventi si siano verificati prima di iniziare ad ascoltarli, quindi dobbiamo risolvere il problema utilizzando la proprietà "completa" 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
});

In questo modo non vengono individuate immagini con errori prima di avere la possibilità di ascoltarle; purtroppo il DOM non ci consente di farlo. Inoltre, viene caricata un'immagine. Le cose diventano ancora più complesse se vogliamo sapere quando un insieme di immagini è stato caricato.

Gli eventi non sono sempre il modo migliore

Gli eventi sono ideali per eventi che possono verificarsi più volte sullo stesso oggetto, ad esempio keyup, touchstart e così via. Questi eventi non ti interessano molto a ciò che è accaduto prima di collegare il listener. Ma quando si tratta di successo o fallimento asincrono, idealmente dovresti ottenere qualcosa del genere:

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

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

Questo è ciò che fanno le promesse, ma con una denominazione migliore. Se gli elementi immagine HTML avessero un metodo "pronto" che restituisce una promessa, potremmo fare questo:

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

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

In linea di massima, le promesse sono un po' come gli ascoltatori di eventi, ad eccezione di:

  • Una promessa può avere successo o fallimento solo una volta. Non può avere esito positivo o negativo due volte, né passare da un'operazione riuscita all'altra.
  • Se una promessa ha avuto esito positivo o negativo e in seguito aggiungi un callback di operazione riuscita/non riuscito, verrà chiamato il callback corretto, anche se l'evento si è verificato prima.

Questo è estremamente utile per il successo o l'errore asincrono, perché ti interessa di meno il momento esatto in cui qualcosa è diventato disponibile e quello di reagire al risultato.

Terminologia delle promesse

La prova Domenic Denicola ha letto la prima bozza di questo articolo e mi ha valutato come terminologia. Mi ha arrestato, mi ha costretto a copiare 100 volte Stati e destini e ha scritto una lettera preoccupata ai miei genitori. Nonostante ciò, continuo a confondere gran parte della terminologia, ma ecco i concetti fondamentali:

Una promessa può essere:

  • completata: l'azione relativa alla promessa è stata completata
  • rejected: l'azione relativa alla promessa non è andata a buon fine
  • in sospeso - Non è stato ancora completato o rifiutato
  • regolamentato: è stato evaso o rifiutato

La specifica usa anche il termine thenable per descrivere un oggetto simile a una promessa, in quanto ha un metodo then. Questo termine mi ricorda l'ex Football Manager Terry Venables e quindi lo userò il meno possibile.

Le promesse vengono arrivate in JavaScript

È da un po' che esistono promesse che esistono sotto forma di biblioteche, ad esempio:

Le promesse precedenti e JavaScript condividono un comportamento comune e standardizzato denominato Promises/A+. Se sei un utente di jQuery, hanno un comportamento simile denominato Rinviati. Tuttavia, gli elementi differiti non sono conformi a Promise/A+, il che le rende in modo leggermente diverso e meno utili, quindi fai attenzione. jQuery ha anche un tipo Promise, ma si tratta solo di un sottoinsieme di Differito e presenta gli stessi problemi.

Sebbene le implementazioni promesse seguano un comportamento standardizzato, le API complessive sono diverse. Le promesse relative a JavaScript sono simili nell'API a quelle in 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 prende un argomento, un callback con due parametri, risolvi e rifiuta. Esegui un'azione all'interno del callback, magari asincrona, quindi chiama "Risoluzione" se tutto ha funzionato, altrimenti chiama "Rifiuta".

Come throw nel vecchio codice JavaScript, è consuetudine, ma non obbligatoria, rifiutare con un oggetto Error. Il vantaggio degli oggetti Error è che acquisiscono un'analisi dello stack, rendendo più utili gli strumenti di debug.

Ecco come puoi usare 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 di JavaScript sono iniziate nel DOM con il nome "Futures", rinominate "Promises" e infine sono passate a JavaScript. Averli in JavaScript piuttosto che nel DOM è ottimo perché saranno disponibili in contesti JS non browser come Node.js (un'altra domanda è se utilizzarli nelle loro API principali).

Nonostante siano una funzionalità JavaScript, il DOM non ha paura di usarle. Di fatto, tutte le nuove API DOM con metodi asincroni di esito positivo o negativo utilizzeranno le promesse. Ciò avviene già con la gestione delle quote, gli eventi di caricamento dei caratteri, ServiceWorker, Web MIDI, Stream e altro ancora.

Compatibilità con altre librerie

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

Anche se, come ho detto, i differiti di jQuery sono un po'... inutili. Per fortuna, puoi trasmetterle alle promesse standard, cosa che vale la pena fare il prima possibile:

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

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

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

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

JS promette di ignorare tutto tranne il primo:

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

Per fortuna di solito è quello che vuoi o almeno ti dà accesso a ciò che vuoi. Inoltre, tieni presente che jQuery non segue la convenzione per il passaggio di oggetti Error nei rifiuti.

Semplificazione del codice asincrono complesso

Esatto, proviamo a programmare alcune cose. Supponiamo di voler:

  1. Avvia una rotellina per indicare il caricamento
  2. Recupera il codice JSON di una storia, che fornisce il titolo e gli URL di ogni capitolo
  3. Aggiungi il titolo alla pagina
  4. Recupera ogni capitolo
  5. Aggiungi la notizia alla pagina
  6. Arresta la rotellina

... ma segnala anche all'utente se si è verificato un problema durante la procedura. A quel punto dovremo interrompere anche la rotellina, altrimenti continuerà a girare, subirà le vertigini e si arresterà in un'altra UI.

Naturalmente, non useresti JavaScript per pubblicare una storia, la pubblicazione come HTML è più veloce, ma questo pattern è piuttosto comune quando si ha a che fare con le API: vengono recuperati più dati, quindi si esegue un'operazione quando è tutto pronto.

Per iniziare, vediamo come recuperare i dati dalla rete:

Promessa di XMLHttpRequest

Le API precedenti verranno aggiornate in modo da utilizzare le promesse, se possibile in modo compatibile con le versioni precedenti. XMLHttpRequest è un candidato primario, ma nel frattempo scriviamo una funzione semplice per creare 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 è ottimo, perché a meno che non veda l'infuriante involucro di cammello di XMLHttpRequest, più sarà felice la mia vita.

Concatenamento

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

Trasformare i 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 formato JSON, ma al momento la stiamo ricevendo in testo normale. Potremmo modificare la nostra funzione get in modo da utilizzare il formato JSON responseType, ma potremmo anche risolverlo nelle promesse:

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

Poiché JSON.parse() prende 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);
})

Di fatto, potremmo creare una funzione getJSON() molto facilmente:

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

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

Coda delle azioni asincrone

Puoi anche concatenare then per eseguire azioni asincrone in sequenza.

Quando restituisci qualcosa da un callback then(), è un po' magico. Se restituisci un valore, viene chiamato il successivo then() con quel valore. Tuttavia, se restituisci qualcosa di simile, il successivo then() attende la promessa e viene chiamata solo quando la promessa si risolve (riuscita/non riuscita). Ad esempio:

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

Qui facciamo una richiesta asincrona a story.json, che ci fornisce un insieme di URL da richiedere, dopodiché chiediamo il primo di questi. È in questi casi che le promesse iniziano davvero a distinguersi dai semplici pattern di callback.

Puoi anche creare una scorciatoia per visualizzare 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 fino a quando non viene chiamato getChapter, ma la prossima volta che viene chiamato getChapter riutilizzeremo la promessa per la storia, quindi story.json viene recuperato una sola volta. Evviva le promesse!

Gestione degli errori

Come abbiamo visto in precedenza, then() accetta due argomenti, uno per il successo e uno per l'errore (o per soddisfare e rifiutare, in parole 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);
})

Non c'è niente di speciale in catch(), è solo zucchero per then(undefined, func), ma è più leggibile. Tieni presente che i due esempi di codice sopra riportati non si comportano nello stesso modo, il secondo equivale 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. Le promesse di rifiuto vanno avanti al then() successivo con un callback di rifiuto (o catch(), poiché è equivalente). Con then(func1, func2), func1 o func2 verranno chiamati, mai entrambi. Con then(func1).catch(func2), però, entrambi verranno chiamati se func1 rifiuta, poiché si tratta di passaggi separati nella catena. Procedi nel seguente modo:

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 test/catch di JavaScript; gli errori che si verificano in un "try" vengono inviati immediatamente al blocco catch(). Ecco quello qui sopra sotto forma di diagramma di flusso (perché adoro i diagrammi di flusso):

Segui le linee blu per le promesse che vengono rispettate o quelle rosse per quelle che rifiutano.

Eccezioni e promesse relative a 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 tutto il lavoro relativo alle promesse all'interno del callback del costruttore della promessa, in modo che gli errori vengano rilevati automaticamente e diventano rifiutati.

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 storia e i capitoli, possiamo usare la funzionalità Cattura 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 esempio, http 500 o l'utente è offline), verranno ignorati tutti i callback riusciti seguenti, incluso quello in getJSON(), che tenta di analizzare la risposta come JSON, e il callback che aggiunge capitolo1.html alla pagina. Si passa invece al callback di recupero. Di conseguenza, se una qualsiasi delle azioni precedenti non è riuscita, alla pagina verrà aggiunto il messaggio "Impossibile mostrare il capitolo".

Come nel caso di trial/catch di JavaScript, l'errore viene rilevato e il codice successivo continua, quindi la rotellina è sempre nascosta, come vogliamo. La precedente versione diventa una versione asincrona che non blocca i seguenti elementi:

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 catch() semplicemente per scopi di logging, senza recuperare dall'errore. Per farlo, è sufficiente restituire l'errore. Potremmo farlo con il 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 li vogliamo tutti. Diamoci da fare.

Parallelismo e sequenziamento: ottenere il meglio da entrambi

Pensare in modo asincrono non è facile. Se non riesci a raggiungerlo, 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! Trasmette invece la sincronizzazione e blocca il browser durante il download. Per rendere il funzionamento asincrono utilizziamo then() in modo che le cose si verifichino 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 scorrere gli URL dei capitoli e recuperarli in ordine? Questa operazione 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 è asincrono, quindi i capitoli appaiono nell'ordine in cui vengono scaricati, che è sostanzialmente come è stato scritto PulpFitt. Non è PulpFiction, quindi risolviamo il problema.

Creazione di una sequenza

Vogliamo trasformare il nostro 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 accediamo a Promise.resolve() e crei una promessa che si risolve a qualsiasi valore tu gli assegni. Se passi un'istanza di Promise, la restituirà semplicemente (nota: si tratta di una modifica alla specifica a cui alcune implementazioni non sono ancora state applicate). Se passi qualcosa di simile a una promessa (ha un metodo then()), crea un Promise originale che adempie/rifiuta nello stesso modo. Se passi qualsiasi altro valore, ad es. Promise.resolve('Hello'), crea una promessa che viene soddisfatta con quel valore. Se la chiami senza valore, come sopra, viene completata con "undefined".

C'è anche Promise.reject(val), che crea una promessa che rifiuta con il valore da te assegnato (o indefinito).

Possiamo sistemare il codice riportato sopra 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())

Questa operazione funziona come l'esempio precedente, ma non ha bisogno della variabile "Sequenza" separata. Il nostro callback di riduzione viene chiamato per ogni elemento nell'array. "sequenza" è Promise.resolve() la prima volta, ma per le altre chiamate "sequenza" è quella che abbiamo restituito dalla chiamata precedente. array.reduce è davvero utile per ridurre un array a un singolo valore, che in questo caso è una promessa.

Riepiloghiamo il tutto:

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, una versione completamente asincrona della versione di sincronizzazione. Ma possiamo fare di meglio. Al momento la nostra pagina viene scaricata nel seguente modo:

I browser sono abbastanza bravi a scaricare più contenuti contemporaneamente, quindi perdiamo le prestazioni scaricando i capitoli uno dopo l'altro. Vogliamo scaricarli tutti contemporaneamente, per poi elaborarli quando sono tutti arrivati. Per fortuna esiste un'API per questa operazione:

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

Promise.all accetta una serie di promesse e crea una promessa che si completa quando tutte vengono completate correttamente. Ottieni una serie di risultati (a prescindere dalle promesse fatte) nello stesso ordine che hai inviato.

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';
})

In base alla connessione, il caricamento potrebbe richiedere secondi più velocemente rispetto al caricamento di una riga alla volta e l'operazione richiede meno codice rispetto al primo tentativo. I capitoli possono essere scaricati nell'ordine corretto, ma vengono visualizzati sullo schermo.

Tuttavia, possiamo comunque migliorare il rendimento percepito. Quando arriva il capitolo 1, dovremmo aggiungerlo alla pagina. In questo modo l'utente può iniziare a leggere prima dell'arrivo degli altri capitoli. Al momento del capitolo tre, non lo aggiungeremo alla pagina perché l'utente potrebbe non rendersi conto che manca il capitolo due. All'arrivo del secondo capitolo, possiamo aggiungere i capitoli 2, 3 e così via.

Per farlo, recuperiamo contemporaneamente JSON per tutti i capitoli, 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! Ci vuole la stessa quantità di tempo per pubblicare tutti i contenuti, ma l'utente riceve la prima porzione di contenuti prima.

In questo esempio banale, tutti i capitoli arrivano all'incirca contemporaneamente, ma il vantaggio di mostrarne uno alla volta sarà esagerato con un numero maggiore di capitoli più grandi.

Effettuare quanto riportato sopra con callback o eventi in stile Node.js equivale a circa il doppio del codice, ma soprattutto non è così facile da seguire. Tuttavia, questa non è la fine del ciclo di vita delle promesse, che diventano ancora più semplici se combinate con altre funzionalità di ES6.

Gara bonus: possibilità estese

Da quando ho scritto inizialmente questo articolo, la possibilità di usare Promises si è notevolmente ampliata. A partire 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 scoprire di più al riguardo nell'my async functions article. Nei principali browser è presente un ampio supporto sia per le funzionalità Promises sia per le funzioni asincrone. Puoi trovare i dettagli nel riferimento Promise e funzione asincrona di MDN.

Grazie mille ad Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans e Yutaka Hirano, che hanno corretto la bozza e apportato corrette/consigli.

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