Каждый день нам приходится сражаться с асинхронностью Javascript. Еще пару лет назад это была настоящая война. Мы писали множество callback-функций для всех анимаций на странице, простых тайм-аутов, запросов к серверам с “умными” API для получения информации из баз данных. Следующий пример демонстрирует, как можно получить список всех магазинов, где есть ингредиенты для яблочного пирога.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | connection.request({ entity: "pie", type: "apple" }, function(results){ connection.request({ entity: "shop", goods: results.ingredients }, function(shops){ //go shopping }, function(error){ console.log("Error again"); }); }, function(e){ console.log("Error"); }); |
Здесь мы видим четыре callback-функции: две — для получения результата и еще две — для обработки ошибок. Выглядит не очень красиво, правда? Что если бы у нас был более сложный пример с использованием кэша и большого количества модулей?
Все запрашиваемые данные могут использоваться в вашем приложении несколькими модулями или виджетами, и каждый из них будет отправлять запрос на сервер, чтобы получить данные. Конечно, нам также стоит принять во внимание индикаторы процесса загрузки, которые должны отображаться в нужном месте до тех пор, пока мы не получим ответ от сервера. В таком случае, единственным выходом является синхронизация запросов. И вот перед вами герой, который спасет положение — это promise-объект.
Использование Promise-объектов
Узнать о нем можно из множества источников. К примеру, самая базовая спецификация известна под названием CommonJS Promises/A. Существует также ее более расширенная версия — Promises/A+.
Если не вдаваться в подробности, promise-объект содержит в себе текущее состояние запроса. Это означает, что каждый фрагмент кода, который имеет ссылку на этот объект, знает о существовании запроса и его текущем состоянии, каким бы оно ни было: “завершен”, “завершен с ошибкой” или все еще “в ожидании ответа”. Кроме того, мы можем установить обработчики, которые будут вызываться при изменении состояния запроса.
Существуют различные типы promise-объектов: A, B, C, D, KISS. Все они используют одну и ту же концепцию хранения текущего состояния объекта, однако отличаются наборами методов и именами promise-объектов. Тем не менее, все они облегчают нам жизнь, оставляя за кадром всю логику транспортировки, обработки ошибок и сбора результатов и позволяя нам заниматься только бизнес-логикой.
Давайте обратимся к небольшому, но полезному примеру. У нас имеется объект с именем “Connection” для получения данных с сервера. У этого объекта есть метод “request”, который принимает параметры запроса и возвращает promise-объект. Нам нужно дождаться ответа и отобразить индикатор процесса загрузки. В этом примере мы не будем использовать никаких библиотек для создания promise-объектов. Это просто псевдо-код для того, чтобы проиллюстрировать работу с promise-объектами.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | var Progress = function(){ var elem = this.element = document.createElement("DIV"); elem.className = "waitingAnimation"; }; Progress.prototype = { showUntilRequestsAreFinished: function(promise){ var progressElement = this.element; document.body.appendChild(progressElement); promise.then(function(){ document.body.removeChild(progressElement); }, function(){ document.body.removeChild(progressElement); }) }; }; var onError = function(){ alert("error"); }; var promiseForAllChainOfRequests = connection.request({ entity: "pie", type: "apple" }).then(function getIngredients(pie){ //success, we have data about pie, and we will return only its ingredients return pie.ingredients; }, onError).then(function requestShops(ingredients){ //эта функция будет вызвана сразу после предыдущей return connection.request({ entity: "shop", goods: ingredients }; }, onError).then(function showAllOpenedShops(shops){ //эта функция будет вызвана, когда выполнится запрос для получения магазинов }, onError); progress = new Progress(); progress.showUntilRequestsAreFinished(promiseForAllChainOfRequests); |
Отлично! Мы разделили два разных модуля: модуль, показывающий процесс загрузки, и модуль, анализирующий данные с сервера. Теперь нам не нужно беспокоиться о том, чтобы вызывать метод “stop” для того, чтобы удалить индикатор процесса загрузки из DOM-модели. Мы можем вообще о нем забыть, поскольку он удалится сам. Кроме того, была решена проблема с двумя вложенными callback-функциями. Весь код можно прочитать как текст на английском языке, если дать функциям правильные названия: “Запросить пирог, затем получить ингредиенты, далее сделать запрос по магазинам, и, наконец, показать все открытые магазины”.
На этом самая простая часть использования promise-объектов заканчивается. Давайте теперь добавим кэш и два независимых виджета, которые будут запрашивать практически одну и ту же информацию с сервера. Прежде всего, нам необходимо создать кэш, который всегда будет возвращать promise-объект. Если в кэше уже находится значение, то возвращаемый promise-объект будет иметь состояние “завершен”, и сразу же будет вызвана callback-функция, переданная в метод “then”. Если значения в кэше нет, то запрос для его получения будет отправлен на сервер. Ключом для каждого значения в нашем кэше будет является строка JSON для запроса. Фрагмент кода представлен ниже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | var createCache = function(connection){ var cache = {}; return { get: function(requestJSON){ var key = JSON.stringify(requestJSON), newPromise = new Promise(), value; if (cache.hasOwnProperty(key)){ value = cache[key]; //check is it already promise or not if (value.hasOwnProperty("then")){ newPromise = value } else { newPromise.fulfill(value)); } } else { connection.request(requestJSON, function(results){ cache[key] = results; newPromise.fulfill(results); }, function(e){ delete cache[key]; newPromise.reject(e); }); } return newPromise; } } }; |
Это простая реализация кэша без методов для его обновления и указания срока хранения данных. Здесь используется только один метод — “get”. Если мы попытаемся получить какое-либо значение из кэша, он вернет promise-объект.
Promise-объект может быть либо завершенным успешно/с ошибкой, либо ждать ответа. Как видите, если мы вызовем метод “get” дважды, мы получим один и тот же promise-объект. Это помогает предотвратить отправку на сервер двух одинаковых запросов. Кроме того, все наши виджеты и модули могут быть независимыми друг от друга. Давайте посмотрим, как мы можем использовать кэш в нашем приложении.
У нас есть два виджета: первый показывает ингредиенты выбранного пирога, а второй — магазины, где мы можем их купить. Это два независимых виджета, которые “не знают” о существовании друг друга. Кроме того, нам нужно применить модуль для визуализации процесса загрузки. Его необходимо немного изменить и добавить еще один аргумент к методу “showUntilRequestsAreFinished”, где будет храниться ссылка на элемент, процесс загрузки которого должен быть показан. Давайте посмотрим на код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | var shopsWidget = function(cache, parent){ return { showShopsForIngredients: function(applePieId){ return cache.get({ entity: "pie", id: applePieId }).then(function(pie){ return pie.ingredients; }).then(function(ingredients){ return cache.get({ entity: "shop", goods: ingredients }); }).then(function(shops){ //создаем список магазинов }); } } }, recipeWidget = function(cache, parent){ return { showRecipe: function(applePieId){ return cache.get({ entity: "pie", id: applePieId }).then(function(pie){ //отображаем где-нибудь рецепт }); } } }; var cache = createCache(connection), applePieId = "idFromUrl", progress = new Progress(); //... много кода var shopsParentElement = document.getElementById("listOfShopsId"), shops = shopsWidget(cache, shopsParentElement), promiseThatWillBeFinishedAfterPopulatingAllShops = shops.showShopsForIngredients(applePieId); progress.showUntilRequestsAreFinished(promiseThatWillBeFinishedApterPopulatingAllShops, shopsParentElement); //... еще один большой фрагмент кода setTimeout(function(){ var recipeParentElement = document.getElementById("recipeElementId"), recipe = recipeWidget(cache, recipeParentElement), promiseThatWillBeFinishedAfterGettingRecipe = recipe.showRecipe(applePieId); progress.showUntilRequestsAreFinished(promiseThatWillBeFinishedAfterGettingRecipe, recipeParentElement); }, 1); |
Как видите, все эти модули создают один и тот же запрос, чтобы получить рецепт яблочного пирога по его ID. Но наш “умный” кэш не позволяет повторить это действие, и во второй раз (для recipeWidget, который создается в другом event loop из-за таймаута), он вернет promise-объект, созданный первым вызовом функции “get” (для shopsWidget). Эти два виджета ждут один и тот же ответ с сервера. Когда ответ приходит, shopsWidget первым получает все данные, и только после того, как он запросит информацию о магазинах, recipeWidget получит те же данные.
Реализация promise-объектов
Итак, псевдокод выглядит замечательно, но в нем есть один большой недостаток: он не работает. Существует два способа решить эту проблему:
создать свой собственный cупер-мега-классный Promise-объект или использовать уже существующие библиотеки.
Первый способ отличный, если вы не хотите зависеть от каких-либо сторонних библиотек. Вы можете выбрать любую реализацию, которая вам нравится, и добавить ещё больше методов. Но будьте внимательны, здесь встречается множество подводных камней. Поэтому мы будем использовать второй способ.
Cуществует множество библиотек для создания promise-объектов:
Все они поддерживают модель, которая описана в спецификации (но будьте внимательны с jQuery 1.9 и более ранних версий. Ее реализация promise-объектов немного отличается. Метод “then” не возвращает promise-объект, и вы не можете сделать цепочку из всех запросов).
Прежде чем погрузиться в реализацию promise-объектов, я должен объяснить, что представляет собой такое понятие как deferred-объект. Как вы уже поняли, promise-объект представляет текущее состояние какой-либо асинхронной операции. Вы можете прочитать это состояние, но не можете его изменить. Для того, чтобы изменить состояние promise-объекта, необходимо использовать связанный с ним deferred-объект. У всех deferred-объектов есть как минимум два метода (чтобы отклонить или выполнить связанный с ними promise-объект) и одно свойство (ссылка на promise-объект, связанный с данным deferred-объектом). Эти методы часто называются “reject” и “resolve”(“fulfill”). Deferred-объект может также иметь метод “notify”, который показывает прогресс выполнения асинхронной операции связанной с данным promise-объектом.
Конечно, у всех библиотек имеется конструктор для deferred-объектов и обычных promise-объектов, у которых есть метод “then”, и другие, не менее занимательные методы. Давайте ближе познакомимся с их API.
1. Q.js
Это небольшая библиотека для создания promise-объектов (она занимает примерно 2.5 Кб в архиве). Может использоваться с Node.js, а также в браузере. Кроме того, Q может обмениваться promise-объектами с jQuery, Dojo, When.js, WinJS и другими библиотеками. Она создает глобальный объект “Q”, являющийся функцией. Promise-объект можно создать следующим образом: Q (некоторое значение). Q.defer() используется для создания deferred-объекта, который может быть отклонен, разрешен (или выполнен), также через него могут передаваться оповещения (чтобы показать прогресс). Deferred-объект используется для того, чтобы управлять состоянием promise-объекта. Например, нам нужно сделать какое-либо асинхронное действие, вернуть promise-объект и разрешить (или отклонить) его, когда действие будет закончено. Давайте обратимся к еще одной реализации recipeWidget:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | var recipeWidget = (function(connection){ return { getRecipe: function(applePieId){ var deferred = Q.defer(); connection.request({ entity: "pie", id: applePieId }, function(pie){ //или deferred.fulfill(pie), что тоже самое deferred.resolve(pie); }, function(error){ deferred.reject(error); }); return deferred.promise; } })(connection); |
Существует также другой способ создания promise-объектов и изменения их состояния. Для этого нужно вызвать метод Promise и передать callback-функцию. Эта callback-функция называется “resolver”, и имеет два аргумента: функции для завершения и отклонения созданного promise-объекта:
1 2 3 4 5 6 7 8 9 10 | Q.Promise(function(resolve, reject){ connection.request({ entity: "pie", id: applePieId }, function(pie){ resolve(pie); }, function(error){ reject(error); }); }); |
Итак, deferred-объект имеет достаточно простой интерфейс. Самой интересной частью данной библиотеки является promise-объект. Он включает в себя множество полезных методов. Приведу самые важные из них:
1) then — такой же, как и в спецификации;
2) inspect — возвращает объект с текущим состоянием promise-объекта и его значением;
3) all — возвращает promise-объект, который будет выполнен, когда все promise-объекты, которые передаются в массиве в качестве аргумента, выполнены;
4) spread — похож на “then”, но “преобразует” массив в callback-функцию с переменным числом аргументов. Это означает, что, если мы применим его к promise-объекту, который мы получили с помощью метода “all”, количество аргументов у callback-функции, переданной первым параметром, будет таким же, как и длина массива, который передается в “all”:
1 2 3 4 | Q.all([promiseA, promiseB, promiseC ]).spread(function(valueOrReasonOfRejectionForPromiseA, valueOrReasonOfRejectionForPromiseB, valueOrReasonOfRejectionForPromiseC){ //выполняем какое-то действие }); |
5) методы для проверки состояния promise-объекта: isReject, isPending, isFulfilled;
6) timeout – возвращает promise-объект, который будет отклонен, если он не был завершен за определенный период времени;
7) множество методов, таких как thenResolve, thenReject, которые можно заменить методом “then”.
А теперь посмотрим на реальный пример использования данной библиотеки. В первом примере мы будем последовательно запрашивать три картинки (чтобы загрузка не произошла слишком быстро, добавим некоторую задержку с помощью Fiddler). Каждый запрос будет создавать новый deferred-объект и возвращать promise-объект. Когда картинка загрузится, мы завершим соответствующий promise-объект и создадим следующий запрос. Код представлен ниже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | var galary = document.getElementById("images"), imgs = ["img1", "img2", "img3" ], requestImage = function(parent, url){ var img = document.createElement("IMG"), deferred = Q.defer(); img.src = url; img.onload = function(){ deferred.resolve(this); this.onload = null; }; parent.appendChild(img); return deferred.promise; }; requestImage(galary, imgs.shift()).then(function(){ return requestImage(galary, imgs.shift()); }).then(function(){ return requestImage(galary, imgs.shift()); }); |
Конечно, загрузка картинок последовательно не является лучшим решением, поскольку это занимает много времени. Во втором примере мы загрузим все картинки параллельно и покажем их, когда они будут загружены. Для этого применим метод “all”:
1 2 3 | Q.all([requestImage(galary, imgs[0]), requestImage(galary, imgs[1]), requestImage(galary, imgs[2])]).then(function(){ galary.style.display = "block"; }); |
2. When.js
Эта библиотека очень похожа на предыдущую. Для создания deferred-объекта нам нужно использовать метод when.defer(). Методы deferred и promise-объектов не отличаются от подобных методов других библиотек. Однако, у этой библиотеки есть несколько отличных методов, которые могут немного облегчить нашу жизнь:
1) Методы массива: “map”, “reduce”, “some” — похожие на обычные методы для массивов;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | var promise = when.map(["apple", "orange", "kiwi"], function(fruit){ var deferred = when.defer(); connection.request({ entity: "pie", type: fruit }, function(pies){ deferred.resolve(pies); }, function(reason){ deferred.reject(reason); }); return deferred.promise; }); |
2) any — этот метод принимает массив promise-объектов и возвращает promise-объект, который будет выполнен после того, как выполнится хотя бы один promise-объект из переданного массива.
3. RSVP.js
Самая маленькая библиотека для создания promise-объектов. Использует тот же способ для создания deferred-объектов. Имеет классическую Promise/A+ реализацию.
Кроме того, она имеет расширенный набор событий и позволяет подписаться или отписаться от некоторых событий, таких как “created”, “chained”, “fulfilled”, “rejected”. Кроме того, библиотека имеет встроенный EventTarget модуль, который может превратить любой объект в обработчик событий, добавив методы для привязки функций к определенным событиям и отправки событий:
1 2 3 4 5 6 7 8 9 | var object = {}; RSVP.EventTarget.mixin(object); object.on("finished", function(event) { // обрабатываем событие }); object.trigger("finished", { detail: value }); |
Другие способы реализации promise-объектов ничем особенным не отличаются. Обратите внимание, что некоторые из них могут не поддерживать всю функциональность классических promise-объектов (таких как promise-объекты в JQuery 1.9 и более ранних версий).
Будущее promise-объектов
В заключение, несколько слов о браузерной реализации promise-объектов. Да, это наше будущее, и оно не столь отдаленно. Можно создавать нативные promise-объекты в Chrome (начиная с версии 32) и Firefox (с версии 27) уже сейчас. Интерфейс достаточно прост. Существует глобальный конструктор Promise. Нужно вызвать этот конструктор и передать resolver в качестве первого параметра. У созданного promise-объекта есть только два метода на настоящий момент — “then” и “catch” :
1 | var promise = new Promise(function(resolve, reject){}).then(function(value){}, function(errorReason){}); |
Благодарим за внимание и ждем ваших вопросов и комментариев.