Знакомство с асинхронностью у JavaScript разработчиков обычно происходит на первых днях. Как правило это AJAX-запросы, таймеры и библиотечные функции, связанные с анимацией, например jQuery-методы fadeIn/fadeOut, slideUp/slideDown и так далее. В целом, это все не очень сложно, и разобраться с асинхронностью на этом этапе не представляет проблем.
Однако, как только разработчик переходит к написанию более или менее сложных приложений, в которых комбинируется все вышеуказанное, асинхронный поток может сильно затруднить понимание и поддержку кода. Цепочки асинхронных действий, например, анимация > ajax-запрос > анимация, создают достаточно сложную архитектуру, которая не подчиняется строгому направлению "снизу верх".
Попробую изложить свой опыт того, как я перестал бояться асинхронности.
Две возможности.
В реальной жизни практически все процессы асинхронны, поэтому мне придется прибегнуть к довольно странной метафоре. Представьте, что вы управляющий компании, которая строит дома, участки, занимается ландшафтным дизайном и т.п. Вам пришел заказ построить домик в частном секторе, но разрешение на стройку есть только у независимой компании, и вы вынуждены обращаться к ним. У вас уже есть налаженный алгоритм: построить дом, покрасить дом, облагородить участок и так далее, однако, в нашем случае вы не строите дом и даже не знаете, когда его построят. Это асинхронный процесс, ваша задача просто передать компании макет и получить готовое здание. Когда дом строите вы, все просто, ваше внимание сосредоточено на текущем процессе: стройка, потом покраска и так далее. Однако, сейчас дом строите не вы, и это лучше всего объясняет, почему не стоит блокировать поток выполнения на время асинхронных процессов, - он простаивает. Если же поток выполнения не блокируется, то, пока выполняется асинхронное действие, можно заняться чем-то другим.В терминах JavaScript, это бы выглядело так
function Build(layout){
//... это асинхронная функция и выполняется она неизвестно сколько
//... и когда заканчивается, она возвращает JS объект (назовем его house)
}
function PaintRoof(house, color){
house.roof.color = color
return house;
}
var layout = {/* какой -то макет*/}
var house = Build(layout);
PaintRoof(house, 'red');
Очевидно, вернет TypeError и скажет, что не может записать свойство в undefined, т.к. house
скорее всего еще будет undefined
(его просто напросто не успели построить). Надо как-то дождаться, пока house
будет инициализирован, а мы не знаем, когда это произойдет.
На самом деле существует не так уж много инструментов для работы с асинхронностью. Возвращаясь к примеру со строительной компанией. Вы как управляющий знаете, какие процессы зависят друг от друга. Все операции с домом (покраска, внутреннее обустройство и пр.) невозможны, пока сам дом не построен. Как же урегулировать рабочий процесс? Есть две идеи:
- Периодически спрашивать у подрядчиков, готов ли дом или нет?
- Попросить подрядчиков сообщить вам, когда дом будет готов.
- Периодически проверять состояния системы, которые могут измениться только в результате выполнения асинхронной функции.
- Зарегистрировать функцию обратного вызова, и передать ей управление, по окончанию асинхронного процесса.
- Попросить асинхронную функцию опубликовать событие по окончанию действия и прослушивать это событие, чтобы повесить на него какой-нибудь обработчик.
Первый вариант
Я утверждаю, что первый вариант никуда не годится, он построен на многочисленных таймерах и проверках. В самом простом случае, состояния - это булевы переменные, но асинхронные функции могут не только влиять на состояние в форме булевых переменных, а также иметь несколько вариантов завершения. А что если запущено несколько асинхронных функций, и мы должны реагировать на каждую комбинацию завершенных состояний по-разному? Кажется, что в таком случае одними коллбэками и событиями не обойтись, однако ближе к концу статьи я покажу, что это не так. Представьте 3 асинхронных вызова, которые могут влиять на состояния системы только, изменяя булеву переменную сfalse
на true
, когда асинхронное действие закончится. Такая ситуация уже порождает 8 общих состояний системы.state1: 0 0 0 1 0 1 1 1
state2: 0 0 1 1 1 0 1 0
state3: 0 1 1 1 0 0 0 1
Проверять сочетания состояний непростая затея, особенно, если они не подчинены никакой логике. Очевидно, что, с точки зрения чистоты и ясности кода, это полный кошмар.
А теперь посмотрите, как выглядит проверка 2-х явных состояний, зависящих от двух асинхронных вызовов.
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)
Если нам нужно отследить не только явные состояния, а еще и незавершенные (3 возможных состояния у переменной), то проверок будет не 4, а 9.
Надеюсь, вы согласны со мной в том, что так делать не стоит.
Второй вариант: Promise
Все очень просто, мы передаем асинхронной функции коллбэков, который она вызовет по окончанию.function Build(layout, onComplete){
//... async
onComplete(house);
}
var house = Build(layout, function(house){
return PaintRoof(house, 'red');
});
Этот путь рано или поздно приведет вас к PromiseAPI, который, как правило, предоставляет возможность реагировать на 2 логичных результата завершения асинхронного действия: в случае успеха и в случае ошибки. Если не реализовывать или не пользоваться готовыми реализациями PromiseAPI, то, по аналогии с популярными реализациями PromiseAPI, можно передавать асинхронной функции 2 коллбэка для разных результатов. Этим самым вы решаете задачу постоянного слежения за изменениями. Теперь, когда изменения происходят, функции обратного вызова срабатывают сами.
function Build(layout, success, error){
//... асинхронные действия ok - true , если все прошло удачно
return ok ? success(house) : error(new Error("Что-то пошло не так"));
}
var house = Build(layout,
function(house){
return PaintRoof(house, 'red');
},
function(error){
throw(error);
});
У такого подхода тоже есть очевидные минусы. Во-первых, слишком запутанная вложенность функций в случае последовательности асинхронных действий, например, если их уже 3, то выглядеть это может так:
async1(args, function(response){
async2(response, function(response){
async3(response, function(response){
hooray(response);
});
});
});
А во-вторых, недостаточная общность: разрабатывая кусочки приложения, мы берем ответственность в обеспечении корректного выполнения коллбэков на себя, в то время, как могли бы воспользоваться гораздо более высокоуровневой абстракцией и применять ее во всех схожих случаях. Если вам сразу пришло в голову, что можно создать некую обертку над асинхронным потоком, то поздравляю, до вас дошла идея PromiseAPI. К счастью, в настоящее время есть возможность писать в стиле:
async1.then(async2).then(async3).then(hooray);
Если вы следите за новостями, вас не удивит, что большинство современных адекватных браузеров поддерживают нативную реализацию promise. Рассматривать спецификации PromiseAPI выходит за рамки этой статьи, однако я очень рекомендую прочитать эту статью, и ознакомиться со спецификацией Common.js Promises.
Вариант третий: Pub/Sub
Третий вариант, когда асинхронная функция сообщает о том, что она завершилась, публикуя событие. В таком случае мы логически отделяем ту часть кода, которая публикует события, от той части кода, которая реагирует на события. Это может быть оправданно, если приложение, которое мы пишем, можно четко логически разбить на несколько модулей, каждый из которых выполняет строго очерченную функциональность, но притом им нужно взаимодействовать.var manager = {/*приемщик событий*/}
function emit(event){
//эмитирует событие
}
function Build(layout){
//... асинхронные действия, заканчиваются эмитированием события
manager.emit({
"type" : "ready",
"msg" : "Дом построен",
"house" : house
});
}
Build(layout);
manager.on("ready", function(event){
house = PaintRoof(event.house, 'red');
});
На первый взгляд, этот подход мало чем отличается от регистрации функций обратного вызова в асинхронные методы, но главное различие в том, что вы сами регулируете взаимодействие между публикатором эвента и подписчиком, что дает определенную свободу, но связанно с некоторыми затратами (см. паттерн "Медиатор"). Главный недостаток такого подхода в том, что нужен некий приемник событий, который прослушивает объект на предмет возникновения события и вызывает зарегистрированный коллбэк. Это не обязательно должен быть отдельный объект, сама асинхронная функция может возвращать объект с методом связывания, - похоже на то, как в случае с PromiseAPI, асинхронная функция возвращает promise-объект.function Build(layout) {
...
return {
bind : function(event, callback){
//реализация bind
}
}
}
var house = Build(layout);
house.bind('ready', function(event){...});
Логичная реализация получателя сообщений так или иначе нужна, если вы писали на NodeJS, вы должны быть знакомы с EventEmitter, тогда вы понимаете, как важно (и круто!) иметь возможность для любого класса использовать методы эмитирования и прослушивания событий.
Если речь идет о программировании для браузера, тут довольно много вариантов. Скорее всего вы, рано или поздно, примете решение использовать trigger-методы того фреймворка, который вы используете, большая часть MV* фреймворков позволяют делать это легко и безболезненно (а некоторые :) позволяют этого вообще не делать). В любом случае теория достаточно подробно описана. Одним из положительных примеров является сочетание паттернов модуль-фасад-медиатор, подробнее об этом можно прочитать здесь.Проектирование
Проектирование на основе коллбэков
Когда вы начинаете писать более или менее большие приложения, вы хотите разделить логические части архитектуры так, чтобы иметь возможность разрабатывать и поддерживать их отдельно друг от друга. Как вы уже знаете, в асинхронном программировании модули возвращают результат не сразу по вызову функции, и поэтому последовательное выполнение запросов в модуль и обработка ответов другими частями приложения принципиально невозможна. Возможность регистрировать внутрь модуля обработчик извне вполне себе удовлетворительный метод, но надо понимать, что, если вы планируете расширять взаимодействие между модулями, то можете прийти к "коллбэчному аду", которого стоит избегать. С другой стороны. порой бывают вполне себе простые модули, которые предоставляют конечный API, который вряд ли будет масштабироваться, тогда архитектура на непосредственном внедрении коллбэков может и угодить вашим требованиям.В качестве примера возьмем модуль на основе jQuery управляющий ajax-прелоадером, который должен перекрывать экран/область экрана, когда выполняется подгрузка данных.
var AjaxPreloader = (function(){
var fixedOnComplete = null;
function AjaxPreloader(overlay){
this.overlay = overlay;
}
AjaxPreloader.prototype.show = function(onComplete) {
this.overlay.fadeIn(onComplete || fixedOnComplete);
return this;
};
AjaxPreloader.prototype.hide = function(onComplete) {
this.overlay.fadeOut(onComplete || fixedOnComplete);
return this;
};
AjaxPreloader.prototype.registerOnComplete = function(callback) {
fixedOnComplete = callback;
return this;
};
return AjaxPreloader;
})();
var preloader = new AjaxPreloader($("#preloader"));
//Если один раз нужно вызвать что-то конкретное
preloader.show(function(){
div.load("/", preloader.hide);
});
//Если нужно вызывать коллбэк каждый раз
preloader.registerOnComplete(function(){
div.load("/", preloader.hide);
}).show();
Если же вы решили перейти на сторону 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.trigger("someEvent"); //эмитирует эвент на элементе root
root.on("someEvent", function(){
//обработчик
});
Чтобы не регистрировать коллбэки внутрь каких-то модулей приложения, а особенно не уделять внимание контексту выполнения, но притом сохранить логическое разделение частей приложения, многие просто эмитируют кастомное событие на каком-то элементе DOM'a, чтобы потом ловить его в каком-то другом месте приложения и выполнять нужные действия. Таким образом модули зависят только от одного элемента, а мы, вместо регистрации коллбэков, просто передаем в модуль лишний параметр - элемент документа, который мы собираемся прослушивать.
Строго говоря, это довольно спорная практика, но сама идея хорошая. Когда модуль публикует события, а приложение прослушивает их, это довольно удобно во многих отношениях. Однако требует несколько больших стараний, так как нужно организовать методы эмитирования и прослушивания событий.В самом простом случае - это выглядит примерно так:
//класс общей функциональности прослушки и эмитирования событий
var EventEmitter = (function(){
var Callbacks = {};
function EventEmitter(){}
EventEmitter.prototype.on = function(event, fn){
Callbacks[event] = fn;
return this;
};
EventEmitter.prototype.emit = function(event){
return Callbacks[event.name] && Callbacks[event.name].apply(this, [event]);
}
return EventEmitter
})();
var Module = (function(){
function Module(name){
this.name;
}
Module.prototype = new EventEmitter(); //наследуем свойства от event-emittera
Module.prototype.async = function(time, event) {
var self = this;
setTimeout(function(){
self.emit({
name : "asyncReady",
timeout : time,
moduleName : self.name
});
}, time);
};
return Module
})();
//теперь мы можем пользовать этим вот так:
var mod = new Module("Module name");
mod.on('asyncReady', function(e){
//e - это объект события event
//this - это модуль
});
В этом случае модуль эмитирует события и сам же является приемником. Альтернативная архитектура, один приемник на все модули, в целом выглядит более централизованно, но это лишь еще одна прослойка между событиями модуля и приложения. Чаще всего в этом нет необходимости, кроме того, не стоит думать, что такой ретранслятор по сути есть фасад, если он занят лишь коллекционированием событий и регистрацией обработчиков, и это ничем не обусловленно (например, многоступенчатой вложенностью архитектуры приложения), то это лишь ненужная прослойка. Event-driven приложения
Если вы разрабатываете приложение на основе событий, важно понимать, что они могут происходить в разное время и, соответственно, в разном порядке, комбинировать их без каких-либо специальных приемов очень сложно, как вы могли заметить в первом разделе этой статьи. При всем при этом есть особого рода приложения, которые можно описать, как "сильно-событийные", которые генерируют много событий, результат которых возможно нужно обрабатывать асинхронно, притом чаще всего вы предположительно знаете, сколько времени займет обработка. Например, игры, где весь поток - это события управления, события реакции, и асинхронная (т.к. подразумевает анимацию) обработка. Как ни странно, методология функционального программирования, примененная к такого рода приложениям, значительно облегчает жизнь. Есть несколько библиотек, которые предоставляют возможность описывать сложные зависимости между разными событиями в декларативном стиле традиционного функционального программирования (см. Bacon.JS, Reactive Extensions - RxJS). Я не буду разбирать эти библиотеки в этой статье, только скажу что сам использую самописную библиотеку, чем-то схожую с Bacon.js, освобожденную от зависимости от DOM. В начале статьи я говорил, что есть возможности избегать многочисленных проверок. И в данный момент могу предоставить фрагмент рабочего кода://обработчики
function direction (event) { return {x : event.direction.x, y: event.direction.y };
function valid (direction) { /* такой ход разрешен */ };
function loose (direction) { /*такой ход приводит к проигрышу*/}
function redraw(direction) { /* перерисовка */};
//потоки
var player.moves = player.stream("move"); //"move" - это событие
var enemy.moves = enemy.stream("move");
//декларации
var allowedPlayerMoves = player.moves.map(direction).filter(valid);
var allowedEnemyMoves = enemy.moves.map(direction);
var looseMoves = allowedPlayerMoves.conjuncteWith(allowedEnemyMoves, loose);
//логика
allowedPlayerMoves.exec(redraw);
allowedEnemyMoves.exec(redraw);
looseMoves.exec(looseGame);
Я не буду углубляться в суть этого кода, это лишь пример того, как легко можно описывать достаточно непростые зависимости между объектами на основе потоков их событий. Посему, вопрос читателю: интересна бы вам была статья на эту тему, т.к. я думаю довести библиотеку до ума и опубликовать исходники.Спасибо за внимание, да пребудет с вами сила!
Комментариев нет:
Отправить комментарий