Написать пост

Знакомство с promises — одним из нововведений ES6

Аватар Иван Бирюков

Что такое promise?

Вообще говоря, promises (дословно — “обещания”) — это обёртки для функций обратного вызова (callback). Их можно использовать для упорядочивания синхронных и асинхронных действий.

С помощью promises вы сможете переписать:

			$.ajax({
  url: '/value',
  success: function(value) {
    var result = parseInt(value) + 10;
    setTimeout(function() {
      console.log(result);
    }, 5000);
  }
});
		

… в таком виде:

			ajax({ url: '/value' }).then(function(value) {
  return parseInt(value) + 10;
}).then(
  delay(5000)
).then(function(result) {
  console.log(result);
})
		

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

Обещание поддерживает 2 метода: then и catchthen принимает продолжение, являющееся функцией обратного вызова, принимающей результат в качестве аргумента и возвращающей новое обещание или другое значение. Аналогично,  catch — это callback, вызываемый при возникновении исключения или другой ошибки.

Полная версия then в себя оба поведения:

			promise.then(onFullfilled, onRejected)
		

Так, catch можно определить следующим образом:

			promise.catch(onRejected) := promise.then(null, onRejected)
		

Как создать обещание?

Для создания обещания вам не нужно создавать объект с методами then и catch. Вместо этого можно использовать конструктор Promise:

			var promise = new Promise(function(resolve, reject) {
  // maybe do some async stuff in here
  resolve('result');
});
		

Достаточно вызвать resolve, когда обещание выполнено, или вызвать reject, если что-то пошло не так. Также можно сгенерировать исключение. Если вы хотите обернуть значение в обещание, которое будет выполнено немедленно, можно просто написать Promise.resolve(value). В обратном случае достаточно написать Promise.reject(error).

Таймауты

Функция setTimeout используется для выполнения кода после определенной задержки. Вот её версия, реализованная с помощью promises:

			function delay(milliseconds) {
  return function(result) {
    return new Promise(function(resolve, reject) {
      setTimeout(function() {
        resolve(result);
      }, milliseconds);
    });
  };
}
		

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

			delay(1000)('hello').then(function(result) {
  console.log(result);
});
		

Вы можете удивиться, что delay каррирована. Это сделано для того, чтобы её можно было включить в последовательность после другого обещания:

			somePromise.then(delay(1000)).then(function() {
  console.log('1 second after somePromise!');
});
		

AJAX

AJAX — классический пример использования promises. Если вы используете jQuery, вы увидите, что $.ajax не вполне совместима с promises, потому что не поддерживает catch. Но мы с легкостью можем создать обёртку:

			function ajax(options) {
  return new Promise(function(resolve, reject) {
    $.ajax(options).done(resolve).fail(reject);
  });
}
		

Пример:

			ajax({ url: '/' }).then(function(result) {
  console.log(result);
});
		

Или, если вы не пользуетесь jQuery, вы можете создать обёртку для XMLHttpRequest:

			function ajax(url, method, data) {
  return new Promise(function(resolve, reject) {
    var request = new XMLHttpRequest();
    request.responseType = 'text';
    request.onreadystatechange = function() {
      if (request.readyState === XMLHttpRequest.DONE) {
        if (request.status === 200) {
          resolve(request.responseText);
        } else {
          reject(Error(request.statusText));
        }
      }
    };
    request.onerror = function() {
      reject(Error("Network Error"));
    };
    request.open(method, url, true);
    request.send(data);
  });
}
		

Пример:

			ajax('/', 'GET').then(function(result) {
  console.log(result);
});
		

Отложенное исполнение

Вопрос: в каком порядке будут выведены строки после исполнения этого кода?

			new Promise(function(resolve, reject) {
  console.log('A');
  resolve();
}).then(function() {
  console.log('B');
});
console.log('C');
		

Ответ: A, C, затем B.

Удивительно, правда? thenпереходник отложен, а тот, что передан в конструктор Promise — нет. Но мы можем воспользоваться таким поведением then. Часто хочется отложить выполнение некоторого куска кода до завершения асинхронной части. Раньше для этого можно было использовать setTimeout(func, 1) но теперь для этого можно написать обещание:

			var defer = Promise.resolve();
		

Его можно использовать так:

			defer.then(function() {
  console.log('A');
});
console.log('B');
		

Этот код выведет B, затем A. Хотя это и не короче, чем setTimeout(func, 1), это разъясняет наши намерения и совместимо с другими обещаниями. Таким образом, мы получаем более структурированный код.

Финальные замечания

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

Я был удивлён несколькими вещами:

  1. Функции обратного вызова для then и catch могут возвращать любое значение, но они ведут себя по-другому. Обычно, возвращаемое значение передаётся следующему за then выражению в цепочке. Но если это значение — обещание, дальше передаётся значение, возвращаемое обещанием при его выполнении. Это значит, что возврат Promise.resolve(x) эквивалентен возврату x.
  2. Функция, переданная в конструктор Promise, выполняется синхронно, но любые продолжения через then или catch будут отложены до следующего цикла событий. Этим объясняется работа defer.
  3. catch — это  зарезервированное ключевое слово, используемое для обработки исключений в JavaScript, но это также и имя метода, закрепляющего обработчик ошибок в обещании. Это похоже на неудачную коллизию имён. С другой стороны,  это облегчает запоминание, так что не всё так плохо!

Первый пункт кажется мне странным из-за следующего мысленного эксперимента: что, если мы действительно хотим передать обещание следующей функции в цепочке? Если мы попытаемся сделать это таким наивным образом, обещание лишится обёртки, и мы не получим желаемый результат. Вы можете спросить: зачем вообще передавать обещание в callback? Ну, может быть, код не знает, какого типа значение он передаёт в функцию. Может быть, значение создаётся где-то ещё в программе, и оно просто должно быть передано. Теперь нужно сперва проверить, если это обещание, и в этом случае обернуть его во что-то ещё для защиты на время передачи. Поэтому я бы предпочёл, чтоб функции обратного вызова всегда должны были возвращать обещание (даже если значение обёрнуто в Promise.resolve(value)).

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

Несмотря на эти странности, promises — приятное дополнение к JavaScript. Ад callback’ов был одним из моих самых нелюбимых аспектов JavaScript, но теперь его можно избежать.

P.S. Promise — это монада! Ну или, как минимум, могло бы ею быть, если бы не первый пункт.

Следите за новыми постами
Следите за новыми постами по любимым темам
26К открытий26К показов