Promessas de JavaScript: uma introdução

As promessas simplificam cálculos adiados e assíncronos. Uma promessa representa uma operação que ainda não foi concluída.

Jake Archibald
Jake Archibald

Desenvolvedores, preparem-se para um momento crucial na história do desenvolvimento da Web.

[Que rufem os tambores]

As promessas chegaram no JavaScript!

[Fogos de artifício explodem, papel brilhante chovendo de cima e a plateia vai à loucura]

Neste ponto, você se enquadra em uma destas categorias:

  • As pessoas comemoram à sua volta, mas você não sabe o motivo dessa confusão. Talvez você nem saiba o que é uma "promessa". Você daria os ombros, mas o peso do papel brilhante pesa sobre seus ombros. Se sim, não se preocupe, eu demorei uma eternidade para entender por que eu deveria me preocupar com essas coisas. É provável que você queira começar pelo início.
  • Você soca o ar! Já tava na hora, certo? Você já usou essas promessas antes, mas está incomodado porque todas as implementações têm uma API um pouco diferente. Qual é a API da versão oficial do JavaScript? É provável que você queira começar com a terminologia.
  • Você já sabia sobre isso e zomba daqueles que estão pulando para cima e para baixo como se fosse novidade para eles. Curta sua superioridade e vá direto para a Referência da API.

Compatibilidade com navegadores e polyfill

Compatibilidade com navegadores

  • 32
  • 12
  • 29
  • 8

Origem

Para que os navegadores que não têm uma implementação completa de promessas estejam em conformidade com as especificações ou para adicionar promessas a outros navegadores e ao Node.js, confira o polyfill (com 2 mil arquivos compactado com Gzip).

Por que todo esse estardalhaço?

O JavaScript usa uma linha de execução única, ou seja, duas partes do script não podem ser executadas ao mesmo tempo. Elas precisam ser executadas uma após a outra. Nos navegadores, o JavaScript compartilha uma linha de execução com muitas outras coisas que variam de navegador para navegador. Mas, normalmente, o JavaScript está na mesma fila das atividades de pintura, atualização de estilos e processamento de ações do usuário, como destaque de texto e interação com controles de formulários. Uma atividade em uma dessas coisas atrasa as outras.

Como ser humano, você usa várias linhas de execução. Você pode digitar com vários dedos, dirigir e conversar ao mesmo tempo. A única função de bloqueio com que temos que lidar é o espirro, em que toda a atividade atual precisa ser suspensa pela duração dele. Isso é muito irritante, especialmente quando você está dirigindo e tentando manter uma conversa. Você não quer escrever um código que espirre.

Você provavelmente já usou eventos e callbacks para contornar isso. Aqui estão os eventos:

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

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

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

Não há nenhum espirro aqui. Obtemos a imagem, adicionamos alguns listeners e o JavaScript pode interromper a execução até que um desses listeners seja chamado.

Infelizmente, no exemplo acima, é possível que os eventos tenham acontecido antes de começarmos a ouvi-los. Portanto, precisamos contornar isso usando a propriedade "complete" das imagens:

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

Isso não captura as imagens com erro antes de começarmos a ouvi-las. Infelizmente, o DOM não oferece uma maneira de fazer isso. Além disso, uma imagem é carregada. As coisas ficam ainda mais complexas quando queremos saber quando um conjunto de imagens foi carregado.

Eventos nem sempre são a melhor maneira

Os eventos são ótimos para coisas que podem acontecer várias vezes no mesmo objeto: keyup, touchstart etc. Com esses eventos, você não se importa com o que aconteceu antes de anexar o listener. Mas, quando se trata de sucesso/falha assíncronos, o ideal é algo assim:

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

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

Isso é o que as promessas fazem, mas com uma nomenclatura melhor. Se elementos de uma imagem HTML tivessem um método "ready" que retornasse uma promessa, poderíamos fazer isto:

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

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

Na essência, as promessas são um pouco parecidas com listeners de eventos, exceto:

  • Uma promessa só pode ter sucesso ou falhar uma vez. Ela não pode ter sucesso ou falhar duas vezes, nem alternar entre sucesso e falha ou vice-versa.
  • Se uma promessa for concluída ou falhar e você adicionar depois um callback de sucesso/falha, o callback correto será chamado, mesmo que o evento tenha ocorrido anteriormente.

Isso é extremamente útil para sucesso/falha assíncronos, porque você está menos interessado no horário exato que algo ficou disponível e mais interessado em reagir ao resultado.

Terminologia das promessas

Domenic Denicola leu o primeiro rascunho deste artigo e me classificou como "F" de terminologia. Ele me colocou em prisão, me obrigou a copiar Estados e destinos 100 vezes e escreveu uma carta preocupada para meus pais. Apesar disso, eu ainda me confundo com a terminologia, mas os conceitos básicos são:

Uma promessa pode ser:

  • atendido: a ação relacionada à promessa foi concluída.
  • rejected: a ação relacionada à promessa falhou
  • pending: ainda não foi atendido ou recusado
  • settled: a ação foi concluída ou rejeitada.

A especificação também usa o termo thenable para descrever um objeto semelhante a uma promessa, porque tem um método then. Esse termo me lembra do ex-treinador de futebol da Inglaterra, Terry Venables, então vou usá-lo o mínimo possível.

As promessas chegaram em JavaScript!

As promessas já existem há algum tempo na forma de bibliotecas, como:

As promessas acima e o JavaScript compartilham um comportamento comum e padronizado, chamado Promises/A+. Se você usa o jQuery, há algo semelhante chamado Deferreds (em inglês). No entanto, Deferreds não são compatíveis com Commit/A+, o que os torna sutilmente diferentes e menos úteis. Portanto, tenha cuidado. O jQuery também tem um tipo de promessa, mas esse é apenas um subconjunto de Deferreds e tem os mesmos problemas.

As implementações de promessas seguem um comportamento padronizado, mas as APIs são diferentes no geral. As promessas de JavaScript são semelhantes em API às RSVP.js. Veja como criar uma 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"));
  }
});

O construtor de promessas aceita um argumento, um callback com dois parâmetros, "resolver" e "rejeitar". Faça algo dentro do callback, talvez algo assíncrono, e chame “resolver” se tudo tiver funcionado bem. Caso contrário, chame “rejeitar”.

Assim como o throw no JavaScript simples, é comum rejeitar com um objeto Error, mas isso não é obrigatório. A vantagem dos objetos Error é que eles capturam um stack trace, tornando as ferramentas de depuração mais úteis.

Veja como usar essa promessa:

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

then() recebe dois argumentos: um callback para o caso de sucesso e outro para a falha. Ambos são opcionais. Portanto, você pode adicionar um callback apenas para o caso de êxito ou falha.

As promessas do JavaScript começaram no DOM como "Futures", foram renomeadas como "promessas" e, finalmente, movidas para o JavaScript. É ótimo tê-las em JavaScript, em vez de no DOM, porque elas estarão disponíveis em contextos JS fora do navegador, como Node.js, independentemente de serem usadas nas APIs principais.

Embora sejam um recurso do JavaScript, o DOM pode usá-los. Na verdade, todas as novas APIs do DOM com métodos de sucesso/falha usarão promessas. Isso já está acontecendo com Gerenciamento de cotas, Eventos de carregamento de fonte, ServiceWorker, Web MIDI, Streams e muito mais.

Compatibilidade com outras bibliotecas

A API de promessas do JavaScript vai tratar tudo que tiver um método then() como semelhante a uma promessa (ou thenable na linguagem de promessas suspiro). Portanto, se você usar uma biblioteca que retorna uma promessa do Q, não tem problema, ela vai funcionar bem com as novas promessas do JavaScript.

Embora, como já mencionei, os Deferreds do jQuery sejam um pouco... inúteis. Você pode usar cast para transformá-los em promessas padrão, o que é bom fazer o mais rápido possível:

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

Aqui, o $.ajax do jQuery retorna um Deferred. Como ele tem um método then(), o Promise.resolve() pode transformá-lo em uma promessa do JavaScript. No entanto, às vezes os adiados passam vários argumentos para os callbacks, por exemplo:

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

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

Já as promessas do JS ignoram tudo, exceto o primeiro:

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

Felizmente, é isso que você quer ou, pelo menos, dá acesso ao que quer. Além disso, esteja ciente de que o jQuery não segue a convenção de transmitir objetos Error nas rejeições.

Simplificação do código assíncrono complexo

Certo, vamos programar algumas coisas. Digamos que queremos:

  1. Iniciar um ícone de carregamento para indicar o carregamento
  2. Busque um JSON para uma história, que nos dê o título e os URLs para cada capítulo
  3. Adicionar título à página
  4. Busque cada capítulo
  5. Adicionar a história à página
  6. Parar o controle giratório

mas também informar ao usuário se algo deu errado durante o processo. Nesse caso, interrompa o ícone de carregamento. Caso contrário, ele continuará girando, ficará tonto e falhará em outra interface.

É claro que você não usaria JavaScript para entregar uma história. A veiculação como HTML é mais rápida. No entanto, esse padrão é muito comum ao lidar com APIs: várias buscas de dados e fazer algo quando tudo estiver pronto.

Para começar, vamos buscar dados da rede:

Fazer promessas de XMLHttpRequest

As APIs antigas serão atualizadas para usar promessas, se possível de maneira compatível com versões anteriores. XMLHttpRequest é a principal candidata, mas, enquanto isso, vamos escrever uma função simples para fazer uma solicitação 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();
  });
}

Agora vamos usá-la:

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

Agora, podemos fazer solicitações HTTP sem digitar XMLHttpRequest manualmente, o que é ótimo, porque quanto menos eu tiver para ver o irritante estilo CamelCase de XMLHttpRequest, mais feliz será minha vida.

Encadeamento

then() não é o fim da história. É possível encadear thens para transformar valores ou executar mais ações assíncronas uma após a outra.

Como transformar valores

Você pode transformar valores simplesmente retornando o novo valor:

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

Como um exemplo prático, vamos voltar para:

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

A resposta é JSON, mas estamos recebendo o arquivo no formato de texto simples. Poderíamos alterar nossa função get para usar o responseType JSON, mas também podemos resolvê-lo em promessas:

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

Como JSON.parse() usa um único argumento e retorna um valor transformado, podemos criar um atalho:

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

Na verdade, poderíamos fazer uma função getJSON() com muita facilidade:

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

getJSON() ainda retorna uma promessa, que busca um URL e analisa a resposta como JSON.

Como enfileirar ações assíncronas

Também é possível encadear thens para executar ações assíncronas em sequência.

Quando você retorna algo de um callback then(), ocorre algo mágico. Se você retornar um valor, a próxima then() será chamada com esse valor. No entanto, se você retornar algo semelhante a uma promessa, a próxima then() aguardará por ela e será chamada apenas quando essa promessa for resolvida (concluída/falha). Exemplo:

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

Aqui, fazemos uma solicitação assíncrona para story.json, que fornece um conjunto de URLs a serem solicitados. Em seguida, solicitamos o primeiro deles. É aí que as promessas começam a se destacar de simples padrões de callback.

Você pode até criar um método de atalho para acessar capítulos:

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

Não fazemos o download de story.json até que getChapter seja chamado, mas nas próximas vezes que getChapter for chamado, reutilizaremos a promessa da história. Portanto, story.json será buscado apenas uma vez. Viva às promessas!

Tratamento de erros

Como vimos anteriormente, then() usa dois argumentos, um para sucesso, outro para falha (ou atender e rejeitar, na terminologia de promessas):

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

Também é possível usar catch():

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

Não há nada especial sobre catch(), é apenas açúcar para then(undefined, func), mas é mais legível. Os dois exemplos de código acima não se comportam da mesma forma. O último é equivalente a:

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

A diferença é sutil, mas extremamente útil. As rejeições de promessa pulam para a próxima then() com um callback de rejeição (ou catch(), já que é equivalente). Com then(func1, func2), func1 ou func2 serão chamados, nunca ambos. No entanto, com then(func1).catch(func2), ambos serão chamados se func1 for rejeitado, já que são etapas separadas na cadeia. Faça o seguinte:

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

O fluxo acima é muito semelhante ao try/catch normal do JavaScript. Os erros que acontecem em um "try" vão imediatamente para o bloco catch(). Aqui está o fluxograma acima (porque adoro fluxogramas):

Siga as linhas azuis para promessas atendidas ou vermelhas para as que rejeitam.

Exceções e promessas de JavaScript

As rejeições acontecem quando uma promessa é rejeitada explicitamente, mas também implicitamente se um erro for gerado no callback do construtor:

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

Isso significa que é útil fazer todo o trabalho relacionado a promessas dentro do callback do construtor de promessas. Dessa forma, os erros são detectados automaticamente e se tornam rejeições.

O mesmo vale para erros gerados em callbacks 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);
})

Tratamento de erros na prática

Com a história e os capítulos, podemos usar "catch" para mostrar um erro ao usuário:

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 a busca de story.chapterUrls[0] falhar (por exemplo, http 500 ou o usuário está off-line), todos os callbacks de sucesso seguintes serão ignorados, incluindo o de getJSON(), que tenta analisar a resposta como JSON. Além disso, ele vai pular o callback que adiciona Chapter1.html na página. Em vez disso, ela é transferida para o callback catch. Como resultado, a mensagem "Failed to show Chapter" será adicionada à página se qualquer uma das ações anteriores falhar.

Assim como o try/catch do JavaScript, o erro é capturado e o código subsequente continua. Portanto, o ícone de carregamento fica sempre oculto, que é o que queremos. O código acima se torna uma versão assíncrona e não bloqueadora do código:

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'

O catch() pode ser necessário apenas para fins de geração de registros, sem recuperação do erro. Para fazer isso, gere o erro novamente. Podemos fazer isso em nosso método getJSON():

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

Conseguimos buscar um capítulo, mas queremos todos. Vamos fazer isso acontecer.

Paralelismo e sequência: como obter o melhor dos dois

Não é fácil pensar assíncrono. Se você está com dificuldades para começar, tente escrever o código como se fosse síncrono. Neste 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'

Funcionou! Mas ele é sincronizado e bloqueia o navegador durante o download. Para fazer isso funcionar de maneira assíncrona, usamos then() para fazer as coisas acontecerem uma após a outra.

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

Mas como podemos percorrer os URLs dos capítulos e buscá-los em ordem? Isso não funciona:

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

O forEach não reconhece assincronismo, então nossos capítulos apareceriam na ordem de download, que é basicamente a forma como Pulp Ficção foi escrita. Como não se trata de Pulp Ficção, vamos corrigir o problema.

Como criar uma sequência

Queremos transformar nossa matriz chapterUrls em uma sequência de promessas. Podemos fazer isso usando 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);
  });
})

Esta é a primeira vez que vemos Promise.resolve(), que cria uma promessa que é resolvida para qualquer valor informado. Se você transmitir uma instância de Promise, ela a retornará. Observação:essa é uma mudança na especificação que algumas implementações ainda não seguem. Se você transmitir algo semelhante a uma promessa (tem um método then()), ela cria um Promise genuíno que é atendido/rejeitado da mesma maneira. Se você passar qualquer outro valor, por exemplo, Promise.resolve('Hello'), ele cria uma promessa que será atendida com esse valor. Se você chamá-lo sem um valor, como acima, ele será atendido com "undefined".

Há também Promise.reject(val), que cria uma promessa que será rejeitada com o valor informado (ou indefinido).

Podemos organizar o código acima usando 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())

Ele faz o mesmo que o exemplo anterior, mas não precisa da variável separada "Sequence". Nosso callback de redução é chamado para cada item na matriz. "sequência" é Promise.resolve() na primeira vez. No entanto, para o restante das chamadas, "sequência" é o que retornamos da chamada anterior. array.reduce é muito útil para resumir uma matriz a um único valor, que nesse caso é uma promessa.

Vamos reunir tudo:

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

E aí está, uma versão totalmente assíncrona da versão síncrona. Mas podemos fazer melhor. No momento, o download da nossa página está assim:

Os navegadores são muito bons em fazer o download de vários itens ao mesmo tempo. Por isso, estamos perdendo desempenho fazendo o download de capítulos um após o outro. Queremos fazer o download de todos ao mesmo tempo e processá-los quando todos chegarem. Felizmente existe uma API para isso:

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

Promise.all recebe uma matriz de promessas e cria uma promessa que será atendida quando todas elas forem concluídas com sucesso. Você recebe uma matriz de resultados (seja quais forem as promessas atendidas) na mesma ordem que as promessas transmitidas.

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

Dependendo da conexão, isso pode ser alguns segundos mais rápido que o carregamento individual e o código pode ser menor do que na primeira tentativa. É possível fazer o download dos capítulos em qualquer ordem, mas eles aparecem na tela na ordem certa.

No entanto, ainda podemos melhorar o desempenho percebido. Quando o capítulo um chegar, devemos adicioná-lo à página. Isso permite que o usuário comece a ler antes da chegada dos demais capítulos. Quando o capítulo três chegar, não o adicionaremos à página porque o usuário pode não perceber que o capítulo dois está ausente. Quando o capítulo dois chegar, poderemos adicionar os capítulos dois, três etc.

Para isso, buscamos o JSON de todos os capítulos ao mesmo tempo e criamos uma sequência para adicioná-los ao 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';
})

E aí está, o melhor dos dois! O mesmo tempo será necessário para entregar todo o conteúdo, mas o usuário receberá a primeira parte antes.

Neste exemplo simples, todos os capítulos chegam aproximadamente ao mesmo tempo, mas a vantagem de exibir um por vez será exagerada com capítulos mais e maiores.

Para fazer isso com callbacks ou eventos no estilo do Node.js, é preciso ter o dobro de código, mas o mais importante não é tão fácil de acompanhar. No entanto, este não é o fim do processo de promessas. Quando combinadas com outros recursos do ES6, elas ficam ainda mais fáceis.

Rodada bônus: mais recursos

Desde que escrevi este artigo, a capacidade de usar promessas se expandiu bastante. Desde o Chrome 55, as funções assíncronas permitem que o código baseado em promessa seja escrito como se fosse síncrono, mas sem bloquear a linha de execução principal. Leia mais sobre isso no my async functions article. Há suporte geral para promessas e funções assíncronas nos principais navegadores. Veja os detalhes na referência de promessa e função assíncrona (em inglês) do MDN.

Muitos agradecimentos a Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans e Yutaka Hirano, que revisaram isso e fizeram correções e recomendações.

Agradecemos também a Mathias Bynens por atualizar várias partes do artigo.