📜 ⬆️ ⬇️

How to invent a bike and get to know FRP

Recently, I had the chance to work on a web application for interacting with an interactive whiteboard (!) For mobile devices (!!) on any technology stack, both server and client (!!!). At the prototype stage, the task was a simple graphic editor. The customer expressed a desire to be able to draw broken lines in some way, circles, segments, arbitrary curves and add text. Everything seems to be simple, however, taught by the bitter experience of GoF, Fowler and other apologists of all sorts of patterns, I immediately realized that the customer was cunning and that within a week or a month after the prototype he would need to draw ellipses, rectangles and heaps of other nishtyakov. And all this will definitely have to be done in different ways. At least for desktop and mobile.

Actually, you can do everything in the forehead (for a prototype), but the weekend fell, a pause in the tasks of the current project, and I decided to do everything in an amicable way. And on the first evening - callback hell .

And then…
Because at work there is nothing else to do.
')


The picture above was made, of course, on the basis of that very editor.

About the sense of beauty


Immediately in my head I had an idea to describe drawing tools in such a way that the code most closely resembled a potential technical task. Let's say

TK:
As a user, I want to be able to draw segments
1. Pressing the left mouse button marks the beginning of the segment.
2. Mouse movement after clicking with the left mouse button draws an intermediate result.
3. After the button is released, the end of the segment is marked.
4. Data is sent to the server.


Spherical code in vacuum:
myDrawingBoard .once(“mousedown”, setStartingPoint) .any(“mousemove”, drawLine) .once(“mouseup”, setEndingPoint) .atLast(saveFigure) 


At least that was the code in my head. Something similar I saw on jQuery Russia this spring, where the implementation was strung on Rx.js. Alas, I didn’t have the opportunity to watch a video or chat with the speaker, and therefore I had to reinvent the wheel myself.

Having chatted with colleagues, I came to the conclusion that the task itself is a finite state machine. And my code requires a little witchcraft over this very automaton, since events need to be tracked over some regularly existing nodes, but not all these events should be intercepted, but only those that are needed in the current state of the automaton.
Actually, by briefly meditating on a notebook, I built just such a scheme and called it “Flat Event Chain” - a flat chain of events.

Flat event chain

Each state is a so-called MetaEvent - a small chain of events consisting of a set of recurring events (such as “any”) and a closing single event (such as once). If there are no recurring events in MetaEvent, then the closing one must be present, otherwise we can never tell when to get out of this state.

Meta event

In this model, collisions are possible when repeated events are of a common type. To do this, each element of the chain is assigned a unique name and at the level of the meta-event a check is made on which handler to use. As soon as we decide which of the elements will be responsible for handling this event, all previous handlers are destroyed. When a closing single event is intercepted, the MetaEvent is considered executed and the machine is transferred to the next state.

About implementation


Each element of the chain is such a module:

 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 assumes the possibility of its initialization (activation of the subscription to a client event) through the init method, and the release of resources through dispose. As you can see, events are provided with a notation in both the “eventType” style and the {“eventType” style: [keyCode]} - the latter option will intercept only those events in which the required keyCode was transmitted (if you need only one, do not write an array).

Thus the chain is described:

 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 assumes the possibility of adding repetitive events via push and adding a closing event through close, as well as the same init and dispose as in BaseEvent. Here you can pay attention to the fact that if the callback returns false, the machine will not change its state. This is not very beautiful, but evt.preventDefault would be equally bad. At least, return false in this context will not affect the default event handler and its bubbling.

Actually, it remains only to screw it all around the State Machine. As this, I used the open source solution from here .

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


The chain from MetaEvents is initially sharpened on a specific DOM element, which is passed through a tiny extension for jQuery:

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


As for the drawing tools, I immediately put on all sorts of patterns, but this is because in the prototype I needed a bunch of these tools. Without extra code, this is how a direct line drawing tool looks.

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


Actually, as it is easy to guess, LineDrawer can initialize the drawing process, for example, as a reaction to the desired client event (click on the line icon in the toolbar). I have written for this a small chain of responsibility, thus creating a new drawing tool costs ten lines.

After the prototype was ready, I suddenly faced a terrible assumption - what if the customer wants to repeat not a separate event, but whole patterns, subchains of events. Let's say a fairly trivial task:

Fantastic TK "Polygon":
As a user, I want to be able to draw broken lines.
1. Pressing the left mouse button marks the beginning of the segment.
2. The movement of the mouse shows an intermediate result.
3. Pressing the space bar marks the top of the polyline.
4. Repeat steps 2 and 3 until the user releases the mouse button, then save the last intermediate result.


In the concept implemented above, such a task is no longer feasible - at least, only a certain number of chain links can be described in it, and not an arbitrary one.
The inner sense of beauty demanded this style:

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


In this style, the wing is already a ready-made solution, which required adding a slightly more complex CycleEvent to the usual BaseEvent.

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


The external contract completely coincides with BaseEvent, and therefore it is enough just to slightly patch the any method in the EventQueue so that it can work with such data.

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


About the result and where does the FRP


Here, of course, is a moot point, is there any FRP in all this? If we present a set of data about a graphic primitive as a set, then, in essence, the code that we write after eventChain () is a description of operations on this set and their composition. The ability to add recurring events and event patterns adds flexibility to all of this, but in general for some kind of FRP, once-events would suffice.
The value of this code is an even more controversial issue. However, in the context of the task, he definitely copes with his duties ideally. Obviously, there is room to expand it: for example, if you add support for promises, you can beautifully describe complex animations, and if you add the concept of equivalent events (it is still implemented in half, allowing you to track keystrokes equally well), you can create simple games.

References:

Cloud9 code
Cloud9 demo
State Machine
Raphaël.js

Source: https://habr.com/ru/post/245269/


All Articles