JavaScript Promise: introduzione

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

Giacomo Archibald
Jake Archibald

Gli sviluppatori devono prepararsi a un momento cruciale nella storia dello sviluppo web.

[Inizia rullo di tamburi]

Le promesse sono arrivate in JavaScript.

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

A questo punto, rientri in una di queste categorie:

  • Le persone fanno il tifo per te, ma non sai bene cosa sia. Forse non sei nemmeno sicuro di cosa sia una "promessa". Al contrario, ti sorregge, ma il peso della carta scintillante pesa sulle tue spalle. Se è così, non preoccuparti, ci sono voluti tantissimi anni per capire perché dovrebbe interessarmi a queste cose. Probabilmente vorrai iniziare dall'inizio.
  • È un bel pugno di aria! È ora del momento, giusto? Hai già usato Promise in passato, ma ti preoccupa il fatto che tutte le implementazioni abbiano un'API leggermente diversa. Cos'è l'API per la versione JavaScript ufficiale? Probabilmente vorrai iniziare con la terminologia.
  • Lo sapevate già e avete preso di mira coloro che saltano come se fosse una notizia per loro. Approfitta della tua superiorità, poi vai direttamente alla documentazione di riferimento dell'API.

Supporto del browser e polyfill

Supporto dei browser

  • 32
  • 12
  • 29
  • 8

Fonte

Per portare i browser la cui mancanza di un'implementazione completa promette la conformità alle specifiche o aggiungere promesse ad altri browser e Node.js, controlla il polyfill (2k gzip).

Perché tutte queste preoccupazioni?

JavaScript è a thread unico, 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 differiscono da un browser all'altro. In genere JavaScript però si trova nella stessa coda delle attività di colorazione, 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 una di queste situazioni ritarda le altre.

Come essere umano, sei multi-thread. Puoi digitare con più dita e guidare e avviare una conversazione contemporaneamente. L'unica funzione di blocco che dobbiamo gestire è lo starnuto, in cui tutta l'attività corrente deve essere sospesa per la durata dello starnuto. È piuttosto fastidioso, soprattutto quando guidi e cerchi di intrattenere una conversazione. Non devi scrivere codice che non viene superato.

Probabilmente hai usato gli eventi e i callback per risolvere questo 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 fa affatto male. Otteniamo l'immagine, aggiungiamo un paio di listener, quindi JavaScript può interrompere l'esecuzione fino a quando 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à "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
});

Le immagini con errori non vengono rilevate prima che abbiamo la possibilità di ascoltarle; purtroppo il DOM non ci offre un modo per farlo. Inoltre, ciò sta 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 cose che possono verificarsi più volte sullo stesso oggetto: keyup, touchstart e così via. Con questi eventi non ti interessa davvero quello che è successo prima di collegare il listener. Ma per quanto riguarda il successo e il fallimento asincroni, l'ideale è ottenere qualcosa di simile:

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 "ready" che restituiva una promessa, potremmo farlo:

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

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

In sostanza, le promesse sono un po' come i listener di eventi, ad eccezione di:

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

Ciò è estremamente utile in caso di successo/errore asincroni, perché sei meno interessato nel momento esatto in cui qualcosa è disponibile e più interessato a reagire al risultato.

Terminologia delle promesse

La prova di Domenic Denicola ha letto la prima bozza di questo articolo e mi ha assegnato un voto "F" per la 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ò, la terminologia confonde moltissimo, ma ecco le nozioni di base:

Una promessa può essere:

  • fulfill: l'azione relativa alla promessa riuscita
  • rejected. L'azione relativa alla promessa non è andata a buon fine
  • in attesa: non è ancora stata completata o rifiutata
  • regolato - è stato completato 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 England Football Manager Terry Venables, quindi lo userò il meno possibile.

Le promesse arrivano in JavaScript.

Le biblioteche esistono da un po' di tempo sotto forma di biblioteche, tra cui:

Le promesse precedenti e JavaScript condividono un comportamento standardizzato comune denominato Promises/A+. Se sei un utente di jQuery, l'elemento qualcosa di simile è chiamato Deferred. Tuttavia, i contenuti differiti non sono conformi a Promise/A+, il che li rende sottilmente diversi e meno utili, quindi fai attenzione. jQuery ha anche un tipo Promise, ma si tratta semplicemente di un sottoinsieme di Defered e presenta gli stessi problemi.

Sebbene le implementazioni promesse seguano un comportamento standardizzato, le API nel complesso 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 prende un argomento, un callback con due parametri, risolvi e rifiuta. Esegui un'azione all'interno del callback, magari asincrono, quindi chiama risoluzione se tutto ha funzionato, altrimenti chiama rifiuto.

Come throw nel codice JavaScript precedente, è consuetudine, ma non obbligatorio, rifiutare con un oggetto Errore. Il vantaggio degli oggetti Errore è 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 di JavaScript sono iniziate nel DOM come "Futures", rinominate in "Promises" e infine spostate in JavaScript. Avere le estensioni in JavaScript anziché nel DOM è ottimo perché saranno disponibili in contesti JS non del browser come Node.js (se lo fanno nelle loro API di base è un'altra domanda).

Sebbene siano una funzione JavaScript, il DOM non ha paura di usarle. In effetti, tutte le nuove API DOM con metodi di successo/errore asincroni utilizzeranno le promesse. Questo avviene già con Gestione delle quote, Eventi di caricamento dei caratteri, ServiceWorker, Web MIDI, Stream e altro ancora.

Compatibilità con altre librerie

L'API JavaScript promette di trattare qualsiasi cosa con un metodo then() come promise-like (o thenable in promise-speak sigh), quindi se utilizzi una libreria che restituisce una promessa Q, non c'è problema con le nuove promesse JavaScript.

Anche se, come ho detto, la sezione Deferred di jQuery è un po'... inutile. Per fortuna puoi fare promesse standard, cosa che vale la pena fare il prima possibile:

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

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

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

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

Mentre JS promette di ignorare tutto tranne il primo:

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

Fortunatamente questo è quello che vuoi, o almeno ti dà accesso a ciò che vuoi. Inoltre, tieni presente che jQuery non segue la convenzione di passare gli oggetti Error nei rifiuti.

Semplificazione del codice asincrono complesso

Perfetto, sviluppiamo un po' di codice. Supponiamo di volere:

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

... ma anche dire all'utente se si è verificato un problema durante la procedura. Vogliamo anche fermare la rotellina in quel punto, altrimenti continuerà a girare, perdita delle storditure e si arresta in modo anomalo in altre UI.

Naturalmente, non utilizzerai JavaScript per pubblicare una storia, in quanto la pubblicazione come HTML è più veloce, ma questo pattern è piuttosto comune quando hai a che fare con le API: recupero di più dati, per poi eseguire un'azione al termine dell'operazione.

Per prima cosa, occupiamoci del recupero dei dati dalla rete:

Promessa di XMLHttpRequest

Le vecchie API verranno aggiornate in modo da utilizzare le promesse, se possibile in modo compatibile con le versioni precedenti. XMLHttpRequest è un candidato principale, ma nel frattempo scriviamo una semplice funzione 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 usiamo:

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 ho il fatto di vedere l'infuriata di XMLHttpRequest, più felice sarà la mia vita.

Concatenazione

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 formato JSON, ma al momento la riceviamo come testo normale. Potremmo modificare la nostra funzione get per utilizzare 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);
})

Infatti, 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 analizza la risposta come JSON.

Azioni asincrone in coda

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

Quando restituisci qualcosa da un callback di then(), è un po' magico. Se restituisci un valore, viene chiamato l'elemento then() successivo con quel valore. Tuttavia, se restituisci qualcosa di promettente, l'oggetto then() successivo non viene inviato e viene chiamato solo quando la promessa si accontenta (riuscito/non riuscito). 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, e poi chiediamo il primo di questi. È in questo caso che le promesse iniziano 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 alla chiamata di getChapter, ma per i prossimi i getChapter riutilizzeremo la promessa della storia, quindi il valore story.json viene recuperato una sola volta. Evviva, promesse!

Gestione degli errori

Come abbiamo visto prima, then() accetta due argomenti, uno per il successo e uno per fallimento (oppure 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(), c'è solo zucchero per then(undefined, func), ma è più leggibile. Tieni presente che i due esempi di codice riportati sopra non hanno lo stesso comportamento, 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. Se prometti il rifiuto, passa al then() successivo con un callback di rifiuto (o catch(), dato che è equivalente). Con then(func1, func2), verranno chiamati func1 o func2, mai entrambi. Tuttavia, con then(func1).catch(func2) verranno richiamati entrambi se func1 rifiuta, poiché si tratta di passaggi separati della catena. Prendi in considerazione 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 tentativo/catch di JavaScript; gli errori che si verificano all'interno di un "prova" vanno immediatamente al blocco catch(). Ecco il diagramma qui sopra (perché adoro i diagrammi di flusso):

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

Eccezioni e promesse JavaScript

Il rifiuto si verifica 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 svolgere tutto il lavoro relativo alle promesse all'interno del callback del costruttore Promise, 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 il metodo di rilevamento 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, inclusi quello in getJSON() che tenta di analizzare la risposta come JSON. Inoltre, verrà ignorato il callback che aggiunge il capitolo1.html alla pagina. Passa invece al callback di cattura. Di conseguenza, alla pagina verrà aggiunto il messaggio "Impossibile visualizzare il capitolo" se una delle azioni precedenti non è andata a buon fine.

Come nel caso di JavaScript/catch, l'errore viene rilevato e il codice successivo continua, quindi l'icona di rotellina rimane sempre nascosta, ovvero ciò che vogliamo. La tabella sopra 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'

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

Parallelismo e sequenza: come ottenere il meglio di entrambe

Pensare in modo asincrono non è facile. Se hai difficoltà a scrivere il codice, 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'

Funziona! Tuttavia, è sincronizzazione e blocca il browser durante il download. Per fare in modo che funzioni in modo asincrono, utilizziamo then() per eseguire le operazioni uno dopo l'altro.

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 facciamo a 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 nostri capitoli verrebbero visualizzati nell'ordine in cui vengono scaricati, che è fondamentalmente il modo in cui Pulp Narrativa è stata scritta. Non si tratta di Pulp Narrativa, 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 vediamo Promise.resolve(), il che crea una promessa che si risolve a qualsiasi valore tu gli assegni. Se passi un'istanza di Promise, la restituirà semplicemente (nota: questa è una modifica alla specifica che alcune implementazioni non sono ancora seguite). Se la proponi in modo promettente (ha un metodo then()), viene creato un Promise originale che evade/rifiuta nello stesso modo. Se trasmetti qualsiasi altro valore, ad es. Promise.resolve('Hello'), crea una promessa che realizza con tale valore. Se lo chiami senza valore, come sopra, soddisfa i termini "non definiti".

C'è anche Promise.reject(val), che crea una promessa che viene rifiutata con il valore assegnato (o non definito).

Possiamo mettere in ordine 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())

L'operazione è uguale all'esempio precedente, ma non è necessaria la variabile "Sequenza" separata. Viene richiesto il callback di riduzione per ogni elemento dell'array. "Sequenza" è Promise.resolve() la prima volta, ma per le altre chiamate "Sequenza" è ciò che abbiamo restituito dalla chiamata precedente. array.reduce è davvero utile per ridurre un array a un singolo valore, il che in questo caso è una promessa.

Ricapitoliamo:

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

Ed ecco una versione completamente asincrona della versione di sincronizzazione. Ma possiamo fare meglio. Al momento la nostra pagina viene scaricata in questo modo:

I browser sono piuttosto bravi a scaricare più contenuti contemporaneamente, quindi stiamo perdendo il rendimento scaricando i capitoli uno dopo l'altro. Vogliamo farlo tutti contemporaneamente, per poi elaborarli quando sono tutti disponibili. Per fortuna c'è un'API per questo:

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

Promise.all accetta una serie di promesse e crea una promessa che si realizza quando tutte vengono completate correttamente. Ottieni un array di risultati (indipendentemente dalle promesse) nello stesso ordine delle promesse fatte.

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, questa operazione può richiedere alcuni secondi in meno rispetto al caricamento singolarmente e meno codice rispetto al primo tentativo. Puoi scaricare i capitoli in qualsiasi ordine, ma sullo schermo vengono visualizzati nell'ordine giusto.

Tuttavia, possiamo ancora migliorare il rendimento percepito. Quando arriva il capitolo 1, dobbiamo aggiungerlo alla pagina. In questo modo l'utente può iniziare a leggere prima che gli altri capitoli siano arrivati. Quando arriva il 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 due e tre e così via.

Per farlo, recuperiamo contemporaneamente JSON per tutti i capitoli e 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';
})

Ed ecco il meglio di entrambe. La pubblicazione di tutti i contenuti richiede la stessa quantità di tempo, ma l'utente riceve la prima parte in meno tempo.

In questo esempio banale, tutti i capitoli arrivano più o meno nello stesso momento, ma il vantaggio di visualizzarli uno alla volta verrà esagerato con capitoli più grandi.

La procedura descritta in precedenza con gli eventi o callback di stile Node.js equivale al doppio del codice, ma soprattutto non è facile da seguire. Tuttavia, questa non è la fine delle promesse, perché insieme ad altre funzionalità di ES6 diventa ancora più semplice.

Round bonus: funzionalità ampliate

Da quando ho scritto in origine questo articolo, la possibilità di usare Promise è aumentata notevolmente. 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ù nel mio my async functions article. Nei principali browser esiste un supporto diffuso sia per le funzioni Promise che per le funzioni asincrone. Puoi trovare i dettagli nel riferimento a 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 questione e apportato correzioni/consigli.

Ringraziamo anche Mathias Bynens per l'aggiornamento di varie parti dell'articolo.