Как изобрести велосипед и познакомиться с FRP

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

    Собственно, можно все сделать в лоб (для прототипа-то), но выпали выходные, пауза в задачах текущего проекта, и я решил сделать все по-хорошему. И в первый же вечер — callback hell.

    А потом…
    Потому что на работе больше заниматься нечем



    Картинка выше сделана, разумеется, на базе того самого редактора.

    О чувстве прекрасного


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

    ТЗ:
    Как пользователь я хочу уметь рисовать отрезки
    1. Нажатие левой кнопки мыши отмечает начало отрезка
    2. Движение мыши после нажатия с удержанием левой кнопки мыши рисует промежуточный результат
    3. После того как кнопку отпустили, отмечается конец отрезка
    4. Данные посылаются на сервер


    Сферический код в вакууме:
    myDrawingBoard
        .once(“mousedown”, setStartingPoint)
        .any(“mousemove”, drawLine)
        .once(“mouseup”, setEndingPoint)
        .atLast(saveFigure)
    


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

    Поболтав с коллегами, я пришел к выводу, что сама задача – это конечный автомат. А мой код требует небольшого колдовства над этим самым автоматом, поскольку события надо отслеживать над какими-то регулярно существующими нодами, но перехватывать надо далеко не все эти события, а только те, которые нужны в текущем состоянии автомата.
    Собственно, путем кратковременной медитации над блокнотом, я построил вот такую схему и обозвал ее “Flat Event Chain” – плоская цепочка событий.

    Flat Event Chain

    Каждое состояние представляет собой так называемый MetaEvent – малую цепочку событий, состоящую из набора повторяющихся событий (типа «any») и закрывающего одиночного события (типа «once»). Если повторяющихся событий в MetaEvent может не быть, то закрывающее присутствовать обязано, иначе мы никогда не сможем сказать, когда выйти из этого состояния.

    Meta Event

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

    О реализации


    Каждый элемент цепочки представляет собой вот такой модуль:

    var BaseEvent = function (type, element, callback, context) {
    	this.element = element;
    	this.callback = callback;
    	this.context = context;
    
    	this.id = GuidFactory.create();
    	this.name = "me_" + this.id;
    
    	if (type instanceof Object) {
    		for (var key in type) {
    			this._codes = type[key] instanceof Array
    				? type[key]
    				: [type[key]];
    			type = key;
    			this.element = $(document);
    			break;
    		}
    	}
    
    	this.type = type;
    	this._uniqueType = type + "." + this.id;
    
    	this._handlers = [];
    };
    BaseEvent.prototype = {
    	on: function (callback, context) {
    		this._handlers.push({callback: callback, context: context || this});
    		return this;
    	},
    	trigger: function () {
    		for (var i = 0; i < this._handlers.length; ++i) {
    			var obj = this._handlers[i];
    			obj.callback.apply(obj.context, arguments);
    		}
    	},
    	init: function () {
    		var _this = this;
    		this.element.on(this._uniqueType, function (evt) {
    			if (!_this._codes || _this._codes.indexOf(evt.keyCode) >= 0) {
    				_this.trigger(evt);
    			}
    		})
    	},
    	dispose: function () {
    		this.element.off(this._uniqueType);
    	}
    };
    


    BaseEvent предполагает возможность своей инициализации (активации подписки на клиентское событие) через метод init, и освобождения ресурсов через dispose. Как можно увидеть, для событий предусмотрена нотация как в стиле «eventType», так и в стиле {«eventType»: [keyCode]} — последний вариант будет перехватывать только те события, в которых был передан нужный keyCode (если нужен только один, можно не писать массив).

    Таким образом описывается цепочка:

    var MetaEvent = function () {
    	this._events = [];
    	this._closingEvent = null;
    	this._currentEvent = null;
    
    	this.closed = false;
    	this.id = GuidFactory.create();
    	this.name = "me_" + this.id;
    };
    MetaEvent.prototype = {
    	push: function (evt) {
    		if (this.closed)
    			throw new Error("Cannot push event to closed MetaEvent");
    
    		this._events.push(evt);
    	},
    	close: function (evt) {
    		if (this.closed)
    			throw new Error("Cannot close already closed MetaEvent");
    
    		this._closingEvent = evt;
    		this.closed = true;
    	},
    
    	init: function (stateMachine) {
    		this._createEventIndex();
    		this._stateMachine = stateMachine;
    		for (var id in this._eventIndex) {
    			this._initEvent(this._eventIndex[id]);
    		}
    	},
    	dispose: function () {
    		for (var id in this._eventIndex) {
    			this._eventIndex[id].dispose();
    		}
    	},
    
    	_initEvent: function (evt) {
    		var _this = this;
    
    		evt.init();
    		evt.on(function (evt) {
    			if (this.id === _this._closingEvent.id &&
    				this.callback.apply(this.context || this.element, [evt]) !== false) {
    				_this._stateMachine[_this.name]();
    			} else if (this.id === _this._currentEvent.id) {
    				this.callback.apply(this.context || this.element, [evt]);
    			} else if (this.type !== _this._currentEvent.type &&
    				this.callback.apply(this.context || this.element, [evt]) !== false) {
    				_this._disposePreviousEvents(this.id);
    				_this._currentEvent = _this._eventIndex[this.id];
    			}
    		});
    	},
    	_createEventIndex: function () {
    		this._eventIndex = {};
    		for (var i = 0; i < this._events.length; ++i) {
    			var evt = this._events[i];
    			this._eventIndex[evt.id] = evt;
    		}
    		this._eventIndex[this._closingEvent.id] = this._closingEvent;
    
    		this._currentEvent = this._events[0] || this._closingEvent;
    	},
    	_disposePreviousEvents: function (eventId) {
    		for (var i = 0; i < this._events.length; ++i) {
    			var evt = this._events[i];
    			if (evt.id !== eventId) {
    				evt.dispose();
    			} else {
    				break;
    			}
    		}
    	}
    };
    


    MetaEvent предполагает возможность добавления повторяющихся событий через push и добавление закрывающего события через close, а также те же самые init и dispose, что и в BaseEvent. Здесь можно обратить внимание на то, что если callback возвращает false, то машина не поменяет своего состояния. Это не очень красиво, но равно нехорошо было бы пользоваться и evt.preventDefault. По крайней мере, return false в данном контексте никак не повлияет на default обработчик события и его bubbling.

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

    var EventChain = function (element) {
    	this._element = $(element);
    	this._metaEvents = [];
    	this._atLast = null;
    };
    EventChain.prototype = {
    	_lastEvent: function () {
    		return this._metaEvents.length > 0
    			? this._metaEvents[this._metaEvents.length - 1]
    			: null;
    	},
    	_createEventIndex: function () {
    		this._eventIndex = {};
    		for (var i = 0; i < this._metaEvents.length; ++i) {
    			var evt = this._metaEvents[i];
    			this._eventIndex[evt.id] = evt;
    		}
    	},
    	_createEvents: function () {
    		return this._metaEvents.map(function (evt, index, metaEvents) {
    			return {
    				name: evt.name,
    				from: evt.id,
    				to: index + 1 < metaEvents.length
    					? metaEvents[index + 1].id
    					: "atLast"
    			}
    		});
    	},
    	_createCallbacks: function () {
    		var result = {},
    			_this = this;
    		for (var i in this._eventIndex) {
    			result["onenter" + this._eventIndex[i].id] = function (evt, from, to, data) {
    				_this._eventIndex[to].init(this);
    			}
    			result["onleave" + this._eventIndex[i].id] = function (evt, from, to, data) {
    				if (_this._eventIndex[from]) {
    					_this._eventIndex[from].dispose();
    				}
    			}
    		}
    		result["onatLast"] = function (evt, from, to) {
    			if (_this._eventIndex[from]) {
    				_this._eventIndex[from].dispose();
    			}
    			if (_this._atLastCallback) {
    				_this._atLastCallback.apply(
    					_this._atLastContext || _this._element,
    					arguments);
    			}
    		};
    		return result;
    	},
    	
    	// add event that will be handled only once
    	once: function (type, element, callback, context) {
    		if (element instanceof Function) {
    			context = callback;
    			callback = element;
    			element = this._element;
    		}
    
    		var lastEvent = this._lastEvent();
    		if (lastEvent && !lastEvent.closed) {
    			lastEvent.close(new BaseEvent(type, element, callback, context));
    		} else {
    			var evt = new MetaEvent();
    			evt.close(new BaseEvent(type, element, callback, context));
    			this._metaEvents.push(evt);
    		}
    
    		return this;
    	},
    
    	// add event that will be handled twice
    	twice: function (type, element, callback, context) {
    		return this
    			.once(type, element, callback, context)
    			.once(type, element, callback, context);
    	},
    
    	// add event that will be repeated any times
    	any: function (type, element, callback, context) {
    		if (element instanceof Function) {
    			context = callback;
    			callback = element;
    			element = this._element;
    		}
    
    		var lastEvent = this._lastEvent();
    		if (lastEvent && !lastEvent.closed) {
    			lastEvent.push(new BaseEvent(type, element, callback, context));
    		} else {
    			var evt = new MetaEvent();
    			evt.push(new BaseEvent(type, element, callback, context));
    			this._metaEvents.push(evt);
    		}
    
    		return this;
    	},
    
    	// add event that will be repeated at least once
    	onceAndMore: function (type, element, callback, context) {
    		return this
    			.once(type, element, callback, context)
    			.any(type, element, callback, context);
    	},
    
    	// set function that will be called after queue is done
    	atLast: function (callback, context) {
    		this._atLastCallback = callback;
    		this._atLastContext = context;
    		return this;
    	},
    
    	// set event that will cancel queue immediately
    	cancel: function (type, element, callback, context) {
    		var _this = this;
    		if (element instanceof Function) {
    			context = callback;
    			callback = element;
    			element = this._element;
    		}
    
    		new BaseEvent(type, element, callback, context)
    			.on("caught", function (evt) {
    				if (this.callback.apply(this.context || this.element, [evt]) !== false) {
    					_this.dispose();
    				}
    			})
    			.init();
    
    		return this;
    	},
    
    	// initialize state machine
    	init: function () {
    		this._createEventIndex();
    		var callbacks = this._createCallbacks(),
    			events = this._createEvents(),
    			stateMachine = StateMachine.create({
    				initial: this._metaEvents[0].id,
    				final: "atLast",
    				events: events,
    				callbacks: callbacks
    			});
    
    		return this;
    	},
    	dispose: function () {
    		for (var i = 0; i < this._metaEvents.length; ++i) {
    			this._metaEvents[i].dispose();
    		}
    	}
    };
    


    Сама цепочка из MetaEvents изначально заточена на конкретный DOM-элемент, который передается через крошечное расширение для jQuery:

    jQuery.fn.eventChain = function () {
    	return new EventChain(this);
    };
    


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

    var LineDrawer = new (ConcreteDrawer.extend({
    	__type: "line",
    	__draw: function (data) {
    		return new SmartPath(data).draw();
    	},
    
    	__startDrawing: function (data) {
    		return Board.EventLayer.eventChain()
    			.once("mousedown", this._placeStartPoint, this)
    			.any("mousemove", this.__drawTemporaryFigure, this)
    			.once("mouseup", this._placeEndPoint, this)
    			.cancel({"keydown": 27}, this.cancelDrawing, this)
    			.atLast(this.__saveFigure, this)
    			.init();
    	},
    
    	_placeStartPoint: function (evt) {
    		this.__figureData.x1 = Board.EventLayer.pageX(evt);
    		this.__figureData.y1 = Board.EventLayer.pageY(evt);
    	},
    	__drawTemporaryFigure: function (evt) {
    		this._placeEndPoint(evt);
    		this.base();
    	},
    	_placeEndPoint: function (evt) {
    		this.__figureData.x2 = Board.EventLayer.pageX(evt);
    		this.__figureData.y2 = Board.EventLayer.pageY(evt);
    	}
    }))();
    


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

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

    Фантастическое ТЗ «Полигон»:
    Как пользователь я хочу уметь рисовать ломаные линии.
    1. Нажатие левой кнопки мыши отмечает начало отрезка.
    2. Движение мыши показывает промежуточный результат.
    3. Нажатие пробела отмечает вершину ломаной.
    4. Повторять пункты 2 и 3 до тех пор, пока пользователь не отпустит кнопку мыши, после чего сохранить последний промежуточный результат.


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

    return Board.EventLayer.eventChain()
    	.once("mousedown", this._placeStartPoint, this)
    	.any(function (chain) {
    		return chain
    			.any("mousemove", this.__drawTemporaryFigure, this)
    			.once({"keydown": 32}, this._placePolygonePoint, this);
    	}, this)
    	.once("mouseup", this._placePolygonePoint, this)
    	.cancel({"keydown": 27}, this.cancelDrawing, this)
    	.atLast(this.__saveFigure, this)
    	.init();
    


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

    var CycleEvent = Base.extend({
    	constructor: function (cycle, element, context) {
    		this._cycle = cycle;
    		this._element = element;
    		this._context = context;
    		this.callback = function () {};
    
    		this.id = GuidFactory.create();
    		this.name = "me_" + this.id;
    		this.type = "cycle_" + this.id;
    	},
    	init: function () {
    		this._cycleChain = this._cycle
    			.apply(this._context || this, [this._element.eventChain()])
    			.atLast(this._restartCycle, this);
    		this._cycleChain.init();
    		return this._cycleChain;
    	},
    	dispose: function () {
    		this._cycleChain.dispose();
    	},
    
    	_restartCycle: function () {
    		this.dispose();
    		this.init();
    		this.trigger("caught");
    	}
    });
    


    Внешний контракт полностью совпадает с BaseEvent, а посему достаточно только немножко пропатчить метод any в EventQueue, чтобы он мог работать и с такими данными.

    any: function (type, element, callback, context) {
    	if (type instanceof Function) {
    		return this._cycle(type, element)
    	} else if (element instanceof Function) {
    		context = callback;
    		callback = element;
    		element = this._element;
    	}
    
    	var lastEvent = this._lastEvent();
    	if (lastEvent && !lastEvent.closed) {
    		lastEvent.push(new BaseEvent(type, element, callback, context));
    	} else {
    		var evt = new MetaEvent();
    		evt.push(new BaseEvent(type, element, callback, context));
    		this._metaEvents.push(evt);
    	}
    	return this;
    },
    // add cycle of events with same syntax
    _cycle: function (cycle, context) {
    	var lastEvent = this._lastEvent();
    	if (lastEvent && !lastEvent.closed) {
    		lastEvent.push(new CycleEvent(cycle, this._element, context));
    	} else {
    		var evt = new MetaEvent();
    		evt.push(new CycleEvent(cycle, this._element, context));
    		this._metaEvents.push(evt);
    	}
    	return this;
    }


    О результате и причем тут вообще FRP


    Тут, конечно, вопрос спорный, есть ли во всем этом FRP. Если представить набор данных о графическом примитиве как множество, то, по сути, код, который мы пишем после eventChain() представляет собой описание операций над этим множеством и их композиции. Возможность добавления повторяющихся событий и паттернов событий добавляет всему этому гибкости, но вообще для какого-никакого FRP было бы достаточно и once-событий.
    Ценность данного кода — еще более спорный вопрос. Однако же в контексте задачи он определенно со своими обязанностями справляется идеально. Очевидно, что его есть куда расширять: например, если добавить поддержку promises, можно красиво описывать сложные анимации, а если добавить концепцию равнозначных событий (она пока реализована наполовину, позволяя равноценно отслеживать нажатия разных клавиш), можно создавать несложные игры.

    Ссылки:

    Код на Cloud9
    Демо на Cloud9
    State Machine
    Raphaёl.js
    • +14
    • 17,7k
    • 5
    Поделиться публикацией

    Похожие публикации

    Комментарии 5

      0
      Мне кажется задача отлично ложится на baconjs.github.io/
        0
        Вообще отлично ложится на любую подобную библиотеку. Я, собственно, даже упомянул вскользь Rx.js от мелкомягких. Просто чего отбойным молотком гвозди забивать, когда моя задача в определенных рамках предсказуема, и вполне можно обойтись самым что ни на есть обычным молотком.
        0
        Отличный изначальный подход и решение. Очень понравилось описание инструментов рисования в «человеческом» виде. Имея богатый набор примитивов можно посадить не-программиста клепать инструменты.
          0
          Спасибо большое! Что касается сажания не-программиста, наверное так запросто все-таки не получится: JS дружелюбен, но очень разборчив в друзьях. Впрочем, иными утрами каждый из нас в некотором роде «не-программист».
          0
          Несмотря, что статья интересная — респект за картинку ;)

          Как когда-то говорили: «это самый ранний Рубенс»

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

          Самое читаемое