Pull to refresh

Как я завел дружбу с асинхронностью в JavaScript

Reading time 12 min
Views 43K
JavaScript встречает разработчиков асинхронностью можно сказать чуть ли не с порога. Начинается все с DOM-событий, ajax, таймерами и библиотечными методами, связанными с анимацией (например jQuery-методы fadeIn/fadeOut, slideUp/slideDown). В целом, это все не очень сложно и разобраться с асинхронностью на этом этапе не представляет проблем. Однако, как только мы переходим к написанию более или менее сложных приложений, в которых комбинируется все вышеуказанное, асинхронный поток может сильно затруднить понимание происходящего в коде. Цепочки асинхронных действий, например, анимация > ajax-запрос > инициализация -> анимация, создают достаточно сложную архитектуру, которая не подчиняется строгому направлению «снизу верх». В этой статье я намерен рассказать про свой опыт преодоления трудностей связанных с асинхронным JS.

Помню, на первых порах, одним из самых удивительных моментов в JavaScript для меня было следующее:

for(var i=0; i<3; i++){
	setTimeout(function(){
		console.log(i);
	}, 0);
}

Удивительно было увидеть:
3
3
3

Вместо ожидаемых:
0
1
2

Нудное вступление

Так я понял, что асинхронность не нужно воспринимать как нечто, что выполниться тогда, когда вы этого «примерно» ожидаете (0 миллисекунд, например), а когда закончится исполнение «синхронного» потока ( блокирующего), т.е «как только будет возможность». Привести вразумительную аналогию непросто, так как в реальной жизни практически все процессы асинхронны. Представьте, что вы управляющий строительной компании. Вам пришел заказ построить дом «под ключ», но разрешение на определенный вид работ на этом участке (например, возведение дома) есть только у сторонней компании, и вы вынуждены обращаться к ним. У вас уже есть налаженный алгоритм: залить фундамент, построить дом, покрасить дом, облагородить участок и так далее, однако, в нашем случае вы не строите дом и даже не знаете, когда его построят. Это асинхронный процесс, ваша задача просто передать компании макет и получить готовое здание. Когда дом строите вы, все просто, ваше внимание сосредоточено на текущем процессе: стройка, потом покраска и так далее. Однако, сейчас дом строите не вы. И вам как-то нужно организовать работу вашей бригады, учитывая обстоятельства. Это лучше всего объясняет, почему не стоит блокировать поток выполнения на время асинхронных процессов, — он простаивает. Если же поток выполнения не блокируется, то, пока происходит асинхронное действие, можно заняться чем-то другим.

Самая популярная ошибка, новичка, в терминах JavaScript выглядит примерно так:

function Build(layout){
  //... это асинхронная функция и выполняется она неизвестно сколько
  //... и когда заканчивается, она возвращает JS объект (назовем его house)
}

function paintRoof(house, color){
  house.roof.color = color
  return house;
}

var layout = {/* какой -то макет*/},
    house = {};
Build(layout, house); 
paintRoof(house, 'red');

Очевидно, вернет TypeError и скажет, что не может прочитать свойство roof в undefined, т.к. house.foof еще будет undefined (его просто напросто не успели построить). Надо как-то дождаться, пока house будет инициализирован, а мы не знаем, когда это произойдет.

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

Идея


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

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

Рассмотрим эти варианты поближе.
Я утверждаю, что первый вариант никуда не годится, ведь он построен на таймерах и многочисленных проверках. В самом простом случае, состояния — это булевы переменные. Но ведь асинхронные функции могут представлять собой обработку объекта, который имеет не только 2 состояния, а значительно больше. И мы должны реагировать на каждую комбинацию завершенных состояний по-разному. Представьте 3 асинхронных вызова, которые могут влиять на состояния системы только, изменяя булеву переменную с false на true, когда асинхронное действие закончится. Такая ситуация уже порождает 8 (23) общих состояний системы. Вы сами видите, что подобная архитектура практически некомпонуема, а в сложных приложениях простота компоновки зачастую является решающим фактором. Проверять сочетания состояний непростая затея, особенно, если они не подчинены никакой логике. Очевидно, что, с точки зрения чистоты и ясности кода, это полный кошмар.

Вы же не хотите, чтобы в вашем коде мелькали такие фрагменты?
setTimeout(function(){
  if(state1 == 'success' && state2 == 'success'){ 
    ...
  }else 
  if(state1 == 'success' && state2 == 'error'){
    ...
  }else
  if(state1 == 'error' && state2 == 'success'){ 
    ...
  }else 
  if(state1 == 'error' && state2 == 'error'){
    ...
  }else{
    setTimeout(arguments.callee, 50); 
    //одна из функций еще не повлияла на состояние
  }
},50


Так какие у нас есть варианты?

Первый вариант — путь Promise


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

function Build(layout, onComplete){
	//... async
	onComplete(house);
}

Build(layout, function(buildedHouse){
	return house = paintRoof(buildedHouse, 'red');
});

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

function Build(layout, success, error){
	//... асинхронные действия ok - true , если все прошло удачно
	return ok ? success(house) : error(new Error("Что-то пошло не так"));
}

Build(layout, 
	function(buildedHouse){
		return house = paintRoof(buildedHouse, 'red');
	},
	function(error){
		throw(error);
	}
);


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

async1(args, function(response){
	async2(response, function(response){
		async3(response, function(response){
			hooray(response);
			// Привет разработчикам Node.js.
		});
	});
});

А во-вторых, недостаточная гибкость управления: разрабатывая кусочки приложения, мы берем ответственность в обеспечении корректного выполнения коллбэков на себя. Проблема даже не в гарантии, какой-то из коллбэков точно сработает, в этом вся суть Promise. Проблема в том, что если мы опять же хотим компоновать конкурентные события, мы не знаем какой коллбэк сработает первым, а ведь порой нам это важно. Еще одна проблема: мы регистрируем коллбэки одних модулей внутри асинхронных функций других модулей, далее, — они срабатывают и нам приходится либо связвать коллбэки через бэкдор с внешним состоянием приложения, либо использовать глобальные (или смежные между модулями) данные. И тот и другой вариант, скажем так, не лучшая идея. Мы вынуждены тщательно проектировать архитектуру, специально заточенную для предотвращения смешивания конкурентных событий, в то время, как могли бы воспользоваться гораздо более высокоуровневой абстракцией и применять ее во всех схожих случаях. Если вам сразу пришло в голову, что можно создать некую обертку над асинхронным потоком, то поздравляю, до вас дошла идея PromiseAPI. К счастью, в настоящее время есть возможность писать в стиле:

async1.then(async2).then(async3).then(hooray);

Самое прекрасное то, что всегда можно выбрать паттерн проектирования, который как бы побуждает нас использовать PromiseAPI, включая его как родную свою составляющую. Большинство современных MV* JavaScript фреймворков основанны на этом. Хороший пример — это служба $q в Angular.js.
В качестве ништяка, и если вы следите за новостями, то вас не удивит, что некоторые современные браузеры уже поддерживают нативную реализацию promise. Рассматривать спецификации PromiseAPI выходит за рамки этой статьи, однако я очень рекомендую прочитать эту статью, и ознакомиться со спецификацией Common.js Promises.

Второй вариант — путь Pub/Sub


Асинхронная функция сообщает о том, что она завершилась, публикуя событие. В таком случае мы логически отделяем ту часть кода, которая публикует события, от той части кода, которая реагирует на события. Это может быть оправданно, если приложение, которое мы пишем, можно четко логически разбить на несколько модулей, каждый из которых выполняет строго очерченную функциональность, но притом им нужно взаимодействовать.
Пример
var manager = {/*слушатель событий*/}

function Build(layout){
	//... асинхронные действия, заканчиваются эмитированием события
	manager.emit({
		"type" : "ready",
		"msg" : "Дом построен",
		"house" : buildedHouse
	});
}

manager.on("ready", function(event){
	return house = paintRoof(event.house, 'red');
});

Build(layout);



На первый взгляд, этот подход мало чем отличается от регистрации функций обратного вызова в асинхронные методы, но главное различие в том, что вы сами регулируете взаимодействие между публикатором эвента и подписчиком, что дает определенную свободу, но связанно с некоторыми затратами (см. паттерн «Медиатор»). Главный недостаток такого подхода в том, что нужен некий подписчик событий, который прослушивает объект на предмет возникновения события и вызывает зарегистрированный коллбэк. Это не обязательно должен быть отдельный объект (как в предыдущем примере), вариантов реализации много. Часто между модулями возникают разного рода «логические прослойки», — псевдомодули обслуживающие взаимодействие модулей вне контекста других модулей. Однако, в сравнении с промисами, такой подход более гибок.
Асинхронная функция может возвращать объект с методом связывания, - похоже на то, как в случае с PromiseAPI, асинхронная функция возвращает promise-объект
function Build(layout) {
	...
	return {
		bind : function(event, callback){
			//реализация bind
		}
	}
}
var house = Build(layout);
house.bind('ready', function(event){...});


Строго говоря, это лишь вопрос реализации паттерна Pub/Sub. Как вы это сделаете не столь важно. Если вы писали на NodeJS, вы должны быть знакомы с EventEmitter, тогда вы понимаете, как важно (и круто!) иметь возможность для любого класса использовать методы эмитирования и прослушивания событий. Если речь идет о программировании для браузера, тут довольно много вариантов. Скорее всего вы, рано или поздно, примете решение использовать trigger-методы того фреймворка, который вы используете, большая часть MV* фреймворков позволяют делать это легко и безболезненно (а некоторые :) позволяют этого вообще не делать). В любом случае теория достаточно подробно описана. Одним из положительных примеров является сочетание паттернов модуль-фасад-медиатор, подробнее об этом можно прочитать здесь.

Проектирование


Когда вы начинаете писать более или менее большие приложения, вы хотите разделить логические части архитектуры так, чтобы иметь возможность разрабатывать и поддерживать их отдельно друг от друга. В асинхронном программировании модули возвращают результат не сразу по вызову метода API, и поэтому последовательное выполнение запросов в модуль и обработка ответов другими частями приложения принципиально невозможна. Возможность регистрировать внутрь модуля обработчик извне вполне себе удовлетворительный метод, но надо понимать, что, если вы планируете расширять взаимодействие между модулями, то можете прийти к «коллбэчному аду», которого стоит избегать. С другой стороны. порой бывают вполне себе простые модули, которые предоставляют конечный API, который вряд ли будет масштабироваться, тогда архитектура на непосредственном внедрении коллбэков может и угодить вашим требованиям.
Например модуль на основе jQuery управляющий ajax-прелоадером
var AjaxPreloader = (function(){
	
	function AjaxPreloader(spinner){
		this.spinner = spinner;
	}

	AjaxPreloader.prototype.show = function(onComplete) {
		this.spinner.fadeIn(onComplete);
		return this;
	};

	AjaxPreloader.prototype.hide = function(onComplete) {
		this.spinner.fadeOut(onComplete);
		return this;
	};


	return AjaxPreloader;
})();

var preloader = new AjaxPreloader($("#preloader"));

preloader.show(function(){
	div.load("/", preloader.hide);
});


Если же вы решили перейти на сторону PromiseApi, вы бы избавились от этих вложенностей и с небольшими модификациями писали бы вот так:
preloader
	.show()
	.then(function(){
		return div.load('/')
	})
	.then(
		function(response){},  //success
		function(error){} //error
	)
	.always(preloader.hide);


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


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

var root = $("#container"); //главный контейнер, где находится все приложение

root.on("someEvent", function(){
	//обработчик
});

root.trigger("someEvent"); //эмитирует эвент на элементе root

Чтобы не регистрировать коллбэки внутрь каких-то модулей приложения, а особенно не уделять внимание контексту выполнения, но притом сохранить логическое разделение частей приложения, многие просто эмитируют кастомное событие на каком-то элементе DOM'a, чтобы потом ловить его в каком-то другом месте приложения и выполнять нужные действия. Таким образом модули зависят только от одного элемента, а мы, вместо регистрации коллбэков, просто передаем в модуль лишний параметр — элемент документа, который мы собираемся прослушивать. Строго говоря, это довольно спорная практика, но сама идея хорошая. Когда модуль публикует события, а приложение прослушивает их, это довольно удобно во многих отношениях.
Для меня стало обычным делом пользоваться оберткой над объектами, которая расширяет модули стандартными Pub/Sub методами.

var module = PubSub({
	load: function(url){
		...
		this.emit('loaded', data);
	}
});

module.on('loaded', function(data){
	...
});

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

Event-driven приложения


В последнее время, актуальной для меня темой является компоновка событий. Неприятная сторона в том, что события происходят не только в разных местах, но и в разное время. Комбинировать разнородные события без каких-либо специальных приемов мягко говоря неприятно. При всем при этом есть особого рода приложения, которые можно описать, как «сильно-событийные», которые состоят из множества разных частей. Эти части могут взаимодействовать друг с другом, с пользователем, слать данные на сервер, или просто висеть в холде. Попытки организовать все возможные сочетания событий с помощью традиционных императивов if/then/else — это комбинаторный взрыв мозга. Как ни странно, методология функционального программирования, примененная к такого рода приложениям, значительно облегчает жизнь. Есть несколько библиотек, которые предоставляют возможность описывать сложные зависимости между разными событиями в декларативном стиле привычного функционального программирования (см. Bacon.JS, Reactive Extensions — RxJS). Я не буду разбирать эти библиотеки в этой статье, только скажу что сейчас использую самописную библиотеку, чем-то схожую с Bacon.js, но с большим упором на компоновку и деструктуризацию асинхронного потока. Предоставляю фрагмент рабочего кода, снабженного комментариями:
Фрагмент кода из мини-игрушки а-ля Swarmation с применением web-socket
// потоки данных
var keyups = obj.stream('keyup'), arrowUps = keyups.filter(isArrows);

// чистые функции
function isArrows(which){ ... }	// нажата ли стрелка
function vectorDirection(which){ ... }	// перевод из event.which в объект вида vector2
function allowedMoves(direction){ ... } // вычисление разрешенных ходов
function isWinerPosition(pos){ ... } // предикат победы игрока

// инициализация
enemy.moves = socket.stream('playerMoves'); // поток ходов противника
player.moves = arrowUps.filter(allowedMoves).map(vectorDirection); // ходов игрока
game.ticks = timer.stream('tick'); // цикл анимации
game.pause = keyups.filter(function(which){ return which==19}); // нажатия на кнопку паузы 

// логика
game.ticks.syncWith(enemy.moves).listen(redraw); // циклы анимации совмещаются с ходами противника и все это перерисовывается
player.moves.listen(redraw);  // ходы игрока перерисовываются

game.ticks 
	.syncWith(enemy.moves) // циклы анимации совмещаются с шагами противника 
	.produceWith(playerMoves, function(pos1, pos2){ // и сопоставляются с шагами игрока
		if(cmp.equals(pos1, pos2)){ // и если они совпали
			socket.emit('win'); // то противник победил
			game.ticks.lock(); // а игрок проиграл
			game.emit('loose', {
				position : pos1,
			});
		}
});

game.pause.toggle([ // нажатия на паузу переключат состояние
	function(){
		game.ticks.lock(); // блокируют циклы анимаций 
		player.moves.lock(); // и потоки шагов игрока
		socket.emit('pause'); // и эмитируют событие паузы для веб-сокета
	},
	function(){
		game.ticks.unlock(); // и соответственно разблокировывают
		player.moves.unlock();
		socket.emit('run');
	}
]);

player.moves
	.filter(isWinnerPosition) // шаги игрока, которые попали на позицию победы
	.listen(function(pos){  
		game.ticks.lock(); // блокируют циклы анимации 
		socket.emit('loose'); // и эмитируют событие проигрыша противника 
		game.emit('win', { // и победы игрока. Ура!
			position: pos
		});
});


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

Спасибо за внимание, да пребудет с вами сила!
Only registered users can participate in poll. Log in, please.
Интересна ли вам будет специальная event-driven библиотека, облегчающая обслуживание и компоновку событий
11.57% Да, главным образом из-за компоновки 31
10.82% Да, главным образом из-за функционального подхода 29
23.13% Не знаю, но заинтересован 62
7.84% Не знаю, вряд ли посмотрю 21
12.31% Нет, поставленные задачи решаются и без лишних усилий 33
16.42% Нет, уже полно таких библиотек 44
17.91% Трудно ответить / все зависит от реализации :) 48
268 users voted. 126 users abstained.
Tags:
Hubs:
+6
Comments 15
Comments Comments 15

Articles