Ways to "communicate" components
The client part of a modern web application is difficult to imagine without modularity, and it involves the exchange of data between modules or just communication. The way this communication is organized depends on the complexity of the project and the technologies it uses.
The first thing that comes to mind is publishing and subscribing to named events. One of the components sends the event to "broadcast", and the rest listen to this "broadcast" and catch those messages that they need. The idea is extremely simple and well-proven.
')
This is somewhat reminiscent of WI-FI in a cafe, when everyone can exchange messages with everyone, but there is also a router (dispatcher), which ensures the existence of a “broadcast” and only gives messages to those to whom they are addressed.
Such an organization allows, for example, “for free” to get a weak binding of components. Its disadvantage is that with an increase in the number of components and, accordingly, the number of events, it becomes difficult to keep track of the names of events and who needs what events are necessary for proper operation. The namespaces and event names appear from something like “Event1” and turn into “Application_stat1. Component2. Event1”. And what is absolutely impossible to do with such an organization is to arrange events. For example, the requirement “do something when event B occurs after two events A” results in a ton of local variables storing the latest data from the events and the counters of the events themselves.
Promises are somewhat easier, they allow you to organize the sequence of events and are practically the first step in organizing data flows.
Another way to organize communication between components is to stretch “wires” between them, so that only those components that have something to “say” to each other are connected. To present this method of binding visually, just look at any printed circuit board.
Here, each component is connected to other, to it necessary components, tracks (“wires”) or simply “streams” along which the signal is transmitted. Now, to imagine what kind of data a component needs for work, you do not need to go into the “black box” and look for event subscriptions. Just look at which components the streams come from and how the data is transformed along the way. It is not necessary to name events at such an organization at all, rather a stream that carries data needs a name. In this case, the task of arranging events is reduced to the composition of the streams containing them.
Thread implementation
To now move from words to action, you need to choose the implementation of our "wires." For me, this implementation was
RxJS , which is a modular library that allows you to create and compose data streams. The approach used in Rx appeared in .NET and was ported to many popular languages ​​from there. Depending on the complexity of the implemented logic, the project can be connected as all RxJS, and its separate
modules . Consider the key concepts.
The streams created in Rx implement the Observable pattern and are inherited from the interface of the same name, which means that each stream can be “listened”. This is implemented using the subscribe method, which takes an Observer as an argument.
observableStream.subscribe(someObserver)
In the simplest case, Observer is a function that takes a single argument — the transmitted message from the stream. A message can be either a simple value or a complex object.
function someObserver(streamEvent){ console.log('Received ' + streamEvent) }
Threads, for example, can be used to handle DOM events. The most convenient way is to organize a subscription to DOM events using the
RxJS-jQuery plugin.
For example, a stream that will respond to button presses and send a DOM event as data can be created like this:
var myAwesomeButtonClick = $('#my-awesome-button').onAsObservable('click')
Now the stream myAwesomeButtonClick can be passed to another component if it needs to handle button presses. To organize an arbitrary stream, Rx.Subject is used, which also implements the Observable pattern, but in addition to the Observable capabilities, it allows to throw arbitrary messages into the stream, for this the onNext method is used.
subjectStream.onNext('new message')
Now a component that wants to send messages to others must return the created stream, and a component that wants to receive messages must receive the flow and subscribe to messages from it.
Now, as soon as an event occurs in Component 1, it calls onNext and Component 2 immediately receives this event and processes it.
Complicated, each component will begin to return and take several threads and turn into a kind of chip, the legs of which are threads.
But all this could be organized with the help of promises. What is the advantage of using RxJs streams?
Everything that happens in the application can be represented as a data stream:
- keystrokes
- mouse movements
- data from the server
- complex logical something that happened in one of the components
And since all this can be presented in a uniform manner, it means that it is possible to work in a uniform manner, there is no longer any difference where events come from and what these events are, for a component it is just data streams.
Event handling in stream, conditions and side effects
Often, one component needs not exactly the data that is initially in the incoming stream and needs to be processed or reformatted in some way. The easiest way to describe this is by example.
var inputChanges = $('input.name').onAsObservable('change, keyup, paste')
An inputChanges stream will be created that reacts to some events in the input field and transmits the DOM event as data, but as a rule, the internal logic of the component needs not DOM events, but specific values, preferably satisfying some rules. Create a new thread that implements event handling for the inputChanges stream.
var inputValueChanges = inputChanges .map(function(event){ return $(event.target).val() }) .distinctUntilChanged() .where(isValidName) .do(someAction)
The new inputValueChanges stream returns values ​​in the input field, and a new event in it occurs only if the value in the field has really changed and satisfies a certain format. We will examine in more detail:
.map(function(event){ return $(event.target).val() })
The
map method (also called select) takes a handler function, which takes an event as an argument and returns some value that will later be used as a stream event.
.distinctUntilChanged()
Keeps track of the values ​​passing through it and if the value in the event does not differ from the value in the previous event, do not pass it further.
.where(isValidName)
The
where method (also known as filter), as well as
distinctUntilChanged, allows you to skip some events further, but accepts a function as a condition that checks whether these events satisfy certain requirements.
.do(someAction)
do (or doAction) implements side effects, while not affecting the event itself in the stream. The function someAction, as in the case of map, takes a single argument, the event.
Threading
It often happens that one component waits for data from two other components in order to perform some kind of action. For example, to show the price for a trip, the “summary” component needs to get data from the “calendar” and “route” components and then update it as soon as something changes in one of the components.
In order to get data from two streams, you can combine them into one stream, which will contain data from both. In the above example, you can use the
combineLatest method
var routeAndDateChangesStream = routeChangesStream .combineLatest(dateChangesStream, function(route, date){ return { date: date, route: route } })
The combineLatest method combines two (or more) streams into one. An event in the resulting stream occurs when an event occurs in one of the merged streams, provided that there is at least one event in each of the merged streams. The first argument combineLatest takes the stream with which to merge, the second function that determines how to merge data from events. When an event occurs in any stream, recent events are taken from the remaining streams to merge.
In addition to combining recent events, it may be necessary to combine events in pairs in the order of their occurrence. For example, some action generates two Ajax requests and needs to be reacted when both requests return data, and the data need to be combined only from the corresponding requests. In such a situation, it is convenient to use the
zip method, which combines not the latest events from the streams, but events with matching sequence numbers.
var routeAndDateChangesStream = response1Stream .zip(response2Stream, function(data1, data2){ return _.extend(data1, data2) })
And finally, there is a situation where you don’t need to combine data from streams, but simply a component receives the same information from different streams, for example, there are two calendars on the page, one detailed per year, the second for the next two weeks, both calendars transmit the date to the “summary” component . Here you just need to combine the two streams into one.
var dataChangesStream = bigCalendarDateChange.merge(miniCalendarDateChanges)
Here, the events from both calendars fall into the result stream, and the component receiving them can process them as if the selection always occurred in only one component.
Testing
The organization of components according to the “Chip” type gives another advantage - testability.
If a component receives all incoming data through a set of streams and also sends data through a set of streams, this greatly simplifies the testing of such a component. It is enough to create a set of empty incoming streams and give them to the component as incoming. Then, sending certain combinations of events to the streams, it is easy to see whether the component behaves correctly and whether it sends the correct data to the outgoing streams.