Обещания JavaScript: введение

Промисы упрощают отложенные и асинхронные вычисления. Обещание представляет собой операцию, которая еще не завершена.

Разработчики, приготовьтесь к поворотному моменту в истории веб-разработки.

[Начинается барабанная дробь]

Обещания появились в JavaScript!

[Взрываются фейерверки, сверху падает блестящий бумажный дождь, толпа сходит с ума]

На данный момент вы попадаете в одну из этих категорий:

  • Люди вокруг вас аплодируют, но вы не понимаете, из-за чего весь этот шум. Возможно, вы даже не совсем понимаете, что такое «обещание». Вы бы пожали плечами, но вес блестящей бумаги давит на ваши плечи. Если да, то не беспокойтесь об этом, мне потребовались годы, чтобы понять, почему меня должны волновать эти вещи. Вероятно, вы захотите начать с самого начала .
  • Ты ударяешь воздух! Уже пора, да? Вы уже использовали эти обещания раньше, но вас беспокоит то, что все реализации имеют немного разные API. Какой API у официальной версии JavaScript? Вероятно, вы захотите начать с терминологии .
  • Вы уже знали об этом и издеваетесь над теми, кто скачет, как будто для них это новость. Найдите минутку, чтобы насладиться собственным превосходством, а затем сразу переходите к справочнику по API .

Поддержка браузеров и полифилл

Поддержка браузера

  • Хром: 32.
  • Край: 12.
  • Фаерфокс: 29.
  • Сафари: 8.

Источник

Чтобы привести браузеры, в которых отсутствует полная реализация обещаний, в соответствие со спецификациями или добавить обещания в другие браузеры и Node.js, воспользуйтесь полифиллом (2 КБ в сжатом виде).

О чем весь этот шум?

JavaScript является однопоточным, что означает, что два фрагмента сценария не могут выполняться одновременно; им приходится бежать один за другим. В браузерах JavaScript разделяет поток с множеством других вещей, которые различаются от браузера к браузеру. Но обычно JavaScript находится в той же очереди, что и рисование, обновление стилей и обработка действий пользователя (таких как выделение текста и взаимодействие с элементами управления формой). Деятельность в одном из этих дел задерживает другие.

Как человек, вы многопоточны. Вы можете печатать несколькими пальцами, управлять автомобилем и вести разговор одновременно. Единственная функция блокировки, с которой нам приходится иметь дело, — это чихание, при котором вся текущая активность должна быть приостановлена ​​на время чихания. Это довольно раздражает, особенно когда вы за рулем и пытаетесь поддержать разговор. Вы не хотите писать грязный код.

Вы, вероятно, использовали события и обратные вызовы, чтобы обойти эту проблему. Вот события:

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

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

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

Это совсем не смешно. Мы получаем изображение, добавляем пару прослушивателей, после чего JavaScript может прекратить выполнение до тех пор, пока не будет вызван один из этих прослушивателей.

К сожалению, в приведенном выше примере события могли произойти до того, как мы начали их прослушивать, поэтому нам нужно обойти это, используя свойство изображений «complete»:

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

Это не отлавливает изображения, которые содержали ошибки до того, как мы получили возможность их прослушать; к сожалению, DOM не дает нам возможности сделать это. Кроме того, это загрузка одного изображения. Все становится еще сложнее, если мы хотим знать, когда загрузился набор изображений.

События не всегда лучший способ

События отлично подходят для вещей, которые могут происходить несколько раз с одним и тем же объектом — keyup , touchstart и т. д. С этими событиями вас не особо заботит то, что произошло до того, как вы подключили прослушиватель. Но когда дело доходит до асинхронного успеха/неудачи, в идеале вам нужно что-то вроде этого:

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

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

Это то, что делают обещания, но с лучшим именованием. Если бы элементы HTML-изображения имели «готовый» метод, возвращающий обещание, мы могли бы сделать это:

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

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

По своей сути, промисы немного похожи на прослушиватели событий, за исключением:

  • Обещание может быть выполнено или не выполнено только один раз. Он не может добиться успеха или потерпеть неудачу дважды, а также не может перейти от успеха к провалу или наоборот.
  • Если обещание выполнено успешно или неудачно, и вы позже добавите обратный вызов для успешного или неудачного выполнения, будет вызван правильный обратный вызов, даже если событие произошло ранее.

Это чрезвычайно полезно для успеха/неудачи асинхронности, поскольку вас меньше интересует точное время, когда что-то стало доступным, и больше интересует реакция на результат.

Терминология обещаний

Доменик Деникола за доказательство прочитал первый вариант этой статьи и поставил мне оценку «F» за терминологию. Он посадил меня под стражу, заставил 100 раз переписать «Состояния и судьбы» и написал обеспокоенное письмо моим родителям. Несмотря на это, я все еще путаю много терминологии, но вот основы:

Обещание может быть:

  • выполнено — действие, связанное с обещанием, выполнено успешно.
  • отклонено — действие, связанное с обещанием, не выполнено.
  • в ожидании - еще не выполнено или отклонено
  • урегулировано - Выполнил или отклонил

В спецификации также используется термин thenable для описания объекта, похожего на обещание, поскольку у него есть метод then . Этот термин напоминает мне бывшего футбольного менеджера сборной Англии Терри Венейблса , поэтому я буду использовать его как можно реже.

Обещания приходят в JavaScript!

Промисы уже некоторое время существуют в виде библиотек, таких как:

Вышеупомянутые обещания и обещания JavaScript имеют общее стандартизированное поведение, называемое Promises/A+ . Если вы пользователь jQuery, у них есть что-то похожее под названием Deferreds . Однако отложенные действия не соответствуют требованиям Promise/A+, что делает их немного разными и менее полезными , поэтому будьте осторожны. В jQuery также есть тип Promise , но это всего лишь подмножество Deferred и имеет те же проблемы.

Хотя реализации обещаний следуют стандартизированному поведению, их API-интерфейсы в целом различаются. Обещания JavaScript по API аналогичны RSVP.js. Вот как вы создаете обещание:

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

Конструктор обещания принимает один аргумент — обратный вызов с двумя параметрами: разрешить и отклонить. Сделайте что-нибудь в обратном вызове, например асинхронное, затем вызовитеsolve, если все сработало, в противном случае вызовите ignore.

Как throw в обычном старом JavaScript, принято, но не обязательно, отклонять объект Error. Преимущество объектов Error заключается в том, что они фиксируют трассировку стека, что делает инструменты отладки более полезными.

Вот как вы можете использовать это обещание:

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

then() принимает два аргумента: обратный вызов для случая успеха и еще один для случая неудачи. Оба варианта являются необязательными, поэтому вы можете добавить обратный вызов только в случае успеха или неудачи.

Обещания JavaScript начинались в DOM как «Futures», затем были переименованы в «Promises» и, наконец, перешли в JavaScript. Иметь их в JavaScript, а не в DOM, это здорово, потому что они будут доступны в небраузерных JS-контекстах, таких как Node.js (используют ли они их в своих основных API — это другой вопрос).

Хотя это функция JavaScript, DOM не боится их использовать. Фактически, все новые DOM API с асинхронными методами успеха/неуспеха будут использовать обещания. Это уже происходит с Quota Management , Font Load Events , ServiceWorker , Web MIDI , Streams и многим другим.

Совместимость с другими библиотеками

API обещаний JavaScript будет обрабатывать все, что имеет метод then() , как обещание (или thenable , как обещание-говорите вздохом ), поэтому, если вы используете библиотеку, которая возвращает обещание Q, это нормально, она будет хорошо работать с новым JavaScript обещает.

Хотя, как я уже говорил, отложенные операции jQuery немного… бесполезны. К счастью, вы можете преобразовать их в стандартные обещания, что стоит сделать как можно скорее:

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

Здесь $.ajax jQuery возвращает Deferred. Поскольку Promise.resolve() имеет метод then() , он может превратить его в обещание JavaScript. Однако иногда отложенные вызовы передают в свои обратные вызовы несколько аргументов, например:

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

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

Тогда как обещания JS игнорируют все, кроме первого:

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

К счастью, обычно это то, что вам нужно, или, по крайней мере, дает вам доступ к тому, что вы хотите. Кроме того, имейте в виду, что jQuery не следует соглашению о передаче объектов Error в отклонения.

Сложный асинхронный код стал проще

Хорошо, давайте закодируем некоторые вещи. Скажем, мы хотим:

  1. Запустите счетчик, чтобы указать на загрузку
  2. Получите JSON для истории, которая даст нам название и URL-адреса для каждой главы.
  3. Добавьте заголовок на страницу
  4. Получить каждую главу
  5. Добавьте историю на страницу
  6. Останови спиннер

… но также сообщать пользователю, если что-то пошло не так. Мы также захотим остановить спиннер в этот момент, иначе он продолжит вращаться, закружится и врежется в какой-нибудь другой пользовательский интерфейс.

Конечно, вы не будете использовать JavaScript для доставки истории, поскольку HTML работает быстрее , но этот шаблон довольно распространен при работе с API: множественная выборка данных, а затем что-то сделать, когда все будет готово.

Для начала разберемся с получением данных из сети:

Многообещающий XMLHttpRequest

Старые API будут обновлены для использования промисов, если это возможно, с обратной совместимостью. XMLHttpRequest — главный кандидат, но давайте пока напишем простую функцию для выполнения 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();
  });
}

Теперь давайте воспользуемся этим:

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

Теперь мы можем отправлять HTTP-запросы без ручного ввода XMLHttpRequest , и это здорово, потому что чем меньше мне придется видеть раздражающее верблюжье оформление XMLHttpRequest , тем счастливее будет моя жизнь.

Цепочка

then() — это еще не конец истории, вы можете объединить then для преобразования значений или выполнения дополнительных асинхронных действий одно за другим.

Преобразование ценностей

Вы можете преобразовать значения, просто вернув новое значение:

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

В качестве практического примера вернемся к:

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

Ответ в формате JSON, но в настоящее время мы получаем его в виде обычного текста. Мы могли бы изменить нашу функцию get, чтобы она использовала JSON responseType , но мы также могли бы решить эту проблему в стране обещаний:

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

Поскольку JSON.parse() принимает один аргумент и возвращает преобразованное значение, мы можем сделать сокращение:

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

На самом деле, мы могли бы очень легко создать функцию getJSON() :

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

getJSON() по-прежнему возвращает обещание, которое извлекает URL-адрес, а затем анализирует ответ как JSON.

Очередь асинхронных действий

Вы также можете связать then s для последовательного выполнения асинхронных действий.

Когда вы возвращаете что-то из обратного вызова then() , это немного волшебно. Если вы возвращаете значение, следующая then() вызывается с этим значением. Однако, если вы возвращаете что-то похожее на обещание, следующая then() ожидает этого и вызывается только тогда, когда это обещание выполнено (успешно/неуспешно). Например:

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

Здесь мы делаем асинхронный запрос к story.json , который дает нам набор URL-адресов для запроса, затем мы запрашиваем первый из них. Именно тогда обещания действительно начинают выделяться из простых шаблонов обратного вызова.

Вы даже можете создать ярлык для получения глав:

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

Мы не загружаем story.json до тех пор, пока не будет вызван getChapter , но при следующем вызове getChapter мы повторно используем обещание истории, поэтому story.json извлекается только один раз. Ура, обещания!

Обработка ошибок

Как мы видели ранее, then() принимает два аргумента: один для успеха, другой для неудачи (или выполнения и отклонения, если говорить об обещаниях):

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

Вы также можете использовать catch() :

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

В catch() нет ничего особенного, это просто сахар для then(undefined, func) , но он более читабелен. Обратите внимание, что два приведенных выше примера кода ведут себя по-разному, последний эквивалентен:

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

Разница тонкая, но чрезвычайно полезная. Отказы от обещаний переходят к следующему then() с обратным вызовом отклонения (или catch() , поскольку это эквивалентно). С then(func1, func2) будет вызываться func1 или func2 , а не обе. Но с then(func1).catch(func2) оба будут вызваны, если func1 отклонит, поскольку это отдельные шаги в цепочке. Возьмите следующее:

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

Вышеописанный процесс очень похож на обычный метод try/catch в JavaScript: ошибки, возникающие в рамках «try», немедленно передаются в блок catch() . Вот блок-схема вышеизложенного (потому что я люблю блок-схемы):

Следуйте синим линиям для обещаний, которые выполняются, или красным для тех, которые отвергаются.

Исключения и обещания JavaScript

Отказы происходят, когда обещание отклонено явно, а также неявно, если в обратном вызове конструктора выдается ошибка:

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

Это означает, что полезно выполнять всю работу, связанную с обещаниями, внутри обратного вызова конструктора обещаний, поэтому ошибки автоматически обнаруживаются и становятся отклоненными.

То же самое касается ошибок, возникающих в обратных вызовах 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);
})

Обработка ошибок на практике

С нашей историей и главами мы можем использовать catch, чтобы отобразить пользователю ошибку:

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

Если получение story.chapterUrls[0] завершается неудачей (например, http 500 или пользователь не в сети), он пропустит все последующие обратные вызовы успешного завершения, включая тот, который в getJSON() пытается проанализировать ответ как JSON, а также пропускает обратный вызов, который добавляет на страницу Chapter1.html. Вместо этого он переходит к обратному вызову catch. В результате на страницу будет добавлено сообщение «Не удалось показать главу», если какое-либо из предыдущих действий не удалось.

Как и в случае с try/catch в JavaScript, ошибка перехватывается и последующий код продолжается, поэтому счетчик всегда скрыт, чего мы и хотим. Вышеприведенное становится неблокирующей асинхронной версией:

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'

Вы можете захотеть использовать catch() просто для целей регистрации, без восстановления после ошибки. Для этого достаточно повторно выдать ошибку. Мы могли бы сделать это в нашем методе getJSON() :

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

Итак, нам удалось получить одну главу, но нам нужны все. Давайте сделаем это возможным.

Параллелизм и последовательность: лучшее от обоих

Думать об асинхронности непросто. Если вам сложно уйти от цели, попробуйте написать код так, как если бы он был синхронным. В этом случае:

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'

Это работает! Но он синхронизируется и блокирует браузер во время загрузки. Чтобы сделать эту работу асинхронной, мы используем then() чтобы все происходило одно за другим.

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

Но как мы можем перебрать URL-адреса глав и получить их по порядку? Это не работает :

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

forEach не поддерживает асинхронность, поэтому наши главы будут появляться в том порядке, в котором они загружаются, — именно так и было написано «Криминальное чтиво». Это не «Криминальное чтиво», так что давайте это исправим.

Создание последовательности

Мы хотим превратить наш массив chapterUrls в последовательность обещаний. Мы можем сделать это, используя 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);
  });
})

Мы впервые видим Promise.resolve() , который создает обещание, которое преобразуется в любое значение, которое вы ему присвоите. Если вы передадите ему экземпляр Promise он просто вернет его ( примечание: это изменение спецификации, которому некоторые реализации еще не следуют). Если вы передадите ему что-то похожее на обещание (имеющее метод then() ), оно создаст настоящее Promise , которое выполняется/отклоняется таким же образом. Если вы передадите любое другое значение, например Promise.resolve('Hello') , будет создано обещание, которое будет выполнено с этим значением. Если вы вызовете его без значения, как указано выше, он выполнится с «неопределенным».

Также существует Promise.reject(val) , который создает обещание, которое отклоняется с заданным вами значением (или неопределенным).

Мы можем привести в порядок приведенный выше код, используя 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())

Это делается так же, как и в предыдущем примере, но отдельная переменная «последовательность» не требуется. Наш обратный вызов сокращения вызывается для каждого элемента массива. «Последовательность» — это Promise.resolve() в первый раз, но для остальных вызовов «последовательность» — это то, что мы вернули из предыдущего вызова. array.reduce действительно полезен для сведения массива к одному значению, что в данном случае является обещанием.

Давайте соберем все это вместе:

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

И вот она, полностью асинхронная версия синхронизируемой версии. Но мы можем добиться большего. На данный момент наша страница загружается так:

Браузеры довольно хорошо загружают несколько вещей одновременно, поэтому мы теряем производительность, загружая главы одну за другой. Мы хотим загрузить их все одновременно, а затем обработать, когда они все поступят. К счастью, для этого есть API:

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

Promise.all принимает массив обещаний и создает обещание, которое выполняется, когда все они успешно завершаются. Вы получаете массив результатов (независимо от того, какие обещания были выполнены) в том же порядке, что и переданные вами обещания.

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

В зависимости от соединения это может быть на несколько секунд быстрее, чем загрузка по одному, и это меньше кода, чем при первой попытке. Главы можно загружать в любом порядке, но на экране они появляются в правильном порядке.

Тем не менее, мы все еще можем улучшить воспринимаемую производительность. Когда появится первая глава, мы должны добавить ее на страницу. Это позволяет пользователю начать чтение до того, как появятся остальные главы. Когда появится третья глава, мы не будем добавлять ее на страницу, потому что пользователь может не заметить, что вторая глава отсутствует. Когда выйдет вторая глава, мы сможем добавить вторую и третью главы и т. д. и т. п.

Для этого мы извлекаем JSON для всех наших глав одновременно, а затем создаем последовательность для добавления их в документ:

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

И вот, лучшее из обоих! Доставка всего контента занимает одинаковое количество времени, но пользователь получает первую часть контента раньше.

В этом тривиальном примере все главы появляются примерно в одно и то же время, но польза от отображения по одной будет преувеличена при наличии большего количества глав большего размера.

Выполнение вышеописанного с помощью обратных вызовов или событий в стиле Node.js требует примерно вдвое больше кода, но, что более важно, за ним не так просто следить. Однако на этом история обещаний не заканчивается: в сочетании с другими функциями ES6 они становятся еще проще.

Бонусный раунд: расширенные возможности

С тех пор, как я изначально написал эту статью, возможности использования промисов значительно расширились. Начиная с Chrome 55, асинхронные функции позволяют писать код на основе обещаний так, как если бы он был синхронным, но без блокировки основного потока. Подробнее об этом можно прочитать в моей статье об асинхронных функциях . В основных браузерах широко распространена поддержка как Promises, так и асинхронных функций. Подробности можно найти в справочнике по обещаниям и асинхронным функциям MDN.

Большое спасибо Анне ван Кестерен, Доменику Дениколе, Тому Эшворту, Реми Шарпу, Адди Османи, Артуру Эвансу и Ютаке Хирано, которые вычитали текст и внесли исправления/рекомендации.

Также спасибо Матиасу Байненсу за обновление различных частей статьи.