Skip to content

Latest commit

 

History

History
2126 lines (1501 loc) · 196 KB

File metadata and controls

2126 lines (1501 loc) · 196 KB

Вы не знаете JS: Асинхронность и производительность

Глава 3: Промисы

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

Проблема, с которой мы хотим начать - инверсия управления (IoC), это то доверие, которое так тяжело сохранять и так легко потерять.

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

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

Но что если бы мы смогли разинверсировать эту инверсию управления? Что если вместо передачи продолжения программы в другой код мы могли бы ожидать от него дать нам возможность узнать когда его задача завершится и затем наш код мог бы решить что делать дальше?

Эта концепция называется Промисы.

Промисы начинают быстро покорять мир JS, поскольку разработчики и создатели спецификаций в равной мере отчаянно ищут возможность избавиться от безумия ада колбеков в своим коде/дизайне. На самом деле, многие новые асинхронные API добавляются в платформу JS/DOM будучи построенным на промисах. Так что, возможно, это неплохая идея уйти с головой и изучить их, Вы так не думаете!?

Примечание: Слово "сразу" будет часто использоваться в этой главе, в основном указывая на какое-либо действие по разрешению промиса. Однако, в фактически всех случаях, "сразу" в терминах поведения очереди заданий означает (см. главу 1) не строго синхронное значение сейчас.

Что такое промис?

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

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

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

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

Будущее значение

Представьте такой сценарий: Я подхожу к стойке в ресторане быстрого питания и делаю заказ на чизбургер. Я даю кассиру $1.47. Разместив и оплатив свой заказ, я сделал запрос возврата значения (чизбургера). Я открыл транзакцию.

Но частенько, чизбургер не сразу мне доступен. Кассир даем мне что-то взамен моего чизбургера: чек с номером заказа в нем. Этот номер заказа - это IOU-обещание (промис) (Я должен вам ("I owe you")) которое гарантирует, что в итоге я должен получить свой чизбургер.

Так что я храню мой чек и номер заказа. Я знаю, что оно представляет собой мой будущий чизбургер, поэтому мне не надо о этом больше волноваться кроме ощущения голода!

Пока я жду Я могу заниматься другими делами, например отправить текстовое сообщение другу, говорящее: "Эй, как насчет присоединиться ко мне за обедом? Я собираюсь съесть чизбургер."

Я уже рассуждаю о своем будущем чизбургере, несмотря на то, что у меня еще его нет. Мой мозг способен так делать потому что он воспринимает номер заказа как заменитель чизбургера. Такой заменитель фактически делает значение независимым от времени. Это и есть будущее значение.

В итоге я слышу "Заказ 113!" и радостно иду обратно к стойке с чеком в руках. Я передаю чек кассиру и взамен беру свой чизбургер.

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

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

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

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

Значения Сейчас и Позже

Всё это может прозвучать слишком абстрактно для применения в вашем коде. Так давайте внесем больше конкретики.

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

Когда вы пишете код, чтобы обработать каким-либо образом некое значение, например выполнив математические вычисления над числом, то осознанно или нет, вы предполагаете кое-что очень фундаментальное об этом значении, а именно, что это - уже конкретное значение сейчас:

var x, y = 2;

console.log( x + y ); // NaN  <-- потому что в `x` еще не установлено значение

Операция x + y предполагает, что оба x и y уже заданы. В терминах, которые мы вскоре разъясним, мы полагаем что значения x и y уже разрешены (т.е. с уж определенными значениями).

Будет абсурдом ожидать, что оператор + сам по себе каким-то магическим образом сможет определить и ждать до тех пор, пока оба x и y разрешатся (т.е. будут готовы), и только затем выполнит операцию. Это может привести к хаосу в программе, если одни выражения закончатся сейчас, а другие закончатся позже, не так ли?

Как вы сможете потенциально рассуждать о связях между двумя выражениями, если одно из них (или оба) могут быть еще не завершены? Если выражение 2 полагается на то, что выражение 1 будет завершено, то возможны два исхода: либо выражение 1 закончится прямо сейчас и всё благополучно продолжится, либо statement 1 еще не завершено, и в итоге выражение 2 приведет к ошибке.

Если такие вещи звучат знакомо после главы 1, хорошо!

Давайте вернемся к нашей математической операции x + y. Представьте, что был бы путь сказать, "Сложи x и y, но если кто-то из них еще не подготовлен, просто подожди пока это не произойдет. Сложи их как можно скорее."

Ваш мозг возможно сразу переключился на колбеки. Хорошо, итак...

function add(getX,getY,cb) {
	var x, y;
	getX( function(xVal){
		x = xVal;
		// оба готовы?
		if (y != undefined) {
			cb( x + y );	// отправить сумму
		}
	} );
	getY( function(yVal){
		y = yVal;
		// оба готовы?
		if (x != undefined) {
			cb( x + y );	// отправить сумму
		}
	} );
}

// `fetchX()` and `fetchY()` синхронные или асинхронные
// функции
add( fetchX, fetchY, function(sum){
	console.log( sum ); // это было легко, ага?
} );

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

Хотя это уродство и несомненное, тут есть кое-что очень важное на заметку об этом асинхронном шаблоне.

В этом кусочке кода, мы трактовали x и y как будущие значения и мы выразили операцию add(..) так, что она (снаружи) не заботится о том, доступен ли x или y прямо сейчас или нет. Другими словами, он нормализует сейчас и потом таким образом, что мы можем положиться на предсказуемый результат операции add(..).

При использовании add(..), которая временно консистентна, она ведет себя одинаково сейчас и потом - такой асинхронный код легче себе представлять.

Выражаясь более просто: чтобы обработать согласованно оба сейчас и потом, мы оба их превращаем в потом: все операции становятся асинхронными.

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

Промис как значение

Мы определенно углубимся в детали промисов позже в этой главе, поэтому не волнуйтесь если что-то тут покажется запутанным, а просто мельком взгляните на то, как мы выразим пример x + y через Promiseы:

function add(xPromise,yPromise) {
	// `Promise.all([ .. ])` принимает массив промисов
	// и возвращает новый промис, который ожидает завершения всех переданных
	return Promise.all( [xPromise, yPromise] )

	// когда промис разрешен, давайте возьмем
	// полученные значения `X` и `Y` и сложим их.
	.then( function(values){
		// `values` - массив сообщений от
		// ранее разрешенных промисов
		return values[0] + values[1];
	} );
}

// `fetchX()` и `fetchY()` возвращают промисы для
// своих соответствующих значений, которые могут быть готовы
// *сейчас* или *позже*.
add( fetchX(), fetchY() )

// мы получаем обратно промис с суммой этих
// двух чисел.
// теперь мы выполняем в цепочке вызов `then(..)`, чтобы дождаться разрешения
// этого возвращенного промиса.
.then( function(sum){
	console.log( sum ); // это намного легче!
} );

В этом кусочке кода есть два слоя промисов.

fetchX() и fetchY() вызываются напрямую и возвращаемые или значения (промисы!) передаются в add(..). Внутренние значения, которые представляют эти промисы, могут быть готовы сейчас или позже, но каждый промис приводит свое поведение к тому, чтобы вести себя одинаково вне зависимости ни о чего. Мы рассуждаем о значениях X и Y во время-независимой манере. Они - будущие значения.

Второй уровень - это промис, который создается в add(..) (через Promise.all([ .. ])) и возвращается, и который мы ожидаем вызвав then(..). Когда операция add(..) завершена, наше будущее значение sum готово и можем вывести его на экран. Внутри add(..) мы скрываем всю логику ожидания будущих значений X и Y.

Примечание Внутри add(..), вызов Promise.all([ .. ]) создает промис (который ждем разрешения promiseX и promiseY). Цепочечный Вызов .then(..) создает еще один промис, который сразу же разрешает строку return values[0] + values[1] (с результатом сложения). Таким образом, вызов then(..), который мы поместили в конец цепочки вызова add(..) в конце фрагмента кода, в сущности работает с этим вторым возвращенным промисом, а не с первым, созданным Promise.all([ .. ]). Также, хотя мы и не добавили ничего в конец цепочки второго then(..), он также создает еще один промис, невзирая на то, хотим мы его использовать или нет. Эту штуку с цепочками промисов мы поясним в деталях позже в этой главе.

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

С использованием промисов, вызов then(..) фактически может принимать две функции: первую - для завершения (как показано ранее), а вторую - для отказа:

add( fetchX(), fetchY() )
.then(
	// обработчик завершения
	function(sum) {
		console.log( sum );
	},
	// обработчик отказа
	function(err) {
		console.error( err ); // облом!
	}
);

Если что-то пошло не так при получении X или Y, или что-то каким-либо образом привело к сбою во время сложения, промис, который возвращается из add(..) - отвергается (завершается отказом) и второй колбек-обработчик ошибок, переданный в then(..) получит значение отказа из промиса.

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

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

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

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

Промисы - это легко повторяемый механизм инкапсуляции и совмещения будущих значений.

Событие завершения

Как мы только что видели, одиночный промис ведет себя как будущее значение. Но есть и другой путь представлять разрешение промиса: как механизм потокового управления, временнОе "это-затем-то" для двух и более шагов в асинхронной задаче.

Давайте представим вызов функции foo(..) для выполнения некой задачи. Мы либо не знаем ничего о ее внутренней реализации, или просто не беспокоимся об этом. Она может завершить задачу сразу или может занять некоторое время.

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

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

Примечание Назовете ли вы это "событием завершения" или "событием продолжения" зависит от вашей точки зрения. На чем больше смещен фокус: на том что случится в foo(..) или на том что произойдет после завершения foo(..)? Обе точки зрения точны и полезны. Уведомление о событии сообщает нам, что foo(..) завершилась, но также и то, что можно продолжить выполнение следующего шага. Безусловно, тот колбек, который вы передаете, чтобы он был вызван для уведомления о событии, сам по себе то, что мы ранее назвали продолжение. Потому что событие завершения немного более сфокусировано на foo(..), что больше привлекает наше внимание в настоящий момент, we все же чуть больше отдаем предпочтение событию завершения до конца этого текста.

С использованием колбеков, "уведомлением" будет наш колбек, вызванный задачей (foo(..)). Но с промисами, мы переворачиваем отношения и ожидаем, что можем ждать событие от foo(..) и как только получим его может действовать соответственно.

Сперва, обратите внимание на этот псевдокод:

foo(x) {
	// начинаем выполнять что-то, что требует времени
}

foo( 42 )

on (foo "completion") {
	// теперь мы можем выполнить следующий шаг!
}

on (foo "error") {
	// ой, что-то пошло не так в `foo(..)`
}

Мы вызываем foo(..), а затем настраиваем два обработчика событий, один для "completion" (завершение), а другой для "error" (сбоя)-- двух возможных окончательных исхода вызова foo(..). По сути, не похоже, что foo(..) вообще в курсе о том, что вызывающий код подписался на эти события, что ведет к очень хорошему разделению обязанностей.

К сожалению, такой код потребовал бы некоторой "магии" окружения JS, которое не существует (и которое бы весьма вероятно было бы немного непрактичным). Вот более естественный путь, которым мы может это выразить в JS:

function foo(x) {
	// начнем выполнять что-нибудь, требующее времени

	// создадим обработчик оповещения о событии `listener`,
	// чтобы его можно было вернуть из функции

	return listener;
}

var evt = foo( 42 );

evt.on( "completion", function(){
	// теперь мы можем выполнить следующий шаг
} );

evt.on( "failure", function(err){
	// ой, что-то пошло не так в `foo(..)`
} );

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

Инверсия обычного колбек-ориентированного кода должно быть очевидна и это намеренно. Вместо передачи колбеков foo(..), она возвращает возможность получения событий, которую мы назвали evt, которая получает колбеки.

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

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

var evt = foo( 42 );

// пусть `bar(..)` получает уведомление о завершении `foo(..)`
bar( evt );

// пусть `baz(..)` также получает уведомление о завершении `foo(..)`
baz( evt );

Разинверсия управления открывает возможность лучшего разделения обязанностей, где функциям bar(..) и baz(..) не нужно быть вовлеченными в то, как вызывается foo(..). Аналогично, функции foo(..) не нужно ни знать, ни беспокоиться о том, что bar(..) и baz(..) существуют или ждут уведомления о завершении foo(..).

Фактически, это объект evt - это нейтральный сторонний посредник между отдельными функциональными обязанностями.

"События" промиса

Как вы можете теперь предположить, возможность получения события evt - это аналогия для промиса.

В промисо-ориентированном подходе предыдущий блок кода мог бы содержать foo(..), создающую и возвращающую экземпляр Promise, и этот промис был бы передан в bar(..) и baz(..).

Примечание "События" разрешения промиса, который мы ждем, не являются событиями в строгом смысле (хотя они определенно ведут себя как события в этом сценарии), и они не просто вызывают "completion" или "error". Вместо этого, мы используем then(..), чтобы зарегистрировать событие "then". Или чуть более точно, then(..) регистрирует событие(я) "fulfillment" (выполнения) и/или "rejection" (отказа), хотя мы и не видим эти термины в явном виде в коде.

Взгляните:

function foo(x) {
	// начнем выполнять что-нибудь, требующее времени

	// создает и возвращаем промис
	return new Promise( function(resolve,reject){
		// в итоге вызвать `resolve(..)` или `reject(..)`,
		// которые являются колбеками разрешения для промиса.
	} );
}

var p = foo( 42 );

bar( p );

baz( p );

Примечание Шаблон, показанный с new Promise( function(..){ .. } ) - обычно называется"открытый конструктор (revealing constructor)". Переданная функция выполняется сразу (а не асинхронно отложенным вызовом, как колбеки в then(..)) и туда передаются два параметра, который в этом случае называются resolve и reject. Это функции разрешения промиса. resolve(..) обычно сигнализирует о выполнении, а reject(..) - об отказе.

Вы вероятно сможете угадать, как выглядят внутри bar(..) и baz(..):

function bar(fooPromise) {
	// ждать завершения `foo(..)`
	fooPromise.then(
		function(){
			// `foo(..)` теперь закончена, так что
			// выполняем задачу `bar(..)`
		},
		function(){
			// ой, что-то пошло не так в `foo(..)`
		}
	);
}

// то же самое для `baz(..)`

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

Еще один путь добиться этого:

function bar() {
	// `foo(..)` определенно завершилась, поэтому
	// выполняем задачу `bar(..)`
}

function oopsBar() {
	// ой, что-то пошло не так в `foo(..)`,
	// поэтому `bar(..)` не был запущен
}

// то же самое для `baz()` и `oopsBaz()`

var p = foo( 42 );

p.then( bar, oopsBar );

p.then( baz, oopsBaz );

Примечание Если вы уже видели раньше промис-ориентированный код, у вас может возникнуть соблазн поверить, что две последних строки этого кода можно записать как p.then( .. ).then( .. ), используя цепочку вызовов вместо p.then(..); p.then(..). Это было бы совершенно другое поведение, будьте осторожны! Прямо сейчас разница может быть неочевидна, но это на самом деле совершенно другой асинхронный шаблон, нежели мы видели до сих пор: разделение/разветвление. Не волнуйтесь! Мы вернемся к этому позже в этой главе.

Вместо передачи промиса p в bar(..) и baz(..), мы используем промис, чтобы управлять когда bar(..) и baz(..) будут выполнены, если вообще будут. Главное отличие - в обработке ошибок.

В подходе, использованном в первом примере кода, bar(..) вызывается независимо от того, foo(..) или нет, и в нем выполняется своя собственная логика возврата, если получается сообщение о том, что в foo(..) произошел сбой. Очевидно, что то же самое верно и для baz(..).

Во втором примере кода, bar(..) вызывается только если foo(..) завершена, а иначе вызывается oopsBar(..). То же самое для baz(..).

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

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

Более того, факт того, что оба примера кода заканчиваются вызовом then(..) дважды для одного и того же промиса p иллюстрирует рассказанное ранее, то что промисы (если уже разрешены) остаются в том же состоянии разрешения (завершение или отказ) навсегда и могут быть позже исследованы столько раз, сколько нужно.

Всякий раз когда p разрешается, следующий шаг будет одним и тем же, и сейчас, и позже.

Утиная типизация и наличие then

В краю промисов важной деталью является то, как узнать наверняка, что какое-то значение - подлинный промис или нет. Или более прямо, ведет ли себя это значение как промис?

Учитывая, что промисы создаются используя синтаксис new Promise(..), вы можете подумать, что p instanceof Promise будет приемлемой проверкой. Но к сожалению есть ряд причин, указывающих что этого совершенно недостаточно.

Преимущественно, вы можете получить значение промиса от другого окна браузера (iframe и т.д.), у которого есть свой собственный промис, отличный от того, который в текущем окне/фрейме, и такая проверка на определение промиса была бы неудачной.

Более того, библиотека или фреймворк могут избрать путь распространения своих собственных промисов и не использовать нативную реализацию ES6 Promise. По сути, вы можете вполне успешно пользоваться промисами из библиотек в старых браузерах, у которых совсем нет промисов.

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

Поэтому, было определено, что путем определения промиса (или чего-то, что ведет себя как промис) будет определение чего-либо, называемого "then-содержащим", как любой объект или функция, у которой есть метод then(..). Предполагается, что любое такое значение является промисо-совместимым then-содержащим.

Общее термин для "проверок типа", которые делают предположения о "типе" значения на основании его формы (какие в нем есть свойства), называется "утиная типизация": "Если это выглядит как утка и крякает как утка, значит это должно быть утка" (см. раздел Типы и грамматика в этой серии книг). Таким образом утиная проверка на наличие then условно будет такой:

if (
	p !== null &&
	(
		typeof p === "object" ||
		typeof p === "function"
	) &&
	typeof p.then === "function"
) {
	// предположим, что это содержит then!
}
else {
	// не содержит then
}

Тьфу! Оставляя в стороне тот факт, что эта логика немного уродливая для использования в различных местах, тут происходит кое-что поглубже и более проблематичное.

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

Это верно даже если вы не осознавали, что у этого значения есть then(..). Например:

var o = { then: function(){} };

// сделать чтобы у `v` в качестве `[[Prototype]]` было `o`
var v = Object.create( o );

v.someStuff = "cool";
v.otherStuff = "not so cool";

v.hasOwnProperty( "then" );		// false

v совсем не выглядит как промис или then-содержащее значение. Это просто обычный объект с некоторыми свойствами. Возможно вы просто хотите передавать это значение везде как любой другой объект.

Но скрытно от вас, v также связано [[Prototype]] (см. this и прототипы объектов книгу в серии книг) с другим объектом o, в котором как оказалось есть then(..). Таким образом проверка утиной типизации на then-содержащее значение подумает и предположит, что v - это then-содержащее значение. Ой-ей.

Тут даже всё может быть не так явно намеренным:

Object.prototype.then = function(){};
Array.prototype.then = function(){};

var v1 = { hello: "world" };
var v2 = [ "Hello", "World" ];

Оба v1 и v2 будут определены как then-содержащие значения. Вы не можете контролировать или предсказать добавит ли какой-либо код случайно или злонамеренно then(..) в Object.prototype, Array.prototype или любой из других встроенных прототипов. И если то, что указано является функцией, которая не вызывает ни один из своих параметров как колбеки, то любой промис, разрешенный с таким значением просто незаметно повиснет навсегда! Безумие.

Звучит неправдоподобно или невероятно? Возможно.

Но не забывайте, что есть несколько хорошо известных не-промис библиотек, существовавших в сообществе до ES6, в кторым случайно оказался метод, названный then(..). Некоторые из этих библиотек решили переименовать свои собственные методы, чтобы избежать коллизий (которые удручают!). Другие просто были отнесены к несчастливому статусу "несовместим с кодом, использующим промисы" в награду за их неспособность измениться, чтобы убраться с дороги.

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

Предупреждение: Мне не нравится как мы закончили материал об утиной типизации then-содержащих значений для определения промисов. Были и другие варианты, такие как "брэндинг" или даже "анти-брэндинг"; то, что у нас было, казалось наихудшим компромиссом. Но это совсем не конец света. Then-содержащая утиная типизация может быть и полезной как мы увидим позже. Просто будьте осторожны, так как такая утиная типизация по then может быть опасна если она некорректно определяет что-то как промис, которое таковым не является.

Доверие к промису

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

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

Давайте начнем с рассмотрения проблем доверия при разработке в стиле одних только колбеков. Когда вы передаете колбек в функцию foo(..), она может:

  • Вызвать колбек слишком рано
  • Вызвать колбек слишком поздно (или никогда)
  • Вызвать колбек слишком мало раз или слишком много раз
  • Провалить передачу в колбек любых необходимых окружения/параметров
  • Проглотить любые ошибки/исключения, которые могут произойти

Характеристики промисов намеренно разработаны, чтобы обеспечить полезные, воспроизводимые ответы на все эти проблемы.

Вызывая слишком рано

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

Промисы по определению не могут быть подвержены этой проблеме, потому что даже сразу завершенный промис (типа new Promise(function(resolve){ resolve(42); })) нельзя исследовать синхронно.

То есть, когда вы вызываете then(..) у промиса, даже если промис уже был разрешен, колбек, который вы передаете в then(..) всегда будет вызван асинхронно (детальнее об этом см. "Задачи" в главе 1).

Больше не нужно вставлять свои собственные костыли с setTimeout(..,0). Промисы не допускают Залго автоматически.

Вызывая слишком поздно

Аналогично предыдущему пункту, колбеки наблюдения, зарегистрированные в then(..) промиса автоматически планируются к вызову когда вызван либо resolve(..), либо reject(..) посредством кода создания промиса. Эти запланированные колбеки будут предсказуемо вызваны в следующий асинхронный момент (см. "Задачи" в главе 1).

Синхронное наблюдение тут невозможно, следовательно невозможно запустить синхронную цепочку задач таким образом, чтобы на практике "отложить" вызов другого колбека ожидаемым образом. То есть, когда промис разрешен, все зарегистрированные дляthen(..) колбеки будут по порядку вызваны, сразу же при следующей асинхронной возможности (снова, см. "Задачи" в главе 1) и ничто, что происходит внутри одного из этих колбеков не может повлиять или задержать вызов остальных колбеков.

Например:

p.then( function(){
	p.then( function(){
		console.log( "C" );
	} );
	console.log( "A" );
} );
p.then( function(){
	console.log( "B" );
} );
// A B C

Здесь, "C" не может прервать и предшествовать "B", в силу того как промисам было определено работать.

Хитрости планировщика промисов

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

Если два промиса p1 и p2 оба уже разрешены, то будет истиной, что p1.then(..); p2.then(..) закончится вызовов колбека(ов) для p1 до колбеков для p2. Но есть некоторые хитрые случаи, когда это может быть и не так, такие как следующий:

var p3 = new Promise( function(resolve,reject){
	resolve( "B" );
} );

var p1 = new Promise( function(resolve,reject){
	resolve( p3 );
} );

var p2 = new Promise( function(resolve,reject){
	resolve( "A" );
} );

p1.then( function(v){
	console.log( v );
} );

p2.then( function(v){
	console.log( v );
} );

// A B  <-- не B A  как вы могли бы ожидать

Мы расскажем об этом позже, но как вы видите p1 разрешен не с непосредственным значением, а с еще одним промисом p3, который сам разрешен со значением "B". Указанное поведение - это распаковать p3 внутри p1, но асинхронно, таким образом колбек(и) p1 позади колбека(ов) p2 в очереди асинхронных задач (см. главу 1).

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

Колбек, который никогда не был вызван

Это - очень распространенная проблема. Она решаема несколькими путями с промисами.

Во-первых, ничто (ни даже JS-ошибка) не может повлиять на уведомление вас от промиса о своем разрешении (если он разрешен). Если вы указываете оба колбека как для завершения, так и для сбоя при создании промиса и промис разрешается,то один из этих двух колбеков всегда будет вызван.

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

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

// функция с промисом по тайм-ауту
function timeoutPromise(delay) {
	return new Promise( function(resolve,reject){
		setTimeout( function(){
			reject( "Тайм-аут!" );
		}, delay );
	} );
}

// настройка тайм-аута для `foo()`
Promise.race( [
	foo(),					// попробовать вызвать `foo()`
	timeoutPromise( 3000 )	// дать на это 3 секунды
] )
.then(
	function(){
		// `foo(..)` выполнился успешно и вовремя!
	},
	function(err){
		// либо `foo()` завершилась неудачно, либо она
		// не завершилась вовремя, так что проверьте
		// `err` чтобы определить причину
	}
);

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

Важно, что мы можем гарантированно просигнализировать о результате foo(), чтобы предотвратить зависание нашей программы бесконечно.

Вызывая слишком мало или много раз

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

Случай "слишком много" легко объяснить. Промисы определены таким образом, что могут быть разрешены только один раз. Если по каким-либо причинам код создания промиса попытается вызвать resolve(..) или reject(..) несколько раз или попытается вызвать их обоих, то промис примет во внимание только первый вызов и молча проигнорирует любые последующие попытки.

Поскольку промис можно разрешить лишь раз, любые зарегистрированные колбеки then(..) будут вызваны только по разу (каждый).

Конечно, если зарегистрируете один и ото же колбек более одного раза, (т.е., p.then(f); p.then(f);), он будет вызван столько раз, сколько был зарегистрирован. Гарантия того, что функция ответа будет вызвана только раз не препятствует вам выстрелить себе в ногу.

Сбой при передаче параметров/окружения

У промисов может быть не больше одного значения разрешения (завершение или отказ).

Если вы не разрешаете промис явно с конкретным значением, значение будет undefined, как и обычно в таких случаях в JS. Но если ли значение или нет, оно всегда будет передавно во все зарегистрированные (и корректные: завершение или отказ) колбеки, либо сейчас, или в будущем.

Что-то, о чем нужно знать: если вы вызываете resolve(..) или reject(..) с несколькими параметрами, все параметры, которые следуют за первым, будут молча проигнорированы. Хотя это и может выглядеть как нарушение гарантии, которую мы только что описали, это не совсем так потому что it это представляет собой недопустимое использование механизма промисов. Другие недопустимые случаи использования API (такие как вызов resolve(..) несколько раз) защищены похожим образом, таким образом поведение промисов тут будет консистентным (если не немного расстраивающим).

Если вы хотите передать несколько значений, вы должны обернуть их в еще одно одиночное значение, которое вы и передадите, такое как array (массив) или object (объект).

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

Проглатывание любых ошибок/исключений

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

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

Например:

var p = new Promise( function(resolve,reject){
	foo.bar();	// `foo` не определена поэтому ошибка!
	resolve( 42 );	// никогда не достигнет этой точки :(
} );

p.then(
	function fulfilled(){
		// никогда не достигнет этой точки :(
	},
	function rejected(err){
		// `err` будет объектом исключения `TypeError`
		// произошедшим на строке `foo.bar()`.
	}
);

JS-исключение, которое возникает от foo.bar(), становится отказом промиса, который вы можете захватить и обработать.

Это - важная деталь, потому что она успешно решает еще один потенциальный Залго-момент, который заключается в том, что эти ошибки повлечь синхронную реакцию тогда как не-ошибки будут асинхронными. Даже JS-исключения промимсы превращают в асинхронное поведение, тем самым значительно уменьшая шансы появления состояния гонки.

Но что произойдет если промис завершен, а произошло JS-исключение во время обработки результата (в зарегистрированном колбеке then(..))? Даже такие исключения не будут потеряны, но вы можете немного удивиться тому, как они обрабатываются, пока не вникните немного глубже в это:

var p = new Promise( function(resolve,reject){
	resolve( 42 );
} );

p.then(
	function fulfilled(msg){
		foo.bar();
		console.log( msg );	// никогда не достигнет этой точки :(
	},
	function rejected(err){
		// никогда не достигнет и этой точки :(
	}
);

Подождите-ка, тут похоже, что исключение от foo.bar() и в самом деле проглочено. Без паники, оно не пропало. Но кое-что внутри не так, а именно то, что мы не смогли получить уведомление об этом. Вызов p.then(..) сам по себе возвращает другой промис и это тот самый промис, который будет отклонен с исключением TypeError.

Так почему же он не может просто вызвать обработчик ошибок, который мы там объявили? Похоже, что логичное объяснение лежит на поверхности. Но оно нарушило бы основополагающий принцип промисов - неизменность после разрешения. p уже была завершена со значением 42, так что она не может быть позднее изменена на отказ только потому, что возникла ошибка в наблюдающей функции разрешения p.

Кроме нарушения принципа, такое поведение может нанести ущерб, если скажем было несколько зарегистрированных колбеков then(..) для промиса p, поскольку тогда некоторые будут вызваны, а другие - нет, и это было очень непрозрачно в плане причины почему так произошло.

Надежный промис?

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

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

Но почему это было бы надежнее, чем просто колбеки сами по себе? Как мы можем быть уверены в точ, что нечто, что мы получаем в ответ на самом деле надежный промис? Не является ли всё это в сущности просто карточным домиком, где мы можем доверять только потому, что мы уже доверяем?

Одна из самых важных, но часто незаслуженно обойденных вниманием деталей промисов - это то, что у них есть решение также и этой проблемы. Включенное в нативную реализацию ES6 Promise - Promise.resolve(..).

Если вы передаете непосредственное, не являющееся ни промисом, ни then-содержащим, значение в Promise.resolve(..), вы получили промис, который завершен с этим значением. Другими словами, эти два промиса p1 и p2 будут вести себя практически идентично:

var p1 = new Promise( function(resolve,reject){
	resolve( 42 );
} );

var p2 = Promise.resolve( 42 );

Но если вы передадите настоящий промис в Promise.resolve(..), вы просто получите тот же промис обратно:

var p1 = Promise.resolve( 42 );

var p2 = Promise.resolve( p1 );

p1 === p2; // true

Что еще более важно, если вы пердаете не-промис then-содержащее значение в Promise.resolve(..), оно попытается распаковать это значение, и распаковка будет продолжаться до тех пор, пока не будет извлечено конкретное окончательное не-промис значение.

Помните наше прошедшее обсуждение then-содержащих?

Рассмотрите:

var p = {
	then: function(cb) {
		cb( 42 );
	}
};

// это сработает, но только благодаря удаче
p
.then(
	function fulfilled(val){
		console.log( val ); // 42
	},
	function rejected(err){
		// никогда не достигнет этой точки
	}
);

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

var p = {
	then: function(cb,errcb) {
		cb( 42 );
		errcb( "злобный смех" );
	}
};

p
.then(
	function fulfilled(val){
		console.log( val ); // 42
	},
	function rejected(err){
		// ой, не должно было сработать
		console.log( err ); // злобный смех
	}
);

Это p - then-содержащее, но оно не ведет себя как хороший промис. Вредоносное ли оно? Или просто игнорирует то, как должны работать промисы? Это не важно, если честно. В любом случае, оно не надежно в том виде, как есть.

Тем ни менее, мы может передать любую их этих версий p в Promise.resolve(..), и мы получим нормализованный, безопасный результат, который и ожидаем:

Promise.resolve( p )
.then(
	function fulfilled(val){
		console.log( val ); // 42
	},
	function rejected(err){
		// никогда не достигнет этой точки
	}
);

Promise.resolve(..) примет любые then-содержащие агрументы, и распакует их в их не-then-содержащее значение. Но вы получите обратно из Promise.resolve(..) настоящий, подлинный промис, тот, которому вы можете доверять. Если то, что вы передали, уже является настоящим промисом, вы просто получилите его же обратно, так что нет никаких недостатков в том, что фильтровать через Promise.resolve(..), чтобы получить надежность.

Допустим мы вызываем foo(..) и не уверены, что можем доверять его возвращаемому значению в том, что оно является правильным промисом, но мы знаем, что оно как минимум then-содержащее. Promise.resolve(..) даст нам надежную обертку в виде промиса, которую можно использовать в цепочке:

// не делайте так:
foo( 42 )
.then( function(v){
	console.log( v );
} );

// вместо этого, делайте так:
Promise.resolve( foo( 42 ) )
.then( function(v){
	console.log( v );
} );

Примечание Еще один выгодный побочный эффект оборачивания Promise.resolve(..) вокруг любого возвращаемого из функции значения (then-содержащей или нет) - это то, что это легкий путь к приведению этого вызова функции к правильно ведущей себя асинхронной задаче. Если foo(42) иногда возвращает непосредственное значение, а иногда промис, Promise.resolve( foo(42) ) следит за тем, чтобы это всегда был в результате промис. И избегая Залго приводит к намного лучшему коду.

Построенное доверие

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

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

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

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

Цепочечный поток

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

Ключ к тому, чтобы это сработало, построен на двух видах поведения, присущих промисам:

  • Каждый раз как вы вызываете then(..) у промиса, он создает и возвращает новый промис, с которым мы можем составить цепочку.
  • Какое бы значение вы ни вернули из колбека завершения then(..) (первый параметр) - оно автоматически устанавливается как set как результата нормального завершения промиса в цепочке (из первого пункта).

Давайте сперва проиллюстрируем что это значит, а затем (then, туту игра слов) вы выведем как это помодет нам создавать асинхронные последовательности управления потоком. Рассмотрим следующее:

var p = Promise.resolve( 21 );

var p2 = p.then( function(v){
	console.log( v );	// 21

	// завершить `p2` со значением `42`
	return v * 2;
} );

// составляем цепочку с `p2`
p2.then( function(v){
	console.log( v );	// 42
} );

Возвращая v * 2 (i.e., 42), мы завершаем промис p2 с успешным результатом, который создал и вернул первый вызов then(..). Когда происходит вызов then(..) у p2, он получает результат из выражения return v * 2. Конечно, p2.then(..) создает еще один промис, который мы можем сохранить в переменной p3.

Но немного раздражает необходимость создавать промежуточную переменную p2 (или p3, и т.д.). К счастью, мы легко можем объединить их в цепочку:

var p = Promise.resolve( 21 );

p
.then( function(v){
	console.log( v );	// 21

	// вернуть результат промиса в цепочке со значением `42`
	return v * 2;
} )
// вот и промис в цепочке
.then( function(v){
	console.log( v );	// 42
} );

Таким образом теперь первый then(..) - это первый шаг в асинхронной последовательности, а второй then(..) - это второй шаг. Это может продолжаться столь долго, сколь вам надо это расширять. Просто продолжайте цепочку от предыдущего then(..) автоматически созданным промисом.

Но здесь нет кое-чего. Что если мы хотим, чтобы шаг 2 ждал пока шаг 1 выполнит что-то асинхронное? Мы используем выражение return для возврата значения сразу, которое немедленно и завершает промис в цепочке.

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

Такой же вид распаковки происходит если вы используете в return then-содержащее или промис из обработчика завершения (или отказа). Рассмотрим:

var p = Promise.resolve( 21 );

p.then( function(v){
	console.log( v );	// 21

	// создаем промис и возвращаем его
	return new Promise( function(resolve,reject){
		// завершение со значением `42`
		resolve( v * 2 );
	} );
} )
.then( function(v){
	console.log( v );	// 42
} );

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

var p = Promise.resolve( 21 );

p.then( function(v){
	console.log( v );	// 21

	// создаем промис и возвращаем его
	return new Promise( function(resolve,reject){
		// добавляем асинхронность!
		setTimeout( function(){
			// завершение со значением `42`
			resolve( v * 2 );
		}, 100 );
	} );
} )
.then( function(v){
	// запускается после задержки 100мс на предыдущем шаге
	console.log( v );	// 42
} );

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

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

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

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

delay( 100 ) // шаг 1
.then( function STEP2(){
	console.log( "шаг 2 (после 100мс)" );
	return delay( 200 );
} )
.then( function STEP3(){
	console.log( "шаг 3 (после еще 200мс)" );
} )
.then( function STEP4(){
	console.log( "шаг 4 (следующая задача)" );
	return delay( 50 );
} )
.then( function STEP5(){
	console.log( "шаг 5 (после еще 50мс)" );
} )
...

Вызов delay(200) создает промис, который завершится через 200мс, а затем вы вернем его из первого колбека завершения then(..), который приведет ко второму промису от then(..), чтобы подождать этот 200мс-промис.

Примечание Как было описано, технически есть два промиса в этом обмене: промис с 200мс-задержкой и промис в цепочке, к которому присоединяется второй then(..). Но возможно вам будет проще мысленно объединить эти два промиса вместе, потому что механизм промисов автоматически объединим их состояния для вас. В этом отношении, вы можете думать о return delay(200) как о создании промиса, который заменяет ранее возвращенный промис в цепочке.

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

Вместо таймеров, двайте рассмотрим возможность создания Ajax-запросов:

// предположим есть функция `ajax( {url}, {callback} )`

// Ajax с учетом промисов
function request(url) {
	return new Promise( function(resolve,reject){
		// колбек `ajax(..)` должен стать нашей
		// функцией `resolve(..)` промиса
		ajax( url, resolve );
	} );
}

Сначала мы определяем функцию request(..), которая создает промис для представления завершения вызова ajax(..):

request( "http://some.url.1/" )
.then( function(response1){
	return request( "http://some.url.2/?v=" + response1 );
} )
.then( function(response2){
	console.log( response2 );
} );

Примечание Разработчики часто сталкиваются с ситуациями, когда они хотят получить асинхронное управление потоком промисов для функций, которые сами по себе не поддерживают промисы (как ajax(..) здесь, который ожидает на входе колбек). Хотя нативный механизм ES6 Promise не решает автоматически этот шаблон за нас, практически все промис-библиотеки решают. Они обычно называют этот процесс "lifting" (поднятие) или "promisifying" (промисифицирование) или вариации на тему. Мы вернемся к этой технике позже.

Используя request(..), умеющий возвращать промис, мы неявно создаем первый шаг в нашей цепочке вызывая его с первым URL, и объединяем в цепочку этот возвращенный промис с первым then(..).

Как только возвращается response1, мы используем это значение чтобы создать второй URL и выполнить второй вызов request(..). Этот второй промис из request(..) - возвращен returnом таким образом, что третий шаг в нашем асинхронном контроле потока ждет завершения этого Ajax-вызова. Наконец, мы выводим response2 как только он возвращен.

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

Что если что-то пойдет не так в одном из шагов цепочки промисов? Ошибка/исключение соединены со своим промисом, что означает, что есть возможность поймать такую ошибку в любом месте цепочки и такой захват действует как бы как "сброс" цепочки обратно к нормальному функционированию в этой точке:

// шаг 1:
request( "http://some.url.1/" )

// шаг 2:
.then( function(response1){
	foo.bar(); // undefined, ошибка!

	// никогда не достигнет этой точки
	return request( "http://some.url.2/?v=" + response1 );
} )

// шаг 3:
.then(
	function fulfilled(response2){
		// никогда не достигнет этой точки
	},
	// обработчик отказа, чтобы поймать ошибку
	function rejected(err){
		console.log( err );	// `TypeError` из-за ошибки на `foo.bar()`
		return 42;
	}
)

// шаг 4:
.then( function(msg){
	console.log( msg );		// 42
} );

Когда происходит ошибка на шаге 2, обработчик отказа в шаге 3 ловит ее. Возвращаемое значение (42 в этом примере кода), если таковое есть, из обработчика отказа завершает промис для следующего шага (4), так, чтобы цепочка вернулось обратно в состояние завершения.

Примечание Как мы уже говорили ранее, при возврате промиса из обработчика завершения, этот промис не обернут и может задержать следующий шаг. Это также верно при возврате промисов из обработчиков отказа, так что если return 42 на шаге 3 вернет вместо этого промис, этот промис может задержать шаг 4. Выброшенное исключение внутри либо обработчика завершения, либо отказа в вызове then(..) приведет к тому, что следующий (в цепочке) промис будет немедленно отвергнут с этим же исключением.

Если вы вызовете then(..) у промиса и передадите только обработчик завершения в него, будет подставлен неявный обработчик отказов:

var p = new Promise( function(resolve,reject){
	reject( "Ой" );
} );

var p2 = p.then(
	function fulfilled(){
		// никогда не достигнет этой точки
	}
	// неявный обработчик отказа, если явно не указан или
	// передано любое другое значение - не-функция
	// function(err) {
	//     throw err;
	// }
);

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

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

Если в then(..) не передана валидная функция в качестве параметра обработчика завершения, также будет подставлен неявный обработчик:

var p = Promise.resolve( 42 );

p.then(
	// неявный обработчик завершения, если явно не указан или
	// передано любое другое значение - не-функция
	// function(v) {
	//     return v;
	// }
	null,
	function rejected(err){
		// никогда не достигнет этой точки
	}
);

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

Примечание Шаблон then(null,function(err){ .. }), обрабатывающий только отказы (если есть), но позволяющий пропускать далее завершения, имеет сокращенную форму в API: catch(function(err){ .. }). Мы рассмотрим catch(..) более полно в следующем разделе.

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

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

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

Определенно, последовательная выразительность цепочки (это-then-это-then-это...) - это большое улучшение по сравнению с запутанным клубком колбеков, как мы уже выяснили в главе 2. Но все еще есть изрядный объем шаблона (then(..) и function(){ .. }), через который нужно продираться. В следующей главе мы увидим значительно более приятный шаблон для выразительной организации последовательного управления потоком с помощью генераторов.

Терминология: Разрешить (Resolve), Завершить (Fulfill) и Отвергнуть (Reject)

Существует небольшая путаница в терминах "разрешить (resolve)", "Завершить (fulfill)" и "отвергнуть (reject)", которую нам необходимо прояснить до того, как вы погрузитесь слишком глубоко в изучении промисов. Давайте сначала рассмотрим конструктор Promise(..):

var p = new Promise( function(X,Y){
	// X() для завершения
	// Y() для отказа
} );

Как видите, переданы два колбека (здесь помечены как X и Y). Первый обычно используется для отметки того, что промис завершен, а второй всегда помечает промис как отвергнутый. Но о чем это "обычно" и что это означает для точного именования этих параметров?

В конечном счете, это только ваш код и имена идентификаторов не интерпретируются JS-движком как что-то значимое, так что технически это не имеет значения; foo(..) и bar(..) одинаково функциональны. Но слова, которыми вы пользуетесь, могут затронуть не только как вы думаете о коде, но и как другие разработчики в вашей команде будут думать о нем. Думая неправильно о тщательно организованном асинхронном коде - это почти наверняка будет хуже, чем альтернативы из спагетти-колбеков.

Так что на самом деле имеет значение, как вы их называете.

Со вторым параметром легко определиться. Почти вся литература использует reject(..) (отвергнуть) как его имя и поскольку это в точности (и только это!) то, что он делает, это и есть очень хороший выбор для этого имени. Я бы настоятельно рекомендовал вам всегда использовать reject(..).

Но вокруг первого параметра чуть больше неясностей, который в литературе о промисах часто обозначается resolve(..) (разрешить). Это слово очевидно связано с "resolution" (разрешение), которое и используется во всей литературе (включая эту книгу), чтобы описать установку конечного значения/состояния в промисе. Мы уже использовать "разрешить промис" несколько раз, чтобы обозначить либо завершение, или отвергнутый промис.

Но если этот параметр, по-видимому, используется для конкретно завершения промиса, почему бы не назвать его fulfill(..) (завершить) вместо resolve(..) (разрешить), чтобы быть более точным? Чтобы ответить на этот вопрос, давайте также взглянет на два метода Promise API:

var fulfilledPr = Promise.resolve( 42 );

var rejectedPr = Promise.reject( "Ой" );

Promise.resolve(..) создает промис, который разрешен со переданным значением. В этом примере, 42 - это обычное, не-промис, не-then-содержащее значение, поэтому завершенный промис fulfilledPr создан для значения 42. Promise.reject("Ой") создает отвергнутый промис rejectedPr для причины "Ой".

Давайте теперь проиллюстрируем, почему слово "resolve" (разрешить) (такое как в Promise.resolve(..)) - является однозначным и более точным, если используется явно в контексте, который должен либо завершиться, либо закончиться отказом:

var rejectedTh = {
	then: function(resolved,rejected) {
		rejected( "Ой" );
	}
};

var rejectedPr = Promise.resolve( rejectedTh );

Как мы уже говорили ранее в этой главе, Promise.resolve(..) вернет полученный настоящий промис напрямую или распакует полученное then-содержащее. Если распаковка этого then-содержащего покажет отвергнутое состояние, то промис, который был возвращен из Promise.resolve(..) - по факту в том же самом отвергнутом состоянии.

Таким образом Promise.resolve(..) - это хорошее, точное название для метода API, потому что он может либо завершиться, либо будет отвергнутым.

Первый колбек-параметр конструктора Promise(..) распакует либо then-содержащее (идентично Promise.resolve(..)), либо настоящий промис:

var rejectedPr = new Promise( function(resolve,reject){
	// разрешить этот промис отвергнутым промисом
	resolve( Promise.reject( "Ой" ) );
} );

rejectedPr.then(
	function fulfilled(){
		// никогда не достигнет этой точки
	},
	function rejected(err){
		console.log( err );	// "Ой"
	}
);

Теперь должно быть ясно, что resolve(..) - подходящее название для для первого колбек-параметра конструктора Promise(..).

Предупреждение: Ранее упомянутый reject(..) не выполняет распаковку, как это делает resolve(..). Если вы передадите промис или then-содержащее значение в reject(..), то именно это значение нетронутым будет установлено как причина отказа. Последующий обработчик отказа получит настоящий промис/then-содержащее, которое вы передали в reject(..), а не его внутреннее непосредственное значение.

Но теперь давайте обратим наше внимание на колбеки, переданные в then(..). Как их следует назвывать (и в литературе, и в коде)? Я бы предложил fulfilled(..) (завершенный) и rejected(..) (отвергнутый):

function fulfilled(msg) {
	console.log( msg );
}

function rejected(err) {
	console.error( err );
}

p.then(
	fulfilled,
	rejected
);

В случае первого параметра в then(..) - это однозначно всегда случай завершения, поэтому нет нужны для двойственной терминологии "resolve". В качестве примечания, спецификация ES6 использует onFulfilled(..) и onRejected(..), чтобы обозначить эти два колбека, поэтому они являются точными терминами.

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

Мы уже видели несколько примеров того, как отказ промисов, либо намеренно через вызов reject(..), либо случайно через исключение JS, позволяет более разумно обрабатывать ошибки при асинхронной разработке. Давайте вернемся назад и четко сформулируем некоторые детали, которые мы пропустили.

Самая естественная форма обработки ошибок для большинства разработчиков - это синхронная конструкция try..catch. К сожалению, она есть только в синхронной форме, поэтому она не поможет в шаблонах асинхронного кода:

function foo() {
	setTimeout( function(){
		baz.bar();
	}, 100 );
}

try {
	foo();
	// позднее выбросит глобальную ошибку из `baz.bar()`
}
catch (err) {
	// никогда не достигнет этой точки
}

Было бы неплохо иметь в арсенале try..catch, но он не работает для асинхронных операций. То есть, если только нет какой-то дополнительной поддержки среды, к к торой мы вернемся вместе с генераторами в главе 4.

В колбеках, появились некоторые стандарты для шаблонной обработки ошибок, особенно стиль "колбек ошибки идет первым":

function foo(cb) {
	setTimeout( function(){
		try {
			var x = baz.bar();
			cb( null, x ); // успех!
		}
		catch (err) {
			cb( err );
		}
	}, 100 );
}

foo( function(err,val){
	if (err) {
		console.error( err ); // облом :(
	}
	else {
		console.log( val );
	}
} );

Примечание try..catch тут работает только с той точки зрения, что вызов baz.bar() немедленно завершится или прервется со сбоем и при этом синхронно. Если baz.bar() сам являлся своей же асинхронной функцией продолжения, то любые асинхронные ошибки внутри него нельзя будет поймать.

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

Такой тип обработки ошибок технически поддерживает асинхронность, но он совсем не способен к композиции. Множественные уровни колбеков в стиле "ошибка первая" сплетенные вместе с этими вездесущими операторами проверок if неизбежно приведет вас к опасностям, связанным с адом колбеков (см. главу 2).

Таким образом вы возвращаемся к обработке ошибок с помощью промисов, с передачей обработчика отказов в then(..). Промисы не используют популярный стиль дизайна "колбек с первым параметром-ошибкой", а вместо этого используют стиль "раздельных колбеков", есть один колбек для завершения и другой - для отказа:

var p = Promise.reject( "Ой" );

p.then(
	function fulfilled(){
		// никогда не достигнет этой точки
	},
	function rejected(err){
		console.log( err ); // "Ой"
	}
);

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

Представьте:

var p = Promise.resolve( 42 );

p.then(
	function fulfilled(msg){
		// числа не содержат функцией как у строк,
		// поэтому тут возникнет ошибка
		console.log( msg.toLowerCase() );
	},
	function rejected(err){
		// никогда не достигнет этой точки
	}
);

Если msg.toLowerCase() законно выдает ошибку (и это действительно так!), почему же наш обработчик ошибок не вызван? Как мы объясняли ранее, так происходит потому, что этот обработчик ошибок - для промиса p, который уже был разрешен со значением 42. Промис p - неизменяемый, поэтому единственный промис, который может получить ошибку, тот, что возвращается из p.then(..), который мы в этом случае никак не ловим.

Это должно дать четкое представление о том, почему обработка ошибок в промисах подвержена ошибкам (каламбур). Слишком легко допустить, чтобы ошибки были "проглочены", так как это очень редко то, что вы задумывали изначально.

Предупреждение: Если вы используете API промисов неправильным путем и происходит ошибка, то это препятсвует правильному созданию промиса, в результате будет полученное сращу же исключение, но не отвергнутый промис. Некоторые примеры некорректного использования, который ломают создание промиса: new Promise(null), Promise.all(), Promise.race(42), и т.д.. Вы не сможете получить отвергнутый промис, если вы не используете API промисов достаточно корректно, чтобы на само деле создать в первую очередь сам промис!

Яма отчаяния

Джефф Этвуд заметил несколько лет назад: языки программирования часто настроены настоены по умолчанию таким образом, что разработчки попадают в "яму отчаяния" (http://blog.codinghorror.com/falling-into-the-pit-of-success/), где за инциденты наказывают и что нужно больше стараться, чтобы все получилось. Он призвал нас вместо этого создавать "яму успеха," когда по умолчанию вы попадаете в ожидаемое (успешное) действие, и, следовательно, должны сильно постараться, чтобы потерпеть неудачу.

Обработка ошибок в промисах - несомненно, является дизайном "ямы отчаяния". По умолчанию, он предполагает, что вы хотите, чтобы любые ошибки были поглощены состоянием промиса и если вы забудете исследовать этот состояние, ошибка тихо томится/умирает в безвестности, обычно отчаяния.

Чтобы избежать ошибки из-за молчания забытого/заброшенного промиса, некоторые разработчики заявили, что "лучшей практикой" для цепочек промисов является всегда завершать цепочку заключительным catch(..), например:

var p = Promise.resolve( 42 );

p.then(
	function fulfilled(msg){
		// числа не содержат функцией как у строк,
		// поэтому тут возникнет ошибка
		console.log( msg.toLowerCase() );
	}
)
.catch( handleErrors );

Поскольку мы не передали обработчик отказов в then(..), был подставлен обработчик по умолчанию, который просто передает ошибку следующему промису в цепочке. Таким образом, обе ошибки, идущие в p, и ошибки, которые появляются после p в его разрешении (как в msg.toLowerCase()) будут отфильтрованы до конечного handleErrors(..).

Проблема решена, не так ли? Не так быстро!

Что случится, если handleErrors(..) сам содержит ошибку? Кто отловит ее? Остался еще один невыполненный промис: тот, который возвращает catch(..), который мы не ловим и не регистрируем обработчик отказа для него.

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

Все еще звучит как невыполнимая головоломка?

Обработка непойманного

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

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

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

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

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

var p = Promise.resolve( 42 );

p.then(
	function fulfilled(msg){
		// у чисел нет функций как у строк,
		// поэтому будет выброшена ошибка
		console.log( msg.toLowerCase() );
	}
)
.done( null, handleErrors );

// если `handleErrors(..)` породила свое собственное исключение, оно
// будет выброшено тут как глобальное

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

Неужели мы просто застряли? Не совсем.

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

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

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

Есть ли другие альтернативы? Да.

Яма успеха

Нижеследующее является лишь теоретическим, как промисы могли бы однажды изменить свое поведение. Я верю, что Я уверен, что это будет намного лучше, чем то, что мы имеем сейчас. И я думаю, что это изменение будет возможно даже в пост-ES6, потому что я не думаю, что это нарушит совместимость с ES6 промисами. Более того, это можно превратить в полифил, если вы будете осторожны. Давайте взглянем:

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

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

Рассмотрим:

var p = Promise.reject( "Ой" ).defer();

// `foo(..)` промисоподобная
foo( 42 )
.then(
	function fulfilled(){
		return p;
	},
	function rejected(err){
		// обработка ошибок в `foo(..)`
	}
);
...

Когда мы создаем p, мы знаем, что мы собираемся подождать некоторое время, чтобы использовать/наблюдать за его отказом, поэтому мы вызываем defer() - таким образом, отсутствует глобальная отчетность. defer() просто возвращает тот же промис для возможности выстраивания цепочки.

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

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

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

Единственная реальная опасность в этом подходе - если вы отложите (defer()) промис, а затем не сможете на деле вообще наблюдать/обработать его отказ.

Но вы должны были намеренно вызвать defer(), чтобы опуститься в эту яму отчаяния, изначально была яма успеха, поэтому мы мало что можем сделать, чтобы спасти вас от ваших собственных ошибок.

Я думаю, что все еще есть надежда на обработку ошибок промисов (пост-ES6). Я надеюсь, что власть предержащие переосмыслят ситуацию и рассмотрят эту альтернативу. Тем временем, вы можете реализовать это сами (непростое упражнение для читателя!) или использовать более умную библиотеку промисов, которая сделает это за вас!

Примечание Эта конкретная модель обработки ошибок/сообщений реализована в моей библиотеке абстракций над промисами asynquence, которую обсудим в приложении A этой книги.

Шаблоны промисов

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

Два таких шаблона кодируются непосредственно в нативную реализацию ES6 Promise, так что мы получаем их бесплатно, чтобы использовать как строительные блоки для других шаблонов.

Promise.all([ .. ])

В асинхронной последовательности (цепочке промисов) только одна асинхронная задача координируется в любой момент времени, шаг 2 строго следует за шагом 1, а шаг 3 строго следует за шагом 2. А как насчет выполнения двух и более шагов одновременно (т.е. "параллельно")?

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

В API промисов, мы называем этот щаблон all([ .. ]).

Скажем вы хотели сделать два Ajax-запроса в одно и то же время и дождаться окончания обоих, независимо от их порядка, до выполнения третьего Ajax-запроса. Рассмотрим:

// `request(..)` - промис-совместимая Ajax-функция,
// примерно как та, что мы определяли ранее в главе

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.all( [p1,p2] )
.then( function(msgs){
	// оба `p1` and `p2` завершатся успешно и передадут
	// свои сообщения сюда
	return request(
		"http://some.url.3/?v=" + msgs.join(",")
	);
} )
.then( function(msg){
	console.log( msg );
} );

Promise.all([ .. ]) ожидает один аргумент, массив, состоящий состоящий в целом из экземпляров промисов. Промис, возвращенный из вызова Promise.all([ .. ]), получит сообщение о завершении (msgs в этом примере кода), которое является массивом всех сообщений о завершении от переданных промисов, в том же порядке как они были переданы (независимо от порядка завершения).

Примечание Технически, массив значений, переданный в Promise.all([ .. ]), может содержать промисы, then-содержащие или даже непосредственные значения. Каждое значение в списке по сути, проходит через Promise.resolve(..), чтобы убедиться, что ожидается настоящий промис, таким образом непосредственное значение будет просто приведено в промис для этого значения. Если массив пустой, основной промис немедленно завершается.

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

Помните о том, чтобы всегда присоединять обработчик отказа/ошибки к каждому промису, даже и особенно к тому, который возвращается из Promise.all([ .. ]).

Promise.race([ .. ])

В то время как Promise.all([ .. ]) координирует несколько обещаний одновременно и предполагает, что все они нужны для завершения, иногда вы хотите всего лишь получить ответ от "первого же промиса, пересекшего финишную линию", позволяя других промисам отпасть за ненадобностью.

Этот шаблон классически называют "задвижка" (latch), но в промисах он называется "гонка" (race).

Предупреждение: В то время как метафора "только первый, пересёкший финишную черту, выигрывает" хорошо соответствует поведению, к сожалению "гонка" - это своего рода нагруженный термин, потому что "состояния гонки" - обычно воспринимаются как ошибки в программах (см. главу 1). Не путайте Promise.race([ .. ]) с "состоянием гонки" (race condition).

Promise.race([ .. ]) также ожидает единственный аргумент в виде массива, содержащий один или более промисов, then-содержащих или непосредственных значений. Не имеет большого практического смысла иметь гонку с непосредственными значениями, потому что первое перечисленное значение очевидно выиграет, как в беге, где один бегун стартует с финиша!

Аналогично Promise.all([ .. ]), Promise.race([ .. ]) завершится если и тогда, когда любое из разрешений промисов - успешное, и завершится отказом если и тогда, когда любое из разрешений промисов - это отказ.

Предупреждение: "гонка" требует по меньшей мере одного "бегуна", поэтому если вы передадите пустой массив, вместо немедленно разрешения, основной промис race([..]) никогда не будет разрешен. Это программные "грабли"! ES6 должен был указать, что он либо выполняет, либо отклоняет, либо просто выбрасывает какую-то синхронную ошибку. К сожалению, из-за прецедента в библиотеках промисов, предшествующих ES6 Promise, им пришлось оставить эту недоработку, поэтому будьте осторожны и никогда не отправляйте пустой массив.

Давайте вернемся к нашему предыдущему примеру с параллельным Ajax, но в контексте гонки между p1 и p2:

// `request(..)` - это промис-совместимая функция,
// подобно той, что мы ранее определили в этой главе

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.race( [p1,p2] )
.then( function(msg){
	// либо `p1`, либо `p2` выиграет гонку
	return request(
		"http://some.url.3/?v=" + msg
	);
} )
.then( function(msg){
	console.log( msg );
} );

Поскольку побеждает только один промис, значение завершения - это одно сообщение, а не массив, как это было в Promise.all([ .. ]).

Гонка тайм-аутов

Мы видели этот пример ранее, иллюстрирующий как Promise.race([ .. ]) может использоваться для выражения шаблона "тайм-аут промиса":

// `foo()` - функция, поддерживающая промисы

// `timeoutPromise(..)`, определенный ранее, аозвращает
// промис, который завершается отказом rejects после указанной задержки

// настроить тайм-аут для `foo()`
Promise.race( [
	foo(),					// попробовать вызвать `foo()`
	timeoutPromise( 3000 )	// дать ему 3 секунды
] )
.then(
	function(){
		// `foo(..)` завершилась успешно и вовремя!
	},
	function(err){
		// либо `foo()` завершился отказом, либо просто
		// не успеет завершиться вовремя, поэтому загляните в
		// `err`, чтобы узнать причину
	}
);

Этот шаблон тайм-аута работает в большинстве случаев. Но есть некоторые нюансы, которые необходимо учитывать, и, честно говоря, они применимы к обоим Promise.race([ .. ]) и Promise.all([ .. ]) в равной степени.

"Finally"

Ключевой вопрос, который необходимо задать: "Что происходит с промисами, который который отбрасываются/игнорируются?" Мы задаем этот вопрос не с точки зрения производительности, они, как правило, попадают в сборку мусора как подходящие кандидаты для этого, а с поведенческий аспекта (побочные эффекты и т.д.). Промисы нельзя отменить, и не должны бы, поскольку это разрушит доверие к внешней неизменяемости, обсуждаемой в секции "Промис неотменяемый" позже в этой главе, поэтому их можно только молча игнорировать.

Но что если foo() из предыдущего примера резервирует какой-то ресурс для использования, но первым срабатывает тайм-аут и приводит к тому, что этот промис игнорируется? Есть ли в этом шаблоне что-нибудь, что с упреждением освобождает зарезервированный ресурс после истечения тайм-аута или иным образом отменяет любые побочные эффекты, которые он мог иметь? Что если всё, что вы хотели - это зафиксировать факт того, что foo() завершился по тайм-ауту?

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

Это может выглядеть так:

var p = Promise.resolve( 42 );

p.then( something )
.finally( cleanup )
.then( another )
.finally( cleanup );

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

Тем временем, мы могли бы создать статическую вспомогательную функцию, которая позволит нам наблюдать (без вмешательства) за разрешением промиса:

// защитная проверка в стиле безопасного полифила
if (!Promise.observe) {
	Promise.observe = function(pr,cb) {
		// стороннее наблюдение за разрешением  `pr`
		pr.then(
			function fulfilled(msg){
				// запланировать колбек асинхронно (в виде задачи)
				Promise.resolve( msg ).then( cb );
			},
			function rejected(err){
				// запланировать колбек асинхронно (в виде задачи)
				Promise.resolve( err ).then( cb );
			}
		);

		// вернуть оригинальный промис
		return pr;
	};
}

Вот как мы используем его в предыдущем примере с тайм-аутом:

Promise.race( [
	Promise.observe(
		foo(),					// попытка вызова `foo()`
		function cleanup(msg){
			// почистить за `foo()`, даже если она
			// не завершилась после тайм-аута
		}
	),
	timeoutPromise( 3000 )	// дать функции тайм-аут в 3 секунды
] )

Это вспомогательная функция Promise.observe(..) - просто иллюстрация того, как вы могли бы наблюдать за завершениями промисов без вмешательства в них. В других библиотеках промисов есть свои собственные решения. НЕзависимо от того ка вы это сделаете, скорее всего, у вас будут места, где вы захотите убедиться, что ваши промисы не будут просто молча проигнорированы случайно.

Вариации на тему all([ .. ]) и race([ .. ])

В то время как нативные ES6 промисы идут со встроенными Promise.all([ .. ]) и Promise.race([ .. ]), есть несколько других часто используемых паттернов с вариациями этой семантики:

  • none([ .. ]) похож на all([ .. ]), но завершения и отказы меняются местами. Все промисы должны быть отвергнуты, отказы становятся значениями завершения, а значения завершения - наоборот.
  • any([ .. ]) похож на all([ .. ]), но она игнорирует любые отказы, поэтому нужно выполнить только один, а не все.
  • first([ .. ]) похож на гонку в сочетании с any([ .. ]), которая заключается в том, что она игнорирует любые отказы и завершается, как только завершается первый промис.
  • last([ .. ]) похож на first([ .. ]), но только побеждает самое последнее завершение.

Некоторые библиотеки абстракций над промисами обеспечивают такие функции, но вы также можете определить из сами использую механизмы промисов, race([ .. ]) и all([ .. ]).

Например, вот как мы могли бы определить first([ .. ]):

// защитная проверка в стиле безопасного полифила
if (!Promise.first) {
	Promise.first = function(prs) {
		return new Promise( function(resolve,reject){
			// цикл по всем промисам
			prs.forEach( function(pr){
				// нормализовать значение
				Promise.resolve( pr )
				// кто завершится первым, тот и победил, и
				// приводит к разрешению основного промиса
				.then( resolve );
			} );
		} );
	};
}

Примечание Такая реализация first(..) не завершается отказом если все ее промисы завершаются отказом; она просто зависает, подобно тому, как работает Promise.race([]). При необходимости, вы могли бы добавить дополнительную логику для отслеживания каждого отказа промисов и если все они отвергнуты, вызвать reject() для основного промиса. Оставим это как упражнение для читателя.

Одновременные итерации

Иногда вы хотите проходить по списку промисов и выполнить некоторую задачу для них всех, так же, как это можно сделать с синхронными arrays (e.g., forEach(..), map(..), some(..), and every(..)). Если задача Если задача, которую нужно выполнить по отношению к каждому промису, является принципиально синхронной, это отлично работает, точно так же, как мы использовали forEach(..) в предыдущем отрывке кода.

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

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

if (!Promise.map) {
	Promise.map = function(vals,cb) {
		// новый промис, который ждет все сопоставленные промисы
		return Promise.all(
			// примечание: обычная функция массива `map(..)`, превращает
			// массив значений в массив промисов
			vals.map( function(val){
				// заменить `val` новым промисом, который
				// разрешается после того, как `val` асинхронно отмаплена
				return new Promise( function(resolve){
					cb( val, resolve );
				} );
			} )
		);
	};
}

Примечание В этой реализации map(..) вы не можете сигнализировать об асинхронном отказе, но если происходит синхронное исключение/ошибка внутри колбека маппинга (cb(..)), основной промис, возвращающийся из the main Promise.map(..) будет отвергнут.

Давайте проиллюстрируем использование map(..) со списком промисов (вместо простых значений):

var p1 = Promise.resolve( 21 );
var p2 = Promise.resolve( 42 );
var p3 = Promise.reject( "Ой" );

// удвоить значения в списке, даже если они в промисах
Promise.map( [p1,p2,p3], function(pr,done){
	// убедиться, что само значение - это промис
	Promise.resolve( pr )
	.then(
		// извлечь значение как `v`
		function(v){
			// отмапить значение завершения `v` в новое значение
			done( v * 2 );
		},
		// или отмапить в сообщение отказа промиса
		done
	);
} )
.then( function(vals){
	console.log( vals );	// [42,84,"Ой"]
} );

Обзор API промисов

Давайте проведем обзор ES6 Promise API, которые мы уже в какой-то степени наблюдали в этой главе.

Примечание Следующий API является нативным только в ES6, но есть полифилы, совместимые со спецификацией (а не просто расширенные библиотеки промисов), которые могут определить Promise и все связанное с ним поведение, так что вы можете использовать нативные промисы даже в до-ES6 браузерах. Один такой полифил - это "Native Promise Only" (http://github.com/getify/native-promise-only), который написал я!

Конструктор new Promise(..)

Доступный конструктор Promise(..) должен использоваться с new и в него нужно передать колбек-функцию, которая вызывается синхронно/немедленно. Это функция передается в в два колбека, которые действуют как возможности для разрешения промиса. Мы обычно называем их resolve(..) и reject(..):

var p = new Promise( function(resolve,reject){
	// `resolve(..)` чтобы разрешить/завершить промис
	// `reject(..)` чтобы отвергнуть промис
} );

reject(..) просто отвергает промис, а resolve(..) может либо завершить промис или отвергнуть его, в зависимости от того, что передано на вход. Если в resolve(..) передано непосредственное, не-промис, не-then-содержащее значение, то промис завершается с этим значением.

Но если в resolve(..) передается настоящий промис или then-содержащее значение, то это значение будет рекурсивно распаковано, и какое бы ни было его окончательное разрешение/состояние - оно будет принято промисом.

Promise.resolve(..) и Promise.reject(..)

Краткий вариант для создания уже отвергнутого промиса - Promise.reject(..), таким образом эти два промиса равнозначны:

var p1 = new Promise( function(resolve,reject){
	reject( "Ой" );
} );

var p2 = Promise.reject( "Ой" );

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

var fulfilledTh = {
	then: function(cb) { cb( 42 ); }
};
var rejectedTh = {
	then: function(cb,errCb) {
		errCb( "Ой" );
	}
};

var p1 = Promise.resolve( fulfilledTh );
var p2 = Promise.resolve( rejectedTh );

// `p1` станет завершенным промисом
// `p2` станет отвергнутым промисом

И помните, Promise.resolve(..) ничего не дает, если то, что вы передаете ему, является настоящим промисом, она просто непосредственно вернет это значение. Поэтому нет никаких затрат на вызов Promise.resolve(..) со значениями чью природу вы не знаете, если они уже оказались настоящими промисами.

then(..) и catch(..)

Каждый экземпляр промиса (но не пространства имен Promise API) содержит методы then(..) и catch(..), которые позволяют зарегистрировать обработчики завершения и отказа для этого промиса. Как только промис разрешен, будет вызван один из этих обработчиков, но не оба, и он всегда будет вызваться асинхронно (см. "Задачи" в главе 1).

then(..) принимает один или два параметра, первый - для колбека завершения, а второй - для колбека отказа. Если какой-либо из параметров будет опущен или будет передано значение не-функция, то подставляется колбек по умолчанию соответственно. Колбек завершения по умолчанию просто передает сообщение дальше, в то время как колбек отказа по умолчанию просто заново выбрасывает (распространяет дальше) полученную причину ошибки (propagates).

catch(..) принимает только колбека отказа как параметр и автоматически подставляет обработчик завершения по умолчанию, как только что упоминалось. Другими словами, это - эквивалент then(null,..):

p.then( fulfilled );

p.then( fulfilled, rejected );

p.catch( rejected ); // или `p.then( null, rejected )`

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

Promise.all([ .. ]) и Promise.race([ .. ])

Статические вспомогательные функции Promise.all([ .. ]) и Promise.race([ .. ]) в ES6 Promise API обе создают промис как свое возвращаемое значение. Разрешение этого промиса целиком управляется массивом промисов, который вы передаете на вход.

Для Promise.all([ .. ]) все промисы, которые вы передаете, должны завершиться, чтобы возвращаемый промис также завершился. Если какой-либо из промисов будет отвергнут, главный возвращаемый промис будет также немедленно отвергнут (отбрасывая результаты любых других промисов). При завершении вы получаете массив всех переданных в промисы значений завершения. При отказе, вы получаете только значение причины отказа первого промиса. Этот шаблон классически называется "шлюз (gate)": все должны прибыть до того, как откроется шлюз.

Для Promise.race([ .. ]), только первый промис с разрешением (завершение или отказ) "выигрывает", и какое бы ни было его разрешение, оно и становится разрешением возвращаемого промиса. Этот шаблон классически называется "задвижка (latch)": первый, кто откроет задвижку - проходит. Представьте:

var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( "Привет, мир" );
var p3 = Promise.reject( "Ой" );

Promise.race( [p1,p2,p3] )
.then( function(msg){
	console.log( msg );		// 42
} );

Promise.all( [p1,p2,p3] )
.catch( function(err){
	console.error( err );	// "Ой"
} );

Promise.all( [p1,p2] )
.then( function(msgs){
	console.log( msgs );	// [42,"Привет, мир"]
} );

Предупреждение: Будьте осторожны! Если в Promise.all([ .. ]) будет передает пустой массив, то функция завершится немедленно, а вот Promise.race([ .. ]) повиснет навсегда и никогда не разрешится.

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

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

Ограничения промисов

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

Обработка ошибок последовательностей

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

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

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

// `foo(..)`, `STEP2(..)` и `STEP3(..)` -
// обе функции поддерживают промисы

var p = foo( 42 )
.then( STEP2 )
.then( STEP3 );

Хотя это может показаться хитрым и запутанным, p здесь не указывает на первый промис в цепочке (тот, что получен из вызова foo(42)), а вместо этого из последнего промиса, того, который возвращается из вызова then(STEP3).

Кроме того, ни один шаг в цепочке промисов не выполняет собственную обработку ошибок. Это означает, что вы могли бы зарегистрировать обработчик ошибок отказа для p и он получит уведомление если возникнет какая-нибудь ошибка в цепочке:

p.catch( handleErrors );

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

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

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

Единственное значение

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

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

Разделение значений

Иногда вы можете воспринять это как сигнал о том, что вы можете/должны разложить проблему на два или более промисов.

Представьте, что ув ас есть функция foo(..), которая обеспечивает два значения (x и y) асинхронно:

function getY(x) {
	return new Promise( function(resolve,reject){
		setTimeout( function(){
			resolve( (3 * x) - 1 );
		}, 100 );
	} );
}

function foo(bar,baz) {
	var x = bar * baz;

	return getY( x )
	.then( function(y){
		// упаковать оба значения в контейнер
		return [x,y];
	} );
}

foo( 10, 20 )
.then( function(msgs){
	var x = msgs[0];
	var y = msgs[1];

	console.log( x, y );	// 200 599
} );

Сперва, давайте переставим то. что возвращает foo(..) чтобы нам не пришлось упаковывать x и y в единственное значение массива, чтобы для передачи через один промис. Вместо этого мы можем обернуть кажое значение в свой собственный промис:

function foo(bar,baz) {
	var x = bar * baz;

	// вернуть оба промиса
	return [
		Promise.resolve( x ),
		getY( x )
	];
}

Promise.all(
	foo( 10, 20 )
)
.then( function(msgs){
	var x = msgs[0];
	var y = msgs[1];

	console.log( x, y );
} );

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

Но этот подход больше отвечает теории дизайна промисов. Теперь легче в будущем рефакторить, чтобы разделить вычисление x и y на отдельные функции. Это Гораздо чище и гибче позволить вызывающему коду решать, как согласовать эти два промиса используя тут Promise.all([ .. ]), но, конечно, не единственный вариант, а не абстрагироваться от таких деталей внутри foo(..).

Распаковка/разбиение аргументов

Присвоения var x = .. и var y = .. - все еще неудобные накладные расходы. Мы можем использовать некоторые функциональные хитрости (благодарим за предоставленную информацию Реджинальда Брейтуэйта (Reginald Braithwaite), @raganwald в твиттере) вво вспомогательной функции:

function spread(fn) {
	return Function.apply.bind( fn, null );
}

Promise.all(
	foo( 10, 20 )
)
.then(
	spread( function(x,y){
		console.log( x, y );	// 200 599
	} )
)

Это немного лучше! Конечно, вы можете встроить функциональную магию, чтобы избежать дополнительную функцию:

Promise.all(
	foo( 10, 20 )
)
.then( Function.apply.bind(
	function(x,y){
		console.log( x, y );	// 200 599
	},
	null
) );

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

Promise.all(
	foo( 10, 20 )
)
.then( function(msgs){
	var [x,y] = msgs;

	console.log( x, y );	// 200 599
} );

Но лучше всего, ES6 предлагает формат деструктуризации параметра-массива:

Promise.all(
	foo( 10, 20 )
)
.then( function([x,y]){
	console.log( x, y );	// 200 599
} );

Теперь мы придерживаемся мантры "одно значение на промис", но сократили до минимума наш вспомогательный шаблон!

Примечание Для получения дополнительной информации о форматах деструктуризации в ES6 см. книгу За пределами ES6 этой серии.

Единственное разрешение

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

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

Представьте себе сценарий, в котором вы хотите запустить последовательность асинхронных шагов в ответ на стимул (например, событие), который может произойти несколько раз, например, нажатие кнопки.

Это, вероятно, не будет работать так, как вы хотите:

// `click(..)` привязывает событие `"click"` к элементу DOM
// `request(..)` это ранее определенный Ajax-запрос с поддержкой промисов

var p = new Promise( function(resolve,reject){
	click( "#mybtn", resolve );
} );

p.then( function(evt){
	var btnID = evt.currentTarget.id;
	return request( "http://some.url.1/?id=" + btnID );
} )
.then( function(text){
	console.log( text );
} );

Приведенное здесь поведение работает только в том случае, если ваше приложение требует, чтобы кнопка была нажата только один раз. Если кнопка нажата второй раз, промис p уже был разрешен, поэтому второй вызов resolve(..) будет проигнорирован.

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

click( "#mybtn", function(evt){
	var btnID = evt.currentTarget.id;

	request( "http://some.url.1/?id=" + btnID )
	.then( function(text){
		console.log( text );
	} );
} );

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

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

Примечание Другой способ сформулировать это ограничение заключается в том, что было бы неплохо, если бы мы могли создать некую "наблюдаемую штуку", на которую мы могли бы подписать цепочку промисов. Существуют библиотеки, которые создали такие абстракции (такие как RxJS -- http://rxjs.codeplex.com/), но абстракции могут показаться настолько тяжелыми, что вы больше не можете видеть природу промисов. Такая тяжелая абстракция заставляет задуматься о таких важных вопросах, как: являются ли (вне промисов) эти механизмы настолько надежными, насколько сами промисы были разработаны для этого. Мы вернемся к шаблону "Наблюдаемый" в Приложении B.

Инерция

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

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

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

Рассмотрим следующий сценарий, основанный на колбеке:

function foo(x,y,cb) {
	ajax(
		"http://some.url.1/?x=" + x + "&y=" + y,
		cb
	);
}

foo( 11, 31, function(err,text) {
	if (err) {
		console.error( err );
	}
	else {
		console.log( text );
	}
} );

Очевидно ли сразу, каковы первые шаги по преобразованию этого кода, основанного на колбеках, в код, ориентированный на промисы? Зависит от вашего опыта. Чем больше у вас будет практики, тем естественнее оно будет ощущаться. Но, безусловно, Промисы не только в том, что на этикетке написано, как именно это сделать - универсального ответа не существует, так что ответственность лежит на вас.

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

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

// защитная проверка в стиле безопасного полифила
if (!Promise.wrap) {
	Promise.wrap = function(fn) {
		return function() {
			var args = [].slice.call( arguments );

			return new Promise( function(resolve,reject){
				fn.apply(
					null,
					args.concat( function(err,v){
						if (err) {
							reject( err );
						}
						else {
							resolve( v );
						}
					} )
				);
			} );
		};
	};
}

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

Вместо того, чтобы тратить много времени на обсуждение того, как эта вспомогательная функция Promise.wrap(..) работает, давайте просто взглянем на то, как мы ее используем:

var request = Promise.wrap( ajax );

request( "http://some.url.1/" )
.then( .. )
..

Ого, это было довольно просто!

Promise.wrap(..) не создает промис. Она создает функцию, которая создаст промисы. В каком-то смысле, промисо-генерирующая функция может рассматриваться как "фабрика промисов". Я предлагаю "promisory" в качестве названия для такой вещи ("Promise" + "factory" (промис + фабрика)).

Процесс обертывания функции, ожидающей колбек, в функцию с поддержкой промисов иногда называют "поднятием" (lifting) или "промисификацией" (promisifying). Но, похоже, нет стандартного термина для того, как следует называть результирующую функцию, кроме как "поднятая функция", поэтому мне больше нравится "промисофабрика (promisory)", поскольку я считаю его более описательным.

Примечание Promisory - это не выдуманный термин. Это реальное слово, и его определение означает "содержать или передать промис". Именно это и делают эти функции, так что получается довольно идеальное терминологическое соответствие!

Таким образом, Promise.wrap(ajax) создает промисофабрику для ajax(..), который мы назвали request(..) и эта промисофабрика создает промисы для Ajax-ответов.

Если бы все функции уже были бы промисофабриками, нам бы не пришлось создавать из самим, так что лишний шаг - это немного досадно. Но, по крайней мере, шаблон упаковки (обычно) повторяемый, поэтому мы можем поместить его во вспомогательную функцию Promise.wrap(...), как показано на примере, чтобы облегчить кодирование промисов.

Итак, возвращаясь к нашему предыдущему примеру, нам нужна промисофабрика для обоих ajax(..) и foo(..):

// создать промисофабрику для `ajax(..)`
var request = Promise.wrap( ajax );

// отрефакторить `foo(..)`, но оставить его снаружи
// на основе колбека для совместимости с другими
// частями кода пока что, и использовать промис из
// `request(..)` только внутри.
function foo(x,y,cb) {
	request(
		"http://some.url.1/?x=" + x + "&y=" + y
	)
	.then(
		function fulfilled(text){
			cb( null, text );
		},
		cb
	);
}

// теперь, для целей данного кода, сделаем
// промисофабрику для `foo(..)`
var betterFoo = Promise.wrap( foo );

// и используем эту промисофабрику
betterFoo( 11, 31 )
.then(
	function fulfilled(text){
		console.log( text );
	},
	function rejected(err){
		console.error( err );
	}
);

Конечно, в то время как мы рефакторим foo(..), чтобы использовать нашу новую промисофабрику request(..), мы могли бы просто превратить саму foo(..) в промисофабрику, вместо того, чтобы оставлять основу из колбеков и необходимость создания и использования последующей промисофабрики betterFoo(..). Это решение зависит только от того. надо ли оставлять foo(..) колбеко-совместимой с другими частями кода или нет.

Представим:

// `foo(..)` теперь является промисофабрикой, поскольку она
// делегирует работу промисофабрике `request(..)`
function foo(x,y) {
	return request(
		"http://some.url.1/?x=" + x + "&y=" + y
	);
}

foo( 11, 31 )
.then( .. )
..

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

Неотменяемый промис

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

Примечание Многие библиотеки абстракций над промисами предоставляют возможности для отмены промисов, но это ужасная идея! Многие разработчики хотели бы, чтобы промисы были изначально спроектированы с возможностью внешней отмены, но проблема в том, что это позволило бы одному потребителю/наблюдателю промиса влиять на способность другого потребителя наблюдать тот же промис. Это нарушает достоверность (внешнюю неизменяемость) будущего значения и, более того, является воплощением анти-паттерна "действие на расстоянии" ("action at a distance" (http://en.wikipedia.org/wiki/Action_at_a_distance_%28computer_programming%29)). Каким бы полезным он ни казался, на самом деле он приведет вас к тому же кошмару, что и колбеки.

Рассмотрим наш сценарий с тайм-аутом промиса, описанный ранее:

var p = foo( 42 );

Promise.race( [
	p,
	timeoutPromise( 3000 )
] )
.then(
	doSomething,
	handleError
);

p.then( function(){
	// все равно происходит даже в случае тайм-аута :(
} );

"Тайм-аут" был внешним по отношению к p, поэтому само p выполняется дальше, чего мы, вероятно, не хотим.

Одним из вариантов может быть инвазивное определение колбеков разрешения:

var OK = true;

var p = foo( 42 );

Promise.race( [
	p,
	timeoutPromise( 3000 )
	.catch( function(err){
		OK = false;
		throw err;
	} )
] )
.then(
	doSomething,
	handleError
);

p.then( function(){
	if (OK) {
		// выполняется только если не было тайм-аута! :)
	}
} );

Это ужасно. Это работает, но далеко от идеала. В общем случае следует стараться избегать таких сценариев.

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

Примечание Моя библиотека абстракций над промисами asynquence предоставляет именно такую абстракцию и возможность сделать abort() для последовательности, все они будут рассмотрены в Приложении А.

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

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

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

Производительность промисов

Данное ограничение является одновременно и простым, и сложным.

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

Больше работы, больше защитных мер, что означает, что обещания медленнее по сравнению с чистыми, ненадежными колбеками. Это очевидно и, вероятно, просто для восприятия.

Но насколько медленнее? Ну... На этот вопрос, на самом деле, невероятно сложно ответить всесторонне.

Честно говоря, это своего рода сравнение "яблок с апельсинами", так что, вероятно, это неправильный вопрос. На самом деле следует сравнить, насколько специализированная система колбеков со всеми теми же защитами, внедренными вручную, быстрее, чем реализация промисов.

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

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

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

Еще одна небольшая проблема заключается в том, что Promises делают все асинхронным, а это значит, что некоторые немедленно (синхронно) завершенные шаги все равно откладывают выполнение следующего шага до Задачи (см. главу 1). Это означает, что последовательность задач промиса может выполняться чуть медленнее, чем та же самая последовательность с колбеками.

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

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

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

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

Обзор

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

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

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