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

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

Джейк Арчибальд
Jake Archibald

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

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

Обещания теперь доступны и в JavaScript!

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

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

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

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

Browser Support

  • Хром: 32.
  • Край: 12.
  • Firefox: 29.
  • Сафари: 8.

Source

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

Из-за чего весь этот шум?

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-элементов изображений был метод «ready», возвращающий обещание, мы могли бы сделать следующее:

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

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

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

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

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

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

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

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

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

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

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

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

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

Хотя реализации обещаний следуют стандартизированному поведению, их общие API различаются. 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"));
  }
});

Конструктор промисов принимает один аргумент — обратный вызов с двумя параметрами: resolve и reject. Внутри обратного вызова выполните что-нибудь, возможно, асинхронное, затем вызовите resolve, если всё прошло успешно, в противном случае вызовите reject.

Как и в 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 в promise-speak sigh ), так что если вы используете библиотеку, которая возвращает обещание Q, это нормально, она будет хорошо работать с новыми обещаниями JavaScript.

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

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

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

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 для последовательного выполнения асинхронных действий.

Когда вы возвращаете что-либо из обратного вызова 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, поэтому 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] завершится неудачей (например, код ошибки 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') , он создаст обещание, которое выполняется с этим значением. Если вы вызовете его без значения, как выше, он выполнится со значением "undefined".

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

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

Этот пример работает так же, как и предыдущий, но не требует отдельной переменной «sequence». Наш обратный вызов reduce вызывается для каждого элемента массива. В первый раз «sequence» — это Promise.resolve() , но для последующих вызовов «sequence» — это то, что мы вернули из предыдущего вызова. 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 они становятся ещё проще.

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

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

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

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