Промисы

Дамы и господа, приготовьтесь к поворотному моменту в истории веб-разработки …

[Барабанная дробь]

Промисы добрались до JavaScript!

[фейерверки, серпантин, хлопушки, беснующаяся толпа]

Прямо сейчас вы находитесь в одной из следующих ситуаций:

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

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

Как человек, вы мультизадачны. Вы можете печатать на клавиатуре несколькими пальцами или вести машину и одновременно разговаривать с кем-то. Есть только одна вещь, во время которой мы не можем заниматься ничем другим. Это чихание. Действительно, когда вы чихаете, все ваши действия приостанавливаются. Особенно это раздражает, когда вы ведете машину и болтаете с кем-то. Но вы ведь не хотите писать код, склонный к насморкам и чиханию, правда?

Я почти уверен, что раньше, чтобы разрулить асинхронность, вы использовали события и функции обратного вызова. Вот пример использования событий:

var img1 = document.querySelector('.img-1');
  
img1.addEventListener('load', function() {
  // изображение загружено
});
 
img1.addEventListener('error', function() {
  // эх, что-то сломалось
});

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

К сожалению, в приведенном примере есть один неприятный нюанс: событие может произойти до того момента, как мы добавим обработчик. Чтобы обойти это ограничение, мы должны проверить свойство “complete” у изображения:

var img1 = document.querySelector('.img-1');
 
function loaded() {
  // изображение загружено
}
  
if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}
 
img1.addEventListener('error', function() {
  // эх, что-то сломалось
});

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

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

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

img1.callThisIfLoadedOrWhenLoaded(function() {
  // загружено
}).orIfFailedCallThis(function() {
  // ошибка
});
 
// и…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // все загружено
}).orIfSomeFailedCallThis(function() {
  // одна или несколько ошибок
});

Это именно то, что делают промисы, только с простыми названиями методов. Если у изображения есть метод “ready”, возвращающий промис, мы можем сделать так:

img1.ready().then(function() {
  // загружено
}, function() {
  // неудача
});
 
// и…
Promise.all([img1.ready(), img2.ready()]).then(function() {
  // все изображения загружены
}, function() {
  // не удалось загрузить одно или несколько изображений
});

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

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

Терминология промисов

Доминик Деникола (Domenic Denicola), прочитав первый черновик этой статьи, поставил мне двойку за терминологию. Более того, он захватил меня в заложники, заставил 100 раз переписать States and Fates, и, в завершение, пожаловался моим родителям. И я все еще продолжаю путаться в терминологии. Тем не менее, вот основные моменты.

Промисы бывают:

Так же в спецификации есть термин промисообразные (thenable). Это объект, похожий на промис тем, что имеет метод then. Я постараюсь использовать его как можно реже.

Промисы добрались до JavaScript!

На самом деле, промисы потихоньку подбирались к JavaScript уже давно. Например, посредством библиотек:

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

Хотя представленные реализации промисов имеют одинаковое поведение, их API отличаются. API нативных промисов в JavaScript совпадает с API в RSVP.js. Вот как происходит создание промиса:

var promise = new Promise(function(resolve, reject) {
  // выполнить что-то, возможно, асинхронно…
  
  if (/* все прошло без ошибок */) {
    resolve("Сработало!");
  }
  else {
    reject(Error("Что-то сломалось!"));
  }
});

Конструктор принимает один аргумент — функцию обратного вызова с двумя параметрами, resolve и reject. Далее выполняются действия внутри этой функции, возможно и асинхронные. А затем, если все отработало без ошибок, вызывается resolve, иначе — reject.

Как и throw в JavaScript, допускается в качестве reject передавать объект типа Error. Польза такого подхода в том, что такой объект отслеживает стек вызова, что делает отладку более приятной.

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

promise.then(function(result) {
  console.log(result); // "Сработало!"
}, function(err) {
  console.log(err); // Error: "Ошибка!"
});

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

Вначале промисы появились в DOM как Futures, затем были переименованы в Promises, и окончательно попали в JavaScript. Это здорово, что они доступны в JavaScript, а не только в DOM, потому что мы можем использовать их и вне браузеров, например, в Node.js (пусть промисов и нет в API Node.js, но это уже другая история).

Кроме того, промисы — это будущее JavaScript. Будьте уверены, что все новые API для работы с DOM для асинхронных методов resolve/reject будут использовать промисы. В подтверждение скажу, что их уже используют Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams и многие другие.

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

В некоторых браузерах уже доступна частичная поддержка промисов. Скачайте Canary, в котором промисы доступны для использования по-умолчанию. Если же вы предпочитаете Firefox, скачайте последнюю ночную сборку, так же поддерживающую промисы.

Стоит сказать, что ни один из этих браузеров не имеет полной поддержки промисов. Вы можете отслеживать разработку Firefox в bugzilla, и на дашборде новых функций Chrome .

(Прим. ред.: на момент публикации статьи, промисы по-умолчанию доступны везде, кроме IE. В нем они будут доступны начиная с Edge. Актуальную информацию о поддержке промисов на сегодняшний день можно получить на сайте caniuse.com.)

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

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

API промисов в JavaScript будет рассматривать любую сущность с методом «then», как промисообразную («thenable» — с возможностью обратного вызова). Вообще, за исключением метода Promise.cast, нет никакой разницы между промисами и промисообразными объектами. Так что если вы используете библиотеку, возвращающую Q-промис, у вас не возникнет проблем с новыми нативными промисами.

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

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

Тут $.ajax возвращает объект типа Deferred. И, так как у него есть метод «then», Promise.cast превращает этот объект в промис. Но иногда deferreds принимают несколько аргументов, например:

var jqDeferred = $.ajax('/whatever.json');
 
jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
});

В этом случае промисы игнорируют все аргументы, кроме первого:

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

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

Просто написанный сложный асинхронный код

А теперь давайте-ка напишем немного кода. Пусть нам нужно:

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

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

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

Давайте начнем с получения данных с сервера.

XMLHttpRequest и промисы

Уверен, скоро старые API будут обновлены для использования промисов, если, конечно, это возможно с точки зрения совместимости. XMLHttpRequest является главным кандидатом на такое обновление, но, всё-таки, давайте сами напишем небольшую функцию для выполнения GET-запроса:

function get(url) {
  // Возвращает новый промис
  return new Promise(function(resolve, reject) {
    // Стандартный XHR запрос
    var req = new XMLHttpRequest();
    req.open('GET', url);
  
    req.onload = function() {
      // Этот метод вызовется, даже в случае 404 ошибки
      // так что проверяем код ответа
      if (req.status == 200) {
        // выполняем «resolve» промиса с полученным текстом
        resolve(req.response);
      }
      else {
        // иначе вызываем «reject» с текстом статуса
        // который, возможно, даст представление об ошибке
        reject(Error(req.statusText));
      }
    };
 
    // Обрабатываем ошибки сети
    req.onerror = function() {
      reject(Error("Сетевая ошибка"));
    };
 
    // Выполняем запрос
    req.send();
  });
}

Теперь получаем содержимое статьи:

get('story.json').then(function(response) {
  console.log("Выполнено", response);
}, function(error) {
  console.error("Не удалось выполнить!", error);
});

Живой пример можно увидеть здесь. Теперь нет необходимости вручную набирать XMLHttpRequest для создания HTTP-запросов, и это здорово. Потому что чем меньше я вижу приводящий меня в ярость «camel-case» XMLHttpRequest, тем счастливее я становлюсь.

Цепочки

“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("Успешно выполнено!", response);
});

Ответ — JSON, но для вставки на страницу нам нужен просто текст. Следует изменить функцию, получающую данные, для использования JSON responseType:

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

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

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Ухты, 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("Раздел 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]);
  })
}
 
// и очень просто использовать:
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 принимает два аргумента — fulfill и reject, один для успешного завершения, другой на случай возникновения ошибки:

get('story.json').then(function(response) {
  console.log("Успешное выполнение!", response);
}, function(error) {
  console.log("Ошибка!", error);
});

Так же вы можете использовать «catch»:

get('story.json').then(function(response) {
  console.log("Успешное выполнение!", response);
}).catch(function(error) {
  console.log("Ошибка!", error);
});

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

get('story.json').then(function(response) {
  console.log("Успешное выполнение!", response);
}).then(undefined, function(error) {
  console.log("Ошибка!", error);
});

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

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("Не обращайте внимание!");
}).then(function() {
  console.log("Все выполнено!");
});

Процесс выполнения этого кода схож с обычным try/catch. Если ошибка возникает внутри блока try, управление передается блоку catch. Вот подробная блок-схема того, как это происходит:

Зеленая линия — если промис успешно выполнен, красная — если нет.

Исключения и промисы

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

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse выдает ошибку, если передать ему
  // невалидный JSON, что прервет выполнение промиса:
  resolve(JSON.parse("Это не JSON!"));
});
 
jsonPromise.then(function(data) {
  // Эта часть не выполнится:
  console.log("Выполнено!", data);
}).catch(function(err) {
  // А вот эта выполнится:
  console.log("Ошибочка вышла!", err);
});

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

То же самое происходит с ошибками, возникшими в функции обратного вызова, переданной в then:

get('/').then(JSON.parse).then(function() {
  // Эта часть не выполнится, '/' — это HTML-страница, не  JSON
  // так что JSON.parse бросит исключение
  console.log("Выполнено!", data);
}).catch(function(err) {
  // и выполнится этот код
  console.log("Ошибка!", err);
});

Практическая обработка ошибок

Работая с нашей статьей и ее разделами, мы можем использовать catch для информирования пользователя об ошибках:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Не удалось показать статью");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
});

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

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

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Не удалось показать статью");
}
 
document.querySelector('.spinner').style.display = 'none';

Вам может понадобиться поймать ошибку просто для логгирования, без необходимости ее обработки. Для этого просто бросьте ошибку еще раз, вот так:

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("Не удалось выполнить getJSON  для", 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("Все выполнено.");
}
catch (err) {
  addTextToPage("Ой, сломалось: " + err.message);
}
 
document.querySelector('.spinner').style.display = 'none';

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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);
  // TODO: для каждого url в story.chapterUrls, получаем и отображаем
}).then(function() {
  // Все выполнено!
  addTextToPage("Все выполнено");
}).catch(function(err) {
  // Ловим ошибки, произошедшие в процессе выполнения
  addTextToPage("Ой, сломалось: " + err.message);
}).then(function() {
  // В любом случае, скрываем прелоадер
  document.querySelector('.spinner').style.display = 'none';
});

Но как мы можем пройтись в цикле по URL разделов и получить их по порядку? Так точно не будет работать:

story.chapterUrls.forEach(function(chapterUrl) {
  // Получаем раздел
  getJSON(chapterUrl).then(function(chapter) {
    // и добавляем его на страницу
    addHtmlToPage(chapter.html);
  });
});

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

Последовательность выполнения

Мы хотим превратить наш массив chapterUrls в последовательность промисов. Это можно сделать, используя then:

// Начнем с промиса, который всегда выполнится
var sequence = Promise.resolve();
 
// Пройдемся через все адреса наших разделов
story.chapterUrls.forEach(function(chapterUrl) {
  // Добавляем действия с ними в конец последовательности
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
});

Мы встретили здесь новый метод — Promise.resolve. Он создает промис, который будет выполнен в любом случае, вне зависимости от значений, ему переданных. Если вы передадите ему нечто промисообразное (имеющее метод then), будет создан новый промис, который выполнится или будет отклонен, так же, как и начальный промис. Фактически, это будет клон. Если же передать другое значение, например, Promise.resolve('Привет'), будет создан промис, который выполнится и вернет это значение. Если же вызвать его без передаваемого значения, результатом выполнения промиса будет undefined. Кроме того, есть метод Promise.reject(val), который создает промис, отклоненный с переданным значением.

Привести в порядок это код мы можем с помощью array.reduce

// Цикл по URL наших разделов
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Добавляем их в конец последовательности выполнений
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve());

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

А теперь соединим все части кода вместе…

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);
 
  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Как только последней промис раздела будет выполнен…
    return sequence.then(function() {
      // …получаем следующий раздел
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // и добавляем его на страницу
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // И все!
  addTextToPage("Все сделано.");
}).catch(function(err) {
  // Ловим ошибки, если они были
  addTextToPage("Эх, сломалось: " + err.message);
}).then(function() {
  // В любом случае, скрываем прелоадер
  document.querySelector('.spinner').style.display = 'none';
});

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

Загрузка страницы

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

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

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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);
  // Берем массив промисов и ждем выполнения всех
  return Promise.all(
    // Применяем метод map для массива с URL разделов
    // и получаем массив с json каждого раздела
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Теперь у нас есть json разделов в нужном порядке. Проходим по ним в цикле...
  chapters.forEach(function(chapter) {
    // …и добавляем их на страницу
    addHtmlToPage(chapter.html);
  });
  addTextToPage("Все выполнено.");
}).catch(function(err) {
  // ловим ошибки, если они были
  addTextToPage("Эх, сломалось: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
});

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

Загрузка страницы с Promise.all()

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

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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);
  
  // Применяем метод map для массива с URL разделов
  // и получаем массив с json каждого раздела
  // Теперь мы уверены, что загрузка осуществляется параллельно.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
        // Используем reduce для связывания промисов вместе,
        // и добавления разделов на страницу
        return sequence.then(function() {
        // Ждем завершения всех действий в последовательности,
        // затем ждем загрузки конкретного раздела
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("Все сделано!");
}).catch(function(err) {
  // Ловим ошибки, если они произошли
  addTextToPage("Эх, сломалось: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
});

И теперь у нас улучшенный вариант кода! Загрузка контента займет примерно одинаковое время, но пользователь увидит первую часть контента чуть раньше.

Параллельная загрузка страницы

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

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

Бонус: Промисы и Генераторы

Поговорим о кое-чём совсем новом в ES6. Хотя вам и необязательно это знать, чтобы начать использовать промисы. Расценивайте это, как небольшой тизер к новым невероятным возможностям.

В ES6 появилась такая вещь, как генераторы. Генератор позволяет вам выйти из функции, как это обычно делает return, но отличие в том, что позже мы сможем продолжить выполнение функции с прерванного места. Например:

function *addGenerator() {
  var i = 0;
  while (true) {
    i += yield i;
  }
}

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

var adder = addGenerator();
adder.next().value; // 0
adder.next(5).value; // 5
adder.next(5).value; // 10
adder.next(5).value; // 15
adder.next(50).value; // 65

Но что это значить для промисов? Что ж, вы можете использовать эти точки возврата/продолжения для написания асинхронного по сути кода, который будет выглядеть, как синхронный. Не волнуйтесь, если не можете полностью осознать вышесказанное. Лучше-ка взгляните на пример, где есть вспомогательная функция, позволяющая использовать yield для ожидания, пока промис отработает:

function spawn(generatorFunc) {
  function continuer(verb, arg) {
    var result;
    try {
      result = generator[verb](arg);
    } catch (err) {
      return Promise.reject(err);
    }
    if (result.done) {
      return result.value;
    } else {
      return Promise.cast(result.value).then(onFulfilled, onRejected);
    }
  }
  var generator = generatorFunc();
  var onFulfilled = continuer.bind(continuer, "next");
  var onRejected = continuer.bind(continuer, "throw");
  return onFulfilled();
}

Эта функция практически дословно взята из библиотеки Q и адаптирована мной под нативные промисы в JavaScript. С её помощью мы можем создать финальную версию нашего скрипта для загрузки разделов. Добавим туда еще немного хорошего из ES6, и получим вот это:

spawn(function *() {
  try {
    // 'yield' отлично организует асинхронное ожидание,
    // возвращая результат промиса
    let story = yield getJSON('story.json');
    addHtmlToPage(story.heading);
	
	// Применяем метод map для массива с URL разделов
  	// и получаем массив с json каждого раздела
  	// Теперь мы уверены, что загрузка осуществляется параллельно.
    let chapterPromises = story.chapterUrls.map(getJSON);
    
    for (let chapterPromise of chapterPromises) {
      // Ждем готовности всех разделов, затем добавляем их на страницу          
      let chapter = yield chapterPromise;
      addHtmlToPage(chapter.html);
    }
    
    addTextToPage("Все сделано");
  }
  catch (err) {
    // если try/catch отработали, ловим отклоненные промисы и выбрасываем здесь
    addTextToPage("Эх, сломалось: " + err.message);
  }
  document.querySelector('.spinner').style.display = 'none';
});

Скрипт работает точно так же, как и до этого, но зато теперь он гораздо проще для понимания. Пока что он работает в Chrome Canary, но для этого необходимо в настройках найти about:flags и включить Enable experimental JavaScript.

Это действие активирует несколько новых возможностей ES6: промисы, генераторы, объявление переменных через let, цикл for-of. Когда мы прерываем выполнение промиса, spawn ждет выполнения промиса и возвращает окончательное значение. Если промис отклонен, точка выхода (yield) выбрасывает исключение, которое мы можем поймать в блоке try/catch. Невероятно просто для асинхронного кода!

API промисов

Все методы работают в ночных сборках Chrome и Firefox, если только не сказано об обратном. Для перечисленных ниже методов есть полифиллы для всех браузеров.

Статические методы

(Прим. ред.: актуальную информацию о поддержке промисов на сегодняшний день можно получить на сайте caniuse.com.)

Конструктор

new Promise(function(resolve, reject) {});

Методы экземпляра класса

Огромное спасибо Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans, и Yutaka Hirano кто помог мне вычитать статью и внести правки.