JavaScript Promise: introduzione

Le promesse semplificano i calcoli differiti e asincroni. Una promessa rappresenta un'operazione non ancora 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 tal caso, non preoccuparti, ci sono voluti molti anni per capire perché dovrei preoccuparmi di queste cose. Probabilmente è meglio iniziare dall'inizio.
  • Sei forte! 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 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).

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 è gli starnuti, in cui tutta l'attività in corso deve essere sospesa 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 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, sto caricando 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 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. 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
});

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

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é può passare da esito positivo a esito negativo o viceversa.
  • 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 l'esito di successo/errore asincrono, perché non ti interessa molto il momento esatto in cui qualcosa è diventato disponibile, ma piuttosto reagire al risultato.

Terminologia di Promise

La prova Domenic Denicola ha letto la prima bozza di questo articolo e mi ha valutato come 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 è andata a buon fine
  • in sospeso - Non è stato ancora evaso o rifiutato
  • 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 vengono arrivate in JavaScript

Le promesse esistono da un po' di tempo sotto forma di librerie, 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 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, ad esempio in modo asincrono, quindi chiama resolve se è andato tutto bene, 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 siano una funzionalità di 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 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.

Tuttavia, come ho detto, i ritardi di jQuery sono un po'… poco utili. Fortunatamente puoi trasmetterli a promesse standard, il che vale la pena fare 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 differiti passano più argomenti ai loro 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) {
  // ...
})

Per fortuna di solito è quello che vuoi o quanto meno ti dà accesso a ciò che vuoi. Inoltre, tieni presente che jQuery non segue la convenzione di impostazione degli oggetti Error nei rifiuti.

Semplificazione del codice asincrono complesso

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

  1. Avvia una rotellina per indicare il caricamento
  2. Recupera JSON per una storia, che fornisce 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 rotazione della 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, vediamo come recuperare i 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.

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

Di fatto, 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, viene chiamato il successivo then() 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 facciamo una richiesta asincrona a story.json, che ci fornisce un insieme di URL da richiedere, dopodiché chiediamo il primo di questi. È 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'accettazione e il rifiuto, in termini di promise):

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. 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 try/catch di JavaScript. Gli errori che si verificano all'interno di un "try" passano immediatamente al blocco catch(). Il seguente è un 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.

Eccezione e promise di 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 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 callback di successo successivo, 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 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 eseguire questa operazione in modo asincrono, utilizziamo then() per far sì che le cose accadano 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 accediamo a Promise.resolve() e crei una promessa che si risolve a qualsiasi valore tu gli assegni. 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 si adempie con quel valore. Se la chiami senza valore, come sopra, viene soddisfatta con "undefined".

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

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 arriva il capitolo 1, dovremmo aggiungerlo alla pagina. In questo modo l'utente può iniziare a leggere prima dell'arrivo degli altri capitoli. 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 arrivano all'incirca nello stesso momento, ma il vantaggio di mostrarne uno alla volta sarà esagerato con un numero maggiore di 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, questa non è la fine del ciclo di vita delle promesse, che diventano ancora più semplici se combinate con altre funzionalità di ES6.

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 scoprire di più al riguardo nell'articolo sulle mie 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.