Skip to content

Latest commit

 

History

History
589 lines (395 loc) · 50.4 KB

File metadata and controls

589 lines (395 loc) · 50.4 KB

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

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

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

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

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

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

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

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

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

Продолжение

Вернемся к примеру колбэка, который мы изучали в главе 1 и немного изменим его:

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

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

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

Давайте еще упростим наш пример:

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

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

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

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

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

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

Последовательное мышление

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

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

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

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

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

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

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

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

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

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

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

Выполнение в сравнении с планированием

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

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

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

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

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

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

Когда мы пишем синхронный код, оператор за оператором, он очень похож на ToDo-лист:

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) ;
} );

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

Такой код часто называют «callback hell», а иногда даже «пирамидой обреченности» (из-за бокового отступа).

Но «callback hell» фактически не имеет отношения ко вложенности или отступам. Эта проблема гораздо более глубокая. Дальше мы рассмотрим, что к чему.

Сначала мы ждем события «клика», затем мы ждем, когда таймер выполнится, мы ожидаем ajax-ответа, затем можем повторить все снова.

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

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

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

Потом:

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

потом продолжается:

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

Наконец, еще позже:

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

Есть несколько проблем, относительно линейности этого кода.

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

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

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-разработчиков, сделавших фейспалм.

Так проблема во вложенности? Неужели из-за нее сложно следить за асинхронностью? Отчасти.

Позвольте, я перепишу наш пример, не используя вложенность:

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();
	}
}

Это вариант не так сложно анализировать, как предыдущий со вложенностью. Но тем не менее — это все еще «callback hell». Почему?

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

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

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

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

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

Вот что такое «callback hell»! Вложенность/отступы — это уже вторично.

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

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

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

Давайте снова рассмотрим понятие колбэка, как продолжения (она же вторая половина) нашей программы:

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

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

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

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

Сказ о пяти колбэках

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

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

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

Все это может выглядеть так:

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

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

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

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

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

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

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

Что же дальше?

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

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

var tracked = false;

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

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

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

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

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

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

Теперь вы понимаете, насколько адским бывает «callback hell».

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

Некоторые из вас могут скептически относиться к этой проблеме. Возможно, вы вообще не используете посторонние утилиты. Возможно, вы используете версионные 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

Это распространенная практика делать такие проверки. Даже в том коде, которому вы доверяете. Тут работает известный политический принцип — «Доверяй, но проверяй».

Разве не стоит делать такие проверки и для асинхронных колбэков? Конечно, стоит.

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

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

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

Попытка сохранить колбэки

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

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

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

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

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

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

Примечание: Вариант с раздельными колбэками используют ES6-промисы. Мы рассмотрим их более подробно в следующей главе.

Другой распространенный шаблон архитектуры колбэков называется «error-first стиль» (иногда называемый «стиль Node», поскольку его использует большинство 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() {
		// таймаут еще не наступил?
		if (intv) {
			clearTimeout( intv );
			fn.apply( this, [ null ].concat( [].slice.call( arguments ) ) );
		}
	};
}

Используем её здесь:

// используем "error-first style"
function foo(err,data) {
	if (err) {
		console.error( err );
	}
	else {
		console.log( data );
	}
}

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

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

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

Примечание: Более подробно о Залго можно почитать здесь и здесь.

Рассмотрим код:

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

var a = 0;

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

Что мы получим в консоли? 0 — синхронный вызов или 1 — асинхронный вызов? Зависит... от условий.

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

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

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

	fn = null;

	return function() {
		if (intv) {
			fn = orig_fn.bind.apply(
				orig_fn,
				[this].concat( [].slice.call( arguments ) )
			);
		}
		else {
			orig_fn.apply( this, arguments );
		}
	};
}

И использовать ее так:

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

var a = 0;

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

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

Да, еще одна проблема решена! Но это неэффективное решение, и оно «раздует» ваше приложение еще сильнее.

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

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

Итоги

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

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

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

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

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

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

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