Skip to content

Latest commit

 

History

History
606 lines (408 loc) · 63.7 KB

File metadata and controls

606 lines (408 loc) · 63.7 KB

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

Глава 2: Колбеки

В главе 1 мы рассмотрели терминологию и концепции асинхронного программирования в JavaScript. Наше внимание сосредоточено на понимании однопоточной (по одному за раз) очереди цикла событий, которая управляет всеми «событиями» (вызовами асинхронных функций). Мы также исследовали различные способы, которыми шаблоны конкурентности объясняют отношения (если они есть!) между одновременно запущенными цепочками событий или «процессами» (задачами, вызовами функций и т.д.).

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

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

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

Бесчисленные JS-программы, даже очень изощренные и сложные, были написаны только на основе коллбеков (конечно, с использованием шаблонов параллелизма, которые мы рассмотрели в главе 1). Функция обратного вызова — это асинхронная рабочая лошадка для JavaScript, и она достойно выполняет свою работу.

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

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

Продолжения

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

// A
ajax( "..", function(..){
	// C
} );
// B

// A и // B представляют первую половину программы (она же сейчас), а //C отмечает вторую половину программы (она же позже). Первая половина выполняется сразу, а потом идет "пауза" неопределенной длины. В какой-то момент в будущем, если вызов Ajax завершится, программа продолжит с того места, где остановилась, и продолжит со второй половиной.

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

Сделаем код еще проще:

// A
setTimeout( function(){
	// C
}, 1000 );
// B

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

Большинство читателей сейчас, вероятно, подумали или сказали что-то вроде: «Выполните A, затем установите тайм-аут на 1000 миллисекунд, затем, как только это сработает, выполните C». Насколько точно было ваше толкование?

Возможно, вы поймали себя на том, что сами отредактировали: «Выполните А, установите тайм-аут на 1000 миллисекунд, затем выполните B, затем, когда истечет тайм-аут, сделайте С». Это более точно, чем первая версия. Вы можете заметить разницу?

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

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

Последовательный мозг

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

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

Ответ может вас удивить: вероятно, нет.

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

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

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

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

Звучит ли это для вас подозрительно как конкурентность с асинхронными событиями (подобная той, что происходит в JS)?! Если нет, вернитесь и прочитайте главу 1 еще раз!

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

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

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

Делать VS Планировать

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

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

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

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

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

Когда разработчик пишет код, он планирует ряд действий, которые должны произойти. Если он хороший разработчик, то он тщательно планирует** это. «Мне нужно установить z в значение x, а затем x в значение y» и так далее.

Когда мы пишем синхронный код, оператор за оператором, он работает во многом подобно нашему списку дел:

// поменять местами `x` и `y` (через временную переменную `z`)
z = x;
x = y;
y = z;

Эти три оператора присваивания являются синхронными, поэтому x = y ожидает завершения z = x, а y = z, в свою очередь, ожидает завершения x = y. Другими словами, эти три утверждения связаны во времени с выполнением в определенном порядке, одно за другим. К счастью, нам не нужно беспокоиться о каких-либо подробностях, связанных с асинхронными событиями. Если бы мы это сделали, код быстро стал бы намного сложнее! Итак, если синхронное планирование мозга хорошо соотносится с операторами синхронного кода, насколько хорошо наш мозг справляется с планированием асинхронного кода?

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

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

"Мне нужно в магазин, но по дороге я уверен, что мне позвонят, так что "Привет, мама", и пока она начнет говорить, я поищу адрес магазина по GPS, но это займет секунду, поэтому я убавлю радио, чтобы лучше слышать маму, а потом пойму, что забыл надеть куртку, а на улице холодно, но неважно, продолжаю ехать и разговариваю с мамой, а затем звон ремня безопасности напоминает мне о том, что нужно пристегнуться, так что «Да, мама, я пристегнут ремнем безопасности, я всегда пристегиваюсь!». Ах, наконец-то GPS получил направление, теперь...»

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

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

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

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

Примечание: Единственное, что может быть хуже, чем не знать, почему некоторые коды не работают, — это не знать, почему они вообще работают! Это классический менталитет «карточного домика»: «он работает, но не знаю почему, поэтому никто его не трогает!» Возможно, вы слышали «Ад — это другие люди» (Сартр), а программистский мем — «Ад — это код других людей». Я искренне верю: «Ад - это не понимать собственный код». И обратные вызовы являются одним из основных виновников.

Вложенные/связанные обратные вызовы

Рассмотрим:

listen( "click", function handler(evt){
	setTimeout( function request(){
		ajax( "http://some.url.1", function response(text){
			if (text == "hello") {
				handler();
			}
			else if (text == "world") {
				request();
			}
		} );
	}, 500) ;
} );

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

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

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

Во-первых, мы ждем события «click», затем мы ждем срабатывания таймера, затем мы ждем возврата ответа Ajax, после чего он может повторить все это снова.

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

Сначала (сейчас):

listen( "..", function handler(..){
	// ..
} );

позже:

setTimeout( function request(..){
	// ..
}, 500) ;

еще позже:

ajax( "..", function response(..){
	// ..
} );

Наконец (наиболее позже):

if ( .. ) {
	// ..
}
else ..

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

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

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

doA( function(){
	doB();

	doC( function(){
		doD();
	} )

	doE();
} );

doF();

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

  • doA()
  • doF()
  • doB()
  • doC()
  • doE()
  • doD()

Вы поняли это правильно, когда впервые взглянули на код?

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

doA( function(){
	doC();

	doD( function(){
		doF();
	} )

	doE();
} );

doB();

Теперь я назвал их в алфавитном порядке в порядке фактического выполнения. Но я все еще держу пари, даже имея опыт в этом сценарии, отслеживание в порядке «A -> B -> C -> D -> E -> F» не является естественным для многих из вас, читатели. Конечно, ваши глаза очень много прыгают вверх и вниз по фрагменту кода, верно?

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

Что, если doA(..) или doD(..) на самом деле не являются асинхронными, как мы, очевидно, предполагали? Ой, теперь порядок другой. Если они оба синхронизированы (и, возможно, только иногда, в зависимости от условий программы в то время), порядок теперь будет «A -> C -> D -> F -> E -> B».

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

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

Но позвольте мне переписать предыдущий пример вложенного события/тайм-аута/Ajax без использования вложенности:

listen( "click", handler );

function handler() {
	setTimeout( request, 500 );
}

function request(){
	ajax( "http://some.url.1", response );
}

function response(text){
	if (text == "hello") {
		handler();
	}
	else if (text == "world") {
		request();
	}
}

Эта формулировка кода едва ли так узнаваема, как проблемы вложенности/отступов в его предыдущей форме, и все же она ничуть не менее подвержена «аду обратных вызовов». Почему?

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

Еще одна вещь, на которую следует обратить внимание: чтобы связать шаги 2, 3 и 4 вместе, чтобы они выполнялись последовательно, единственный доступный способ, которые обратные вызовы дают нам, — это жестко закодировать шаг 2 в шаг 1, шаг 3 в шаг 2, шаг 4 в шаг 3, и так далее. Жесткое кодирование не обязательно плохо, если это действительно фиксированное условие, что шаг 2 всегда должен вести к шагу 3.

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

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

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

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

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

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

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

Проблемы с доверием

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

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

// A
ajax( "..", function(..){
	// C
} );
// B

// A и // B происходят сейчас под непосредственным контролем основной JS-программы. Но // C откладывается, чтобы произойти позже, и находится под контролем другой стороны – в данном случае, функции ajax(..). В общем смысле такая передача управления обычно не вызывает много проблем для программ.

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

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

Сказка о пяти обратных вызовах

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

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

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

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

analytics.trackPurchase( purchaseData, function(){
	chargeCreditCard();
	displayThankyouPage();
} );

Достаточно легко, верно? Вы пишете код, тестируете его, все работает, и вы запускаете его в производство. Все счастливы!

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

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

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

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

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

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

Что дальше?

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

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

var tracked = false;

analytics.trackPurchase( purchaseData, function(){
	if (!tracked) {
		tracked = true;
		chargeCreditCard();
		displayThankyouPage();
	}
} );

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

Но затем один из ваших QA-инженеров спрашивает: «Что произойдет, если они никогда не вызовут обратный вызов?» Упс. Никто из вас не думал об этом.

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

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

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

Теперь вы немного более полно осознаете, насколько адским является «ад обратных вызовов».

Не только чужой код

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

Итак, подумайте над этим: можете ли вы вообще действительно доверять утилитам, которыми вы теоретически управляете (в своей собственной кодовой базе)?

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

Чрезмерное доверие входным данным:

function addNumbers(x,y) {
    // + перегружен приведением,
    // чтобы также быть конкатенацией строк,
    // поэтому эта операция не является строго безопасной
    // в зависимости от того, что передано.
	return x + y;
}

addNumbers( 21, 21 );	// 42
addNumbers( 21, "21" );	// "2121"

Защита от ненадежного ввода:

function addNumbers(x,y) {
    // обеспечиваем числовой ввод
	if (typeof x != "number" || typeof y != "number") {
		throw Error( "Bad parameters" );
	}

	// если мы доберемся сюда, + безопасно выполнит числовое сложение
	return x + y;
}

addNumbers( 21, 21 );	// 42
addNumbers( 21, "21" );	// Error: "Bad parameters"

Или, возможно, все еще безопасно, но дружелюбнее:

function addNumbers(x,y) {
    // обеспечиваем числовой ввод
	x = Number( x );
	y = Number( y );

	// + будет безопасно делать числовое сложение
	return x + y;
}

addNumbers( 21, 21 );	// 42
addNumbers( 21, "21" );	// 42

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

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

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

Самая неприятная проблема с обратными вызовами — это инверсия управления, ведущая к полному разрыву всех этих линий доверия.

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

Действительно ад.

Попытка сохранить обратные вызовы

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

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

function success(data) {
	console.log( data );
}

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

ajax( "http://some.url.1", success, failure );

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

Примечание. Этот дизайн с разделенным обратным вызовом используется API ES6 Promise. Мы рассмотрим обещания ES6 более подробно в следующей главе.

Другой распространенный шаблон обратного вызова называется «стиль с ошибкой в первую очередь» (иногда его также называют «стилем узла», поскольку это также соглашение, используемое почти во всех API-интерфейсах Node.js), где первый аргумент одного обратного вызова зарезервирован для объекта ошибки (если есть). В случае успеха этот аргумент будет пустым/ложным (и любые последующие аргументы будут данными об успехе), но если сигнализируется результат ошибки, первый аргумент устанавливается/истинный (и обычно больше ничего не передается):

function response(err,data) {
	// ошибка?
	if (err) {
		console.error( err );
	}
	// в иных случаях успех
	else {
		console.log( data );
	}
}

ajax( "http://some.url.1", response );

В обоих этих случаях следует соблюдать несколько вещей.

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

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

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

function timeoutify(fn,delay) {
	var intv = setTimeout( function(){
			intv = null;
			fn( new Error( "Timeout!" ) );
		}, delay )
	;

	return function() {
		// timeout уже произошёл?
		if (intv) {
			clearTimeout( intv );
			fn.apply( this, [ null ].concat( [].slice.call( arguments ) ) );
		}
	};
}

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

// использование обратного вызова в стиле «сначала ошибка»
function foo(err,data) {
	if (err) {
		console.error( err );
	}
	else {
		console.log( data );
	}
}

ajax( "http://some.url.1", timeoutify( foo, 500 ) );

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

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

Примечание. Дополнительную информацию о Zalgo см. в статье Орена Голана «Не выпускайте Zalgo!» (https://github.com/oren/oren.github.io/blob/master/posts/zalgo.md) и «Разработка API-интерфейсов для асинхронности» Исаака З. Шлютера (http://blog.izs.me/post/59142742143/designing-apis-for-asynchrony).

Рассмотрим:

function result(data) {
	console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", result );
a++;

Будет ли этот код печатать «0» (вызов обратного вызова синхронизации) или «1» (вызов асинхронного обратного вызова)? Зависит... от условий.

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

Что, если вы не знаете, всегда ли рассматриваемый API будет выполняться асинхронно? Вы можете изобрести утилиту, подобную этой asyncify(..), для доказательства концепции:

function asyncify(fn) {
	var orig_fn = fn,
		intv = setTimeout( function(){
			intv = null;
			if (fn) fn();
		}, 0 )
	;

	fn = null;

	return function() {
		// срабатывает слишком быстро, прежде чем сработает таймер
        // `intv`, чтобы показать, что асинхронный ход прошел?
		if (intv) {
			fn = orig_fn.bind.apply(
				orig_fn,
                // добавляем `this` обёртки к `bind(..)`
                // параметры вызова, а также каррирование любых
                // передается в параметрах
				[this].concat( [].slice.call( arguments ) )
			);
		}
        // уже асинхронно
		else {
			// вызвать исходную функцию
			orig_fn.apply( this, arguments );
		}
	};
}

Можно использовать asyncify(..) следующим образом:

function result(data) {
	console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", asyncify( result ) );
a++;

Независимо от того, находится ли запрос Ajax в кеше и разрешает попытку немедленного вызова обратного вызова, или его нужно получить по сети и, таким образом, выполнить позже асинхронно, этот код всегда будет выводить 1 вместо 0 -- result( ..) не может не вызываться асинхронно, что означает, что a++ имеет шанс запуститься раньше, чем result(..).

Ура, еще одна проблема доверия решена! Но это неэффективно и еще больше раздувает шаблон, чтобы отягощать ваш проект.

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

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

Обзор

Обратные вызовы — это фундаментальная единица асинхронности в JS. Но их недостаточно для меняющегося ландшафта асинхронного программирования по мере взросления JS.

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

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

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

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

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

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