О применении $.Deferred в работе с асинхронными задачами

в 10:10, , рубрики: asynchronous, deferred, javascript, jquery, promises, асинхронность, асинхронные задачи, примеры, метки: , , , , , ,

Привет всем!

В данной статье я хотел бы поделиться с вами соображениями о том, как на практике можно использовать механизм работы с асинхронными процессами, предоставляемый библиотекой jQuery с версии 1.5 под названием deferred, «отложенный» (jQuery.Deferred), а также со связанными объектами и методами.

Разумеется, уже написан не один десяток статей на тему работы с парой deferred/promise. Своей же я задался целью предоставить такой набор знаний, который дал бы новичку, во-первых, возможность забыть о своих страхах перед непонятным и сложным и, во-вторых, сделать еще один шаг к написанию понятного и хорошо структурированного кода, работающего с асинхронными процессами. Я бы хотел сосредоточить свое и ваше внимание на проблемах, которые легко разрешаются ипользованием deferred, на предпосылках и типовых схемах использования этого объекта.

Асинхронные процессы

Итак, асинхронный процесс предполагает, что он может выполняться параллельно с другим кодом, а результат его выполнения становится доступным вызывающей его программе не сразу. Это, конечно, странно: не синхронные ли действия, это те, которые выполняются параллельно друг с другом, а не последовательно, один за другим? В оправдание устоявшейся терминологии: говоря, что процесс «синхронный», мы исходим из того, что момент, когда появляется его результат, т.е. конец одного процесса, совпадает с моментом начала выполнения какого-то следующего, и их конец и начало синхронизированы.

var result = readValueBy(key);
console.info(result);

А свойство «асинхронный» говорит, что результат процесса наступит, но точное место в программе, где это произойдет, указать невозможно. Чтобы описать логику обработки результатов асинхронного процесса программисты прибегают к функциям обратного вызова, или колбэкам (callback functions), передавая в них результаты работы в виде ее фактических входных параметров.

Пример асинхронного процесса

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

$.post("ajax/test.html", function( data ) {
    $( ".result" ).html( data );
});

Тем более, это — пример из официальной документации jQuery к методу $.post() (см.).
Отметим, что вторым параметром в метод передается колбэк-функция, которая принимает в качестве первого аргумента результат — ответ сервера.

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

$.ajax({
  type: "POST",
  url: "ajax/test.html",
  data: data,
  success: function( data ) {
        $( ".result" ).html( data );
    },
  dataType: dataType
});

В этом примере обработчик успешного завершения передается в виде свойства success объекта вместе с другими параметрами.

Но, начиная с версии jQuery 1.5, ajax-методы ($.get, $.post, $.ajax) можно вызывать, не передавая им обработчики, т.к. эти методы возвращают не просто объект XMLHTTPRequest, а этот объект, перегруженный методами, реализующими интерфейс Promise. С практической точки зрения это означает, что, инициировав асинхронный процесс (=запрос к серверу), мы можем не спешить указывать колбэки на успешное выполнение, на обработку ошибочной ситуации и т.д., и не ограничивать себя одним, когда нам понадобится их указать. Мы можем сохранить запрос к серверу в переменной:

function api(key, data) {
  return $.post('ajax/' + key, data);
}
// ..
var requestData = { id: '79001' }, 
  storeDataRequest = api('get_store_by_id', requestData); // вот эта переменная

… и впоследствии «навесить» все необходимые нам колбэки:

function addStandardHandlers(request, requestData, model) {
  request.done(function(data){
    // модель получает вызов, в котором ей передаются в виде параметров 
    // запрошенные данные и разобранный ответ от сервера
    model.received(requestData, data && data.payload);
  }).fail(function(){
    // при ошибке обращения к серверу
    // модель получает вызов, в котором запрошенным параметрам соответствует нулевой ответ
    model.received(requestData, null);
  });
}
function addUIHandlers(request, $messageHolder) {
  // при успешном окончании..
  request.done(function(data){
    // .. надо написать "Успех", 
    $messageHolder.text('Success');
  }).fail(function(){ // а при ошибке - 
    // "Ошибка, повторите снова" 
    $messageHolder.text('Error, please retry.');
  }).always(function(){ // но все равно, что это было, ошибка или успех,
    // через три секунды после этого медленно скрыть
    window.setTimeout(function(){ $messageHolder.fadeOut(); }, 3000);
  });
}

// добавляем обработчики, ответственные за данные
addStandardHandlers(storeDataRequest, requestData, context.getModel());
// добавляем обработчики, ответственные за UI
addUIHandlers(storeDataRequest, $('.main_page-message_holder'));

Если в нашей программе есть сто и одно место, где нам требуется обратиться к серверу, а структура ответа везде требует однотипной обработки, логично не плодить однообразный код обработчиков в местах вызова сервера, а «навешивать» на каждый экземпляр запроса в зависимости от нужды, воспользовавшись знанием, что ajax-запросы теперь еще и обещания (promise). См. про обещания ниже.

Другие асинхронные процессы

Давайте отступим на шаг и перечислим примеры иных асинхронных процессов, с которыми нам приходится иметь дело:

  • загрузка одной или нескольких картинок (с последующим установлением, какие у нее размеры, и принятием какого-то решения, когда картинки загрузились или дали сбой)
  • наступление состояния готовности документа (событие dom ready)
  • наступление состояния тайм-аута
  • завершение длительных вычислений, реализованных на итераторе и нулевом тайм-ауте
  • завершение анимации на элементах страницы
  • результат работы диалогового окна («ОК/Cancel») (например, диалоговое окно можно закрыть и кнопкой Cancel, и крестиком сверху, и даже щелчком в фон вокруг окна, но это все равно можно трактовать, как недостижение операции, предлагавшейся в диалоге).
  • одновременное наступление разнородных событий (например, соблюдение всех условий в форме ввода; загрузка всех картинок из списка И загрузка файла с шаблоном) или наступление хотя бы одного из ожидаемых событий (кнопка «перезагрузить» с тайм-аутом в 10 секунд).

Какие проблемы решает Deferred?

Задумаемся о ситуациях, когда нам нужно нечто большее, чем передача колбэка в виде параметра функции или свойства объекта (как мы это видели в простых примерах использования ajax выше):

  1. Как назначить больше одного колбэка для одного и того же события завершения процесса?
  2. Как распознать наступление ожидаемого события уже после его завершения?
  3. Как сгруппировать все колбэки-обработчики по типам обрабатываемой ситуации вокруг одного процесса?

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

window.onload = (function(oldHandler){
	return function(){
		if (oldHandler)
			oldHandler();
		// new code here.. 
	};
})(window.onload);

Хорошо, что это уже проблема давно минувших дней. Сегодня никто так не делает (надеюсь) и достаточно помнить про $.on(..) (работая с jQuery), если мы говорим о событиях документа. И про $.Deferred() — когда мы говорим про все остальное. (Простите за намеренное преувеличение, оно служит целям повествования.) Ну а «что делать множественными обработчиками?» — это уже другой вопрос. Допустим, одним, например, можно парсить данные, а другим — спрятать индикатор занятости или сменить текст статуса, как это предложено в примере выше. Раньше мы вынуждены были засунуть все, и работу и с моделью, и с отображением, в один обработчик, теперь у нас есть свобода разделять.

Также, несколько лет назад второй пункт решался каким-то нагромождением вокруг переменной-флажка, на который было замкнуто тело обработчика. Отчетливо помню, как мы соображали, как сообщить нашему виджету, что dom ready уже давно состоялся к тому времени, как он подгружен. Но появилась $(function(){ ... }), которая сейчас реализована в jQuery при помощи deferred. А это значит, что колбэк в этой конструкции сработает даже тогда, когда код исполняется после наступления самого события dom ready. Схема использования проста. «Навешиваете» обработчик. Если процесс уже завершился, обработчик выполняется сразу же. Если нет, обработчик (все обработчики этого типа) будет выполнен в момент его наступления в будущем.

Посмотрим в сторону deferred для решения третьей проблемы. С deferred у нас есть группа обработчиков для успешного завершения процесса, есть — для неудачного (ошибочного или непредвиденного) завершения, есть группа, которая вызовется при любом окончании («полюбому»). Также есть группа, обработчики в которой могут отслеживать прогресс выполнения операции.

Итак, мы рассмотрели три проблемы, решаемые использованием $.Deferred().

Предпосылки использования Deferred

Давайте обозначим условия, когда нам надо задуматься об использовании deferred в своей программе.

Мы подумаем об использовании Deferred тогда..

  1. когда нам требуется дождаться наступления какого-то состояния и сохранить индикатор его наступления и/или связанные с ним данные. Ожидание наступления состояния — это первое условие.
  2. Второе условие — это когда мы задумываемся о взаиморасположении кода длительного процесса и кода обработки его изменения/завершения. Deferred позволяет «отвязать» по месту определение способа получения данных/результата от определения способа их приема и последующей обработки.

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

Вторая предпосылка. Давайте взглянем на код:

$.post("ajax/cart", function( data1 ) {
  // Шаг 1.   
  // проверка валидности data1
  // if (data1 && data.success) ....
  // Шаг 2.
  // Логика по обработке полученных данных
  var processedData1 = data1.payload;
  // Шаг 3.
  // Использование обработанных данных для следующего запроса:
  $.post("ajax/shipping", processedData1, function( data2 ) {
    // Шаг 1.   
    // Шаг 2.
    var processedData2 = data2.payload;
    // Шаг 3.
    $.post("ajax/payment", data2, function( data3 ) {
      // Шаг 1. 
      // Шаг 2.
      var processedData3 = data3.payload;
      // Шаг 3. Использование обработанных данных
      $( ".result" ).html( processedData3 );
    });
  });
});

Первый запрос получает данные, на основании которых делается второй запрос, а на основании его результата — последующие и т.д. Тут только три запроса и логика лишь обозначена, а у нас уже известный антипаттерн «Пирамида злого рока» (pyramid of doom).

В одном из проектов, в котором мне довелось участвовать, решалась задача «обертывания» скрытно работающего сайта платежной системы (который был написан на CGI и был неподдерживаемым, так что о его стилизации, модификации и встраивании в наш сайт речи не шло) — красивой страничкой на WordPress, которая «в тени», при помощи асинхронных get- и post- запросов, эмулировала действия пользователя (заход на страницу корзины, пересчет товаров, отправка формы, получение страницы подтверждения отправки, ввод адреса, отправка второй формы, ввод данных об оплате..) Знаю-знаю. Плохо это. Мне стыдно вспоминать детали. В свое время я побоялся сходить туда и отрефакторить, т.к. «оно работало». Но самое жуткое — это то, что метод, содержащий логику оплаты имел то ли шесть, то ли семь уровней вложенности и растягивался экранов на десять по высоте. Что уж говорить, при таком решении выбор между поддержкой только этого метода и освоением CGI с нуля становился неочевидным, с легким перевесом в пользу CGI. Что бы я сейчас сделал с подобным решением (притворимся, что имитация последолвательности совершения покупки это «окей»)?

Во-первых, я бы разбил логику метода на шаги. Позвольте продемонстрировать на основании примера выше:

function purchaseStep01_CartDataRequestFor(input) {
    return $.post("ajax/cart", input);
}
function purchaseStep02_UpdateShippingInfoRequestFor(input) {
    return $.post("ajax/shipping", input);
}
function purchaseStep03_SubmitPaymentDetailsRequestFor(input) {
    return $.post("ajax/payment", input);
}

Во-вторых, запрос мало послать, надо предусмотреть как общий функционал разбора ответов:

// на вход отдаем:
// запрос-обещание и - опционально - функцию 
// с логикой обработки/трансформации полученных данных
function handleResponseData(request, specificLogic) {
    // на выходе получаем объект-promise, у которого состояние fail
    // возникает не только, если AJAX не сработал, но и
    // если в ответе от сервера нет флага success==true
    // (на деле может быть иной способ контроля успешности общения с бекэндом)
    // и если имея функцию обработки данных, мы получаем от нее исключение
    return $.Deferred(function(def){
        request
            .fail(def.reject)
            .done(function(data){
                if (!data || !data.success) 
                    return def.reject();

                if (!specificLogic) 
                    return def.resolve(data);

                try {
                    def.resolve(specificLogic(data));
                } catch (e) {
                    def.reject(e);
                }               
            });
    }).promise();
}

… так и логику для обработки результата каждого запроса в отдельности. Эту логику мы совместим с использованием написанного выше:

// шаг 1. Запрос данных корзины. Сохраняем обещание.
var step01_CartDataPromise = handleResponseData(
    purchaseStep01_CartDataRequestFor(data),
    function (data){
        // логика обработки данных проверяет наличие данных корзины.
        // если их нет - кидаем исключение, которое повлечет событие fail.
        if (!data.cart) throw new Error('Cart info is missing');
        // иначе - отдаем данные, которые придут в параметр cartInfo следующего шага.
        return data.cart;
    }), // <- запятая, т.к. мы продолжаем объявлять локальные переменные и присваивать им значения.

// шаг 2. Происходит только при успешном завершении шага 1.
// строчку ниже можно было бы переписать так:
// step02_ShipInfoPromise = step01_CartDataPromise.then().done(function(cartInfo){...
// then() отдает новое обещание, с нулевым количеством любых обработчиков
// см. сигнатуру этого метода на сайте jQuery
step02_ShipInfoPromise = step01_CartDataPromise.then(function(cartInfo){
    return handleResponseData(
        purchaseStep02_UpdateShippingInfoRequestFor({ id: cartInfo.id}),
        function (data){
            if (!data.shipping) throw new Error('Shipping info is missing');
            return {
                cart: cartInfo,
                shipping: data.shipping
            };
        });
}),

// шаг 3. Наступает при успехе шага 2.
step03_PaymentResultPromise = step02_ShipInfoPromise.then(function(prePurchaseInfo){
    return handleResponseData(
        purchaseStep03_SubmitPaymentDetailsRequestFor(prePurchaseInfo),
        function (data){
            if (!data.payment) throw new Error('Payment gone wrong');
            return data.payment;
        });
}); // <- объявление переменных закончено 

// нам не потребуется в дальнейшем следить за успешностью этого действия, поэтому
// мы не записываем его в переменную
// Если тут мы выберем done() вместо then() то function(paymentInfo).. будет
// исполнена вместе с function(prePurchaseInfo).. из предыдущего шага.
// См. объяснение к шагу 2.
step03_PaymentResultPromise.then(function(paymentInfo){
    $('.result').html( paymentInfo.message );
});

Минусы такого решения:

  • код записывается большим количеством строк из-за обрамления логики функциями
  • надо помнить, как работает конструктор $.Deferred(..), а также метод связывания двух обещаний в последовательность «один-за-другим» — .then(..), — и помнить, что внутри then надо не забывать отдавать promise, т.е. не забывать ставить return перед вызовом функции handleResponseData, которая и возвращает promise.

Плюсы:

  • Разнесение логики посылки данных и логики обработки полученного ответа
  • Расположение логики принятия решения об успешности операции в одном месте и невозможность повлиять на это решение извне. Связка $.Deferred(..).promise() в методе handleResponseData().
  • Расположение логики принятия решения об успешности одной и той же операции на ее разных стадиях. Обратите внимание на вызовы def.reject(..) внутри handleResponseData(). Операция считается неуспешной не только в том случае, если сервер отказался работать, но и в том случае, если он отработал правильно, но вернул недостаточно данных. А также, если данные не прошли валидацию уже после их обработки (ветка catch(e){ .. }). А теперь подумайте, как бы мы реализовали ее без использования $.Deferred.
  • Лучшее структурирование логики, понижение ее вложенности, повышение поддерживаемости.

Мало кто будет спорить, что плюсы перевешивают и числом и качеством.

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

Обещание

Располагая ссылкой на deferred-объект, программа имеет возможность контролировать результат течения процесса (не сам процесс, хотя, иногда и его) и «сообщать» всем слушателям, каким результатом он завершился. А вот объект promise, или «обещание», связанный с данным объектом deferred, дает программе набор методов исключительно для «слушания». (Интересно отметить, что в статье Википедии на эту тему такой readonly-объект именуется фьючерсом (future), а обещанием называется то, чем в jQuery служит $.Deferred. Попробуйте не запутаться.) На деле такое разделение полномочий предупреждает ряд неловких ситуаций, когда код желает сделать то, что ему «по должности не положено».

var def = $.Deferred();
// логика асинхронного процесса,
// которая "зовет" def.resolve() или def.reject()
//
// получение readonly-объекта promise,
// связанного с def
// для "навешивания" слушателей
var promise = def.promise()

Чаще всего, наиболее удобным и безопасным способом использования ссылки на deferred, является определение его логики внутри функции, передаваемой параметром при создании:

$.Deferred(function(def){
  // тут можно задавать логику нашего асинхронного процесса,
  // вызывая resolve() или reject() 
  // при наступлении определенных условий.
});

Такое использование можно сразу же «отдавать» (return), не забывая, закрывать доступ к этому объекту, вызывая .promise().

Простой пример. Наступление тайм-аута определяет успешное завершение процесса.

function doneTimeout(timeout) {
  return $.Deferred(function(def){
    window.setTimeout(def.resolve, timeout);
  }).promise();
}

Кажется, свое обещание рассказать об обещаниях я выполнил. Примеры.

Примеры

Скажу, что некоторое время назад я совершенно не мог мыслить концепциями deferred/promise и черпал вдохновение в примерах на официальном сайте.

Загрузка картинок

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

var loadImage = createCache(function(defer, url) {
    var image = new Image();
    function cleanUp() {
        image.onload = image.onerror = null;
    }
    defer.then( cleanUp, cleanUp );
    image.onload = function() {
        defer.resolve( url, { width: image.width, height: image.height });
    };
    image.onerror = defer.reject;
    image.src = url;
});

Определение наступления состояния готовности документа

Мы уже говорили о $(function(){.. }) выше. Позвольте лишь привести способ получения объекта-обещания наступления готовности документа в чистом виде:

var domReadyPromise = $.ready.promise();

Наступление состояния тайм-аута

Мы уже рассмотрели пример с тайм-аутом, наступление которого влечет успешное завершение процесса, doneTimeout(). Но теперь нам нужен еще и вариант, в котором по тайм-ауту будет считаться, что процесс завершен с ошибкой. Вот он:

function failTimeout(timeout) {
  return $.Deferred(function(def){
    window.setTimeout(def.reject, timeout);
  }).promise();
}

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

var timeToSubmit = $.Deferred(function(def){
    doneTimeout(10000).done(def.resolve);
    $('.submissionButton').one('click', function(){
        def.resolve();
        return false;
    });
}).promise();

timeToSubmit.done(function(){
    // истекло время ожидания или нажата кнопка
    // можно перезагружать компьютер
}); 

Завершение анимации на элементах страницы

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

function animationPromise($element){
    return $.Deferred(function(def){
        $element.fadeIn( 10000 , def.resolve );
    }).promise();
}

однако, это вовсе излишне. Каждый jQuery элемент поддерживает вызов promise(), возвращая обещание окончания анимации на нем:

function animationPromise($element){
    return $element.fadeIn( 10000 ).promise();
}

«Послушать» окончание анимации можно так:

animationPromise($('#foo')).done(function(){
    console.log('Animation is finished');
});

или просто:

$element.fadeIn( 10000 ).promise().done(function(){
    console.log('Animation is finished');
});

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

$element.fadeIn( 10000 ).fadeOut( 10000 );

Сопровождение работы модального диалога

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

Пример — реализация работы модального окна в Twitter Bootstrap (см. тут). Есть методы — «показать» и «скрыть», есть события «окно открыто», «окно скрыто». Но как нам, программистам, узнать, какой выбор сделал пользователь в этом окне? Т.е. не какой ссылкой или кнопкой он воспользовался, а что он сказал своим действием? Привел ли его выбор к успешному завершению планируемого процесса или к его отмене? Например, диалоговое окно можно закрыть и кнопкой Cancel, и крестиком сверху, и даже щелчком в фон вокруг окна, но это все равно можно трактовать, как недостижение операции, предлагавшейся в диалоге.

Я предлагаю читателю подумать о собственной реализации обертки для модальных окон Twitter Bootstrap, если ему, конечно, эта тема близка. А собственный пример приведу в другой раз.

Объединение ожидания нескольких процессов

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

Штатно jQuery предлагает нам метод $.when(), который принимает обещания, а отдает новое обещание, успех которого обусловлен полным успешным завершением каждого составляющего его обещания, а неудача — хотя бы одной неудачей любого. Это — своеобразная логическая операция И. На выходе ИСТИНА (=успех) только тогда, когда все операнды также ИСТИНА, в остальных случаях — ЛОЖЬ (=неуспех).

Не хватает, как мы видим, аналога логического ИЛИ. Т.е. такого метода, объединяющего обещания, который бы сигнализировал о неуспехе операции тогда и только тогда, когда КАЖДОЕ образующее его обещание также заканчивалось бы неудачей. А в остальных случаях операция заканчивается успехом.

Я приведу тут собственный набросок такой реализации:

// fail if and only if all fail:
function failIifAllFail(_promise_array){
  var promises = [].slice.apply(arguments), count = promises.length;
  return $.Deferred(function(def){
    // счетчики успешных и неуспешных операций
    var done = 0, fail = 0;
    // успех или неудача каждого обещания приводит к увеличению того или иного счетчика
    // а проверка check() устанавливает, можно ли уже считать операцию завершенной 
    // и с каким результатом
    $.each(promises, function wrap(key, p){
      p.done(function(){
        done++;
        check();
      }).fail(function(){
        fail++;
        check();
      });
    });

    function check(){
      if ((done + fail) < count) return;
      if (fail === count) 
        def.resolve();
      else 
        def.reject();
    }
  }).promise();
}

Контроль за длительными вычислениями

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

Я предлагаю читателю самостоятельно подумать над реализацией, ну а затем сравним, что получится в отдельной заметке.

Спасибо за внимание!

Дополнительные ссылки по теме

Автор: andrevinsky

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js