Pull to refresh

Обещания JavaScript

Reading time 19 min
Views 204K
Original author: Jake Archibald
Всем привет, и ещё раз всех с прошедшими праздниками. Трудовые будни набирают обороты и вместе с ними растёт информационный голод мучающий нас. Мир разработки переднего конца не дремлет и готовит нам много сюрпризов в наступившем году, и уж поверьте мне, скучно не будет ни кому. Одна из новых особенностей которые нам готовят разработчики браузеров совместно с группами разработчиков пишущих спецификации — JavaScript Promises(далее в переводе — Обещания, прошу сильно не бить) — полюбившийся многим шаблон написания асинхронного кода обзаводится нативной поддержкой. Что же такое обещания и с чем их едят можно прочесть в нижеследующем переводе(слегка вольном) замечательной статьи Джейка Арчибальда.



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

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

Обещания стали нативными в JavaScript’е!

[Повсюду грохот салютов, толпа в восторге]

В данный момент тебя можно отнести к одной из следующих категорий:
  • Люди ликуют вокруг, но ты не можешь понять в чём причина торжества. Возможно ты до сих пор не до конца можешь догнать, что эти “Обещания” вообще из себя представляют. Ты пытаешься пожать плечами, но тонны разноцветных блестящих конфетти давят своим весом на твои плечи. Если так, не переживай, у меня ушли годы изнурительной работы, пока я не перестал волноваться за понимание этой штуковины. Вероятно, тебе просто стоит начать отсюда.
  • Ты кричишь. Ты использовал эти штуки из обещаний до этого, но тебе не давала заснуть мысль, что все имеющиеся реализации имеют слегка разное API. Какое же API нам предоставит официальная реализация в JavaScript’е? Тогда тебе, вероятно, сюда.
  • Ты в курсе всех дел, и злорадно хихикаешь над массой этих простаков, скачущих вокруг тебя так, как будто для них это новость. Удели минутку тому, что бы насладиться своей крутизной, а затем уверенным шагом следуй сразу к справочнику по API.


Почему все вокруг танцуют?


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

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

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

var img1 = document.querySelector('.img-1');
  
img1.addEventListener('load', function() {
  // вау, картинка загружена
});

img1.addEventListener('error', function() {
  // чёрт, всё сломалось
});


Здесь нет блокирующего кода. Мы взяли изображение, навесили на него парочку слушателей, и всё. Дальше JavaScript может прекратить своё выполнение до момента, пока одно из событий не выстрелит.

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

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

function loaded() {
  // вау, картинка загружена
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // чёрт, всё сломалось
});


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

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


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

 img1.callThisIfLoadedOrWhenLoaded(function() {
  // загружена
}).orIfFailedCallThis(function() {
  // ошибка
});

// и…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // всё загрузилось
}).orIfSomeFailedCallThis(function() {
  // чёт не хочет грузиться
});


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

img1.ready().then(function() {
  // загружено
}, function() {
  // не хочет грузиться
});

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


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

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


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

Терминология Promise’ов


Доменик Деникола прочитал первый черновик этой статьи и поставил мне кол за используемую терминологию. В наказание он заставил меня прочесть сто раз положения о возможных “Состояниях и Исходах Обещаний”, и написать извинительное письмо моим родителям. Невзирая на это, у меня всё равно всё смешалось, и я пришёл к следующим основным понятиям:

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

  • fulfilled — успешно завершённым
  • rejected — завершённым с ошибкой
  • pending — не завершённым
  • settled — завершённым с любым исходом


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

Promise’ы встроены в JavaScript!


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



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

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


Стандартизация обещаний началась в DOM как “Futures”, позднее была переименована в “Promises”, и наконец, переместилась в спецификацию JavaScript’а. Идея реализации обещаний, в первую очередь в JavaScript’e, отдельно от объектной модели документа, прекрасна потому, что они смогут быть доступны в не браузерных средах, таких как Node.js.

Хотя они и превратились чисто в JavaScript’овую фичу, DOM не стесняется использовать их на полную. По факту всё новое DOM API, завязанное на асинхронности, будет использовать Обещания. Сейчас это уже происходит с Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams, и другими API'шками.

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


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

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

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

Для того, чтобы привести работу обещаний к надлежащему виду или добавить обещания в другие браузеры и Node.js, используй полифил.

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


JavaScript’овое API обещаний не обходится без метода then, как и надлежит Promise-подобному объекту (в Promise терминологии его ещё называют thenable). Имеется также метод Promise.cast, который стирает границы между встроенными и пользовательскими Promise-подобными объектами. Итак, если ты используешь библиотеку, которая возвращает обещания типа Q, это прекрасно, они будут отлично работать с нативными JavaScrip’овыми обещаниями.

Но, как я и предупреждал, JQuery Deferred’ы, слегка… иные. К счастью, ты можешь привести их к стандартным:

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


Здесь jQuery'ривский $.ajax возвращает Deferred. Но пока у него есть метод then, Promist.cast может обратить его в настоящее обещание. Как бы то ни было, временами Deffered передаёт слишком много аргументов своему колбэку:

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

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

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

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


К счастью, в большинстве случаев это то, что тебе нужно, и ты получаешь доступ к тому, что тебе надо. Ещё важно знать, что JQuery не следует конвенции передавать объект ошибки в reject.

Асинхронный код становится проще


Так, давай закодируем пару вещей. Предположим, что мы хотим:

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


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

Конечно же, ты не хочешь динамически грузить контент, который быстрее отдать как 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(req.response);
      }
      else {
        // Обламываемся, и передаём статус ошибки
        // что бы облегчить отладку и поддержку
        reject(Error(req.statusText));
      }
    };

    // отлавливаем ошибки сети
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Делаем запрос
    req.send();
  });
}


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

get('story.json').then(function(response) {
  console.log("Отлично!", response);
}, function(error) {
  console.error("Ошибка!", 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("Отлично!", response);
});


Ответ пришёл нам в формате 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 по прежнему возвращает обещание после того как вытянет данные и распарсит JSON’овский ответ.

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

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

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

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Получили первую главу!", 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 переиспользуют уже выполнившееся обещание загрузки истории и не делают дополнительных запросов. Ох уж эти Обещания!

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


Как мы видели ранее, 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);
});


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

 get('story.json').then(function(response) {
  console.log("Отлично!", response);
}).then(undefined, function(error) {
  console.log("Ошибка!", error);
});


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

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, ошибка, произошедшая в блоке try, немедленно передаётся в блок catch. Вот блок-схема происходящего в коде выше (обожаю блок-схемы):

image

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

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

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

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse генериует исключение в случае
  // невалидного JSON, и оно неявно передаётся reject’у:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // Этот кусок никогда не выполнится:
  console.log("Работает!", data);
}).catch(function(err) {
  // а это произойдёт:
  console.log("Ошибка!", err);
});


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

То же самое произойдёт при генерации исключения в колбэке then:

get('/').then(JSON.parse).then(function() {
  // Этот код не отработает, '/' это не нужный нам 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] сорвётся (http 500 или пользователь ушёл в оффлайн), не произойдёт выполнение всех последующих колбэков, вызываемых в случае успеха, таких как парсер JSON’а, включённого в getJSON, колбэк, добавляющий первую главу на страницу, тоже проигнорируется. Выполнение сразу перейдёт к первому колбэку обработки ошибки. В результате, пользователь увидит сообщение «Невозможно отобразить главу», если в любом из предыдущих колбэков что-то пойдёт не так.

Как и в случае стандартного 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';


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

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("All done");
}
catch (err) {
  addTextToPage("Что-то сломалось: " + err.message);
}

document.querySelector('.spinner').style.display = 'none';


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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: загрузить и отобразить каждую главу из story.chapterUrls
}).then(function() {
  // Всё загрузилось и обработалось!
  addTextToPage("Всё ок");
}).catch(function(err) {
  // Перехватываем любую ошибку, которая встретилась на пути
  addTextToPage("Что-то сломалось: " + err.message);
}).then(function() {
  // И всегда скрываем индикатор
  document.querySelector('.spinner').style.display = 'none';
});


Но как нам обойти последовательно все главы? Так работать не будет:

story.chapterUrls.forEach(function(chapterUrl) {
  // запросить главы
  getJSON(chapterUrl).then(function(chapter) {
    // и вывести их на страницу
    addHtmlToPage(chapter.html);
  });
});


forEach не имеет ничего общего с асинхронностью, и наши главы будут добавляться на страницу в произвольном порядке по мере загрузки, примерно, как было написано “Криминальное чтиво”. У нас не “Криминальное чтиво”, поэтому давай это исправим…

Ставим всё в очередь

Мы хотим обратить наш массив chaptersUrls в очередь обещаний. Мы можем сделать это следующим образом:

// Начинаем с создания Обещания, которое уже спешно выполнено
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), который возвращает обещание, завершённое ошибкой, со значением, которое ты ему передашь (или undefined).

Мы можем сделать код чуть более опрятным, используя array.reduce:

// бежим по нашему списку глав
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // добавляем следующие действия в конец очереди
  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) {
    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';
});


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

image

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

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


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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

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


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

image

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

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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

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


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

image

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

Повторить всё вышесказанное в стиле колбэков и событий 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, просто адаптирована под нативные обещания. С ней мы можем взять наш последний пример отображения глав и превратить его в вот это:

spawn(function *() {
  try {
    // 'yield' effectively does an async wait,
    // returning the result of the promise
    let story = yield getJSON('story.json');
    addHtmlToPage(story.heading);

    // Map our array of chapter urls to
    // an array of chapter json promises.
    // This makes sure they all download parallel.
    let chapterPromises = story.chapterUrls.map(getJSON);

    for (let chapterPromise of chapterPromises) {
      // Wait for each chapter to be ready, then add it to the page
      let chapter = yield chapterPromise;
      addHtmlToPage(chapter.html);
    }

    addTextToPage("All done");
  }
  catch (err) {
    // try/catch just works, rejected promises are thrown here
    addTextToPage("Argh, broken: " + err.message);
  }
  document.querySelector('.spinner').style.display = 'none';
});


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

Этот пример объединяет много новых возможностей ES6: обещания, генераторы, let, for-of. И показывает как мы можем писать простой асинхронный код с нормальным try/catch.

Будущее уже близко.
Tags:
Hubs:
+69
Comments 39
Comments Comments 39

Articles