Ещё раз о Deferred/Promise

в 6:05, , рубрики: coffeescript, javascript, jquery, promise, Программирование, метки: , , ,

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

Как я могу судить, для людей, которые на практике не столкнулись с некоторыми специфическими проблемами, эти 2 понятия являются довольно трудными для понимания. И не потому, что понятия Promise и Deferred являются с чем-то сложным, а потому, что довольно непросто сходу выдумать подходящую задачу, что бы апробировать действие Deferred objects (в JQuery и не только).

Да, вероятно для тех, кто знаком с этим вопросом он покажется пустяковым и не стоящим и выеденного яйца. Кроме того, вопрос уже многократно обсуждался. Однако, я наберусь смелости еще раз его затронуть и вот почему: 1) Мне кажется, что для некоторых читателей этот пост может оказаться интересным. 2) Я пойду от практики, а не от теории. Моя задача — продемонстрировать работу инструмента. Теорию и другие варианты применения при необходимости вы найдете в ссылках к посту.

Ниже я попробую показать вам что Promise и Deferred это очень и очень просто. Кроме того, для объяснения этой темы, мне придётся затронуть еще несколько интересных моментов JavaScript.

Что такое Deferred object?: это весьма простой способ отлавливать состояния асинхронных событий.
Зачем их использовать?: например, что бы назначить общий колбэк нескольким AJAX запросам.
Где это можно использовать?: например, вы хотите показать на странице карту. Но только тогда, когда получили информацию обо всех отметках на ней из внешнего источника. Предположим, вы загружаете страницу и карту (в скрытом виде) и показываете прелоадер, в этот момент браузер отправляет на ваш сервер 20 AJAX запросов и получает информацию. И только по завершении всех запросов вам требуется скрыть прелоадер, показать карту, маркеры и блоки контента. Вот так приблизительно можно сформулировать абстрактную задачу.

Далее я буду показывать примеры на CoffeeScript (c JavaScript под спойлером) и мне следует пояснить некоторые моменты, касающиеся CoffeeScript.

1) CoffeeScript это абсолютно тот же JavaScript, но с более чистым и лаконичным синтаксисом.
2) -> в CoffeeScript означает function(){ } в JavaScript
3) (a, b) -> в CoffeeScript означает function(a, b){ } в JavaScript
4) do в CoffeeScript означает () в JavaScript. Т.е вызов функции, имя которой указано после do
5) do (a, b) -> "Hello World!" в CoffeeScript означает (function(a, b){ return "Hello World!"; })(a, b) в JavaScript

Метод apply

Я ленивый и не люблю лишний раз стучать пальцами по клавишам. Поэтому console.log я предпочитаю использовать просто log. Этого легко добиться используя JS метод apply.

Что такое метод apply? Это один из методов Function (Function.apply). А значит, он применим ко всем функциям.
Что делает метод apply? Он вызывается от функции и следовательно влияет на нее. Первый аргумент задает контекст вызываемой функции, т.е. фактически принудительно устанавливает значение this внутри этой функции. Второй аргумент задает массив параметров для функции.
Где это можно использовать? Часто в описании функций можно увидеть, что она принимает на вход список параметров fn(x1, x2, x3, x4, ...), но по странному стечению обстоятельств у вас может оказаться массив переменных и вам бы хотелось передать этот массив в функцию в виде списка, а не одной переменной-массивом. Именно это и помогает сделать метод apply. По сути apply позволяет развернуть массив переменных в список параметров функции.

Зная то, что массив всех значений переданных в функцию в JavaScript можно получить через переменную arguments, сделаем следующий финт ушами:

window.log = -> try console.log.apply(console, arguments)
JS версия

window.log = function() {
  try {
    return console.log.apply(console, arguments);
  } catch (_error) {}
};

Что здесь произошло? Я создал в глобальной области видимости метод log. Все аргументы, которые я получаю (arguments) при вызове метода log я передаю в виде списка функции console.log (сделать мне помогает метод apply). Кроме того, что бы не нарушить логику работы метода console.log, я предпочел передать в качестве первого аргумента в функцию apply объект console. Для браузеров, у которых есть проблемы с console.log я использую try, что бы обезопасить себя.

Еще немного сахара в Coffee

Поставьте ... после массива arguments и вы избавите себя от указания контекста и ручного написания apply. Данный код совершенно идентичен представленному выше.
window.log = -> try console.log arguments...

Если с этим все более-менее понятно, то движемся дальше. Кстати, я не просто так вспомнил про apply — он нам еще пригодится.

Шаг 1. Классическая проблема

Для эмуляции 10 асинхронных запросов используем метод setTimeout. При выполнении он отрывается от основного потока исполнения и «живет своей жизнью». Вот мы и нашли отличный вариант для проверки работы Deferred объектов.

Сделаем простой цикл и запустим setTimeout 10 раз и прологгируем переменную index.

for index in [0...10]
  setTimeout ->
    log index
  , 1000
JS версия

var index, _i;

for (index = _i = 0; _i < 10; index = ++_i) {
  setTimeout(function() {
    return log(index);
  }, 1000);
}

Ровно через секунду мы получим не числа «0 1 2 3 4 5 6 7 8 9», а «10 10 10 10 10 10 10 10 10 10».
Произошло это потому, что к тому момент когда выполнится код внутри setTimeout, цикл уже закончится (он отработает очень быстро), а счетчик index к этому моменту примет крайнее значение и будет равен 10.

Шаг 2. Классическое решение

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

for index in [0...10]
  do (index) ->
    setTimeout ->
      log index
    , 1000
JS версия

var index, _fn, _i;

_fn = function(index) {
  return setTimeout(function() {
    return log(index);
  }, 1000);
};
for (index = _i = 0; _i < 10; index = ++_i) {
  _fn(index);
}

Шаг 3. Как работает deferred?

Deferred объект — это всего лишь хранилище состояния асинхронной функции. Таких состояний обычно несколько:

pending — ожидание завершения процесса
rejected — процесс закончен падением
resolved — процесс закончен успешно

Кроме того у Deferred объекта есть ряд методов, которые могут менять его состояние. Например, метод .resolve().

По состоянию Deferred объекта мы можем судить, закончен ли процесс, состояние которого мы отслеживаем.

Из этого мы сможем сделать вывод, что для использования Deferred объекта, мы должны:

1) создать Deferred объект
2) При завершении асинхронного метода перевести Deferred объект в нужное состояние
3) Передать Deferred объект из текущей функции куда-то, где его состояние будут отслеживать.

Ниже вы видите код, который это иллюстрирует. Подробный разбор я оставляю на откуп заинтересованному читателю. Про return dfd.promise() читайте сразу после примера кода.

for index in [0...10]
  promise = do (index) ->
    dfd = new $.Deferred()
    
    setTimeout ->
      log index
      dfd.resolve()
    , 1000

    return dfd.promise()
JS версия

var index, promise, _i;

for (index = _i = 0; _i < 10; index = ++_i) {
  promise = (function(index) {
    var dfd;
    dfd = new $.Deferred();

    setTimeout(function() {
      log(index);
      return dfd.resolve();
    }, 1000);

    return dfd.promise();
  })(index);
}

Вы обязательно должны обратить внимание на строку return dfd.promise(). И наверняка вы зададите резонный вопрос: «почему при возврате из функции передается не сам Deferred объект, а именно Promise?». Суть в том, что считается, что при передаче Deferred объекта наружу, вы должны исключить из него все методы, которые могут изменить его состояние. Поскольку нигде, кроме строго заданной разрешенной области у Deferred объекта не должно быть возможности изменить свое состояние. Иначе, конечный разработчик может не устоять перед соблазном и нарушить заданный паттерн. Promise как раз и занимается тем, что возвращает урезанную по функционалу копию Deferred.

Шаг 4. Формируем массив «обещаний»

Если нам требуется дождаться выполнения 10 асинхронных функций — мы просто сформируем массив из «обещаний» (видимо обещаний когда-то закончить своё исполнение), которые формируются в функциях внутри цикла. И так, создаем пустой массив и заносим в него новое «обещание» на каждой итерации.

promises_ary = []

for index in [0...10]
  promise = do (index) ->
    dfd = new $.Deferred()
    
    setTimeout ->
      log index
      dfd.resolve()
    , 1000

    dfd.promise()

  promises_ary.push promise 

log promises_ary
JS версия

var index, promise, promises_ary, _i;

promises_ary = [];

for (index = _i = 0; _i < 10; index = ++_i) {
  promise = (function(index) {
    var dfd;
    dfd = new $.Deferred();

    setTimeout(function() {
      log(index);
      return dfd.resolve();
    }, 1000);

    return dfd.promise();
  })(index);

  promises_ary.push(promise);
}

log(promises_ary);

В итоге log покажет нам список объектов.

# => [obj, obj, ...]
Шаг 5. Используем массив «обещаний»

А теперь очень просто повесить колбэк, который будет вызван только после выполнения всех асинхронных функций. Для этого нам потребуется JQuery метод $.when. В документации $.when описан как метод принимающий на вход список параметров. Однако, мы на входе имеем массив параметров promises_ary. Как не трудно догадаться мы вновь используем метод apply.

promises_ary = []

for index in [0...10]
  promise = do (index) ->
    dfd = new $.Deferred()
    
    setTimeout ->
      log index
      dfd.resolve()
    , 1000

    dfd.promise()

  promises_ary.push promise 

$.when.apply($, promises_ary).done ->
  log 'Promises Ary is Done'
Шаг 6. Немного асинхронности

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

rand = (min, max) -> Math.floor(Math.random() * (max - min + 1) + min)

promises_ary = []

for index in [0...10]
  promise = do (index) ->
    dfd = new $.Deferred()
    
    setTimeout ->
      log index
      dfd.resolve()
    , rand(1, 5) * 1000

    dfd.promise()

  promises_ary.push promise 

$.when.apply($, promises_ary).done ->
  log 'Promises Ary is Done'
JS версия

var index, promise, promises_ary, _i;

rand = function(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
};

promises_ary = [];

for (index = _i = 0; _i < 10; index = ++_i) {
  promise = (function(index) {
    var dfd;
    dfd = new $.Deferred();
    
    setTimeout(function() {
      log(index);
      return dfd.resolve();
    }, rand(1, 5) * 1000);

    return dfd.promise();
  })(index);

  promises_ary.push(promise);
}

$.when.apply($, promises_ary).done(function() {
  return log('Promises Ary is Done');
});

В завершении следует сказать, что все AJAX запросы в JQuery с версии 1.5 используют механизм Deferred/Promise, поэтому работа с ними еще больше упрощается.

На этом я остановлюсь. Базовый пример с объяснениями, на мой взгляд, завершен. Думаю этого будет достаточно, что бы продолжить более глубокое изучение техники Deferred/Promise по другим источникам. Надеюсь этот пост однажды кому-то поможет.

  1. Deferred Object (JQuery official)
  2. jQuery Deferred Object (Habr)
  3. Использование Deferred объектов в jQuery 1.5 (Habr)
  4. CommonJS Promises
  5. AngularJS (promise/deferred)
  6. Deferred объекты в AngularJS
  7. Promise-ы в AngularJS
  8. Promises for ActionScript 3.0
  9. Объект deferred

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

Вопрос к обсуждению: Было бы интересно узнать о тех задачах, где вы использовали Deferred/Promises.

Автор: zayko

Источник

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


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