Hello, my name is Dmitry Karlovsky and I ... client-side developer. I have 8 years of support for various websites and web applications: from unknown online stores to such giants as Yandex. And all this time I am not only figging in production, but also sharpening the ax to be at the cutting edge of technology. And now, when you know that I’m not just fucking off the mountain, let me tell you about one architectural trick I’ve been using for the last year.
This article introduces the reader to the abstraction "atom", designed to automate the tracking of dependencies between variables and effectively update their values. Atoms can be implemented in any language, but the examples in the article will be in javascript.
Caution: reading can cause a dislocation of the brain, an attack of holivar, as well as sleepless nights of refactoring.
From simple to complex
This chapter briefly demonstrates the typical evolution of a fairly simple application, gradually leading the reader to the concept of the atom.
')
Let's imagine for a start such a simple task: you need to write a greeting message to the user. Implementing this is not very difficult:
this.$foo = {} $foo.message = ', !' $foo.sayHello = function(){ document.body.innerHTML = $foo.message } $foo.start = function(){ $foo.sayHello() }
But it would not be bad to contact the user by name. Suppose we have a username stored in localStorage, then the implementation will be a little more complicated:
this.$foo = {} $foo.userName = localStorage.userName $foo.message = ', ' + $foo.userName + '!' $foo.sayHello = function(){ document.body.innerHTML = $foo.message } $foo.start = function(){ $foo.sayHello() }
But wait, the calculation of userName and message occurs during initialization, but what if at the time of the call to sayHello its name has already changed? It turns out we will greet him by the old name, which is not very good. So let's rewrite the code so that the message is calculated only when it really needs to be shown:
this.$foo = {} $foo.userName = function( ){ return localStorage.userName } $foo.message = function( ){ return ', ' + $foo.userName() + '!' } $foo.sayHello = function(){ document.body.innerHTML = $foo.message() } $foo.start = function(){ $foo.sayHello() }
Notice that we had to change the message and userName fields interface - now they are stored not in the values ​​themselves, but in the functions that return them.
Thesis 1: In order not to condemn yourself and other developers to tedious refactorings when changing the interface, try to immediately use an interface that allows you to change the internal implementation most freely.
We could hide the function call using
Object.defineProperty :
this.$foo = {} Object.defineProperty( $foo, "userName", { get: function( ){ return localStorage.userName } }) Object.defineProperty( $foo, "message", { get: function( ){ return ', ' + $foo.userName + '!' } }) $foo.sayHello = function(){ document.body.innerHTML = $foo.message } $foo.start = function(){ $foo.sayHello() }
But I would recommend an explicit function call for the following reasons:
* IE8 supports Object.defineProperty only for dom nodes.
* Functions can be built into chains of the form $ foo.title ('Hello!') .UserName ('Anonymous').
* The function can be passed as a callback to somewhere: $ foo.userName.bind ($ foo) - the property will be transferred in its entirety (both the getter and the setter).
* The function can store various additional information in its fields: from global identifier to validation parameters.
* If you turn to a non-existent property, an exception will be raised instead of silently returning undefined.
But what if the username changes after we show the message? On good, you need to track this moment and redraw the message:
this.$foo = {} $foo.userName = function( ){ return localStorage.userName } $foo.message = function( ){ return ', ' + $foo.userName() + '!' } $foo._sayHello_listening = false $foo.sayHello = function(){ if( !$foo._sayHello_listening ){ window.addEventListener( 'storage', function( event ){ if( event.key === 'userName' ) $foo.sayHello() }, false ) this._sayHello_listening = true } document.body.innerHTML = $foo.message() } $foo.start = function(){ $foo.sayHello() }
And here we have committed a terrible sin - the implementation of the sayHello method, suddenly, knows about the internal implementation of the userName property (knows where it gets its value from). It is worth noting that in the examples they are nearby only for clarity. In a real application, such methods will be in different objects, the code will be in different files, and it will be supported by different people. Therefore, this code should be rewritten so that one property can subscribe to changes of another through its public interface. In order not to overcomplicate the code, let's use the
jQuery pub / sub implementation:
this.$foo = {} $foo.bus = $({}) $foo._userName_listening = false $foo.userName = function( ){ if( !this._userName_listening ){ window.addEventListener( 'storage', function( event ){ if( event.key !== 'userName' ) return $foo.bus.trigger( 'changed:$foo.userName' ) }, false ) this._userName_listening = true } return localStorage.userName } $foo._message_listening = false $foo.message = function( ){ if( !this._message_listening ){ $foo.bus.on( 'changed:$foo.userName', function( ){ $foo.bus.trigger( 'changed:$foo.message' ) } ) this._message_listening = true } return ', ' + $foo.userName() + '!' } $foo._sayHello_listening = false $foo.sayHello = function(){ if( !this._sayHello_listening ){ $foo.bus.on( 'changed:$foo.message', function( ){ $foo.sayHello() } ) this._message_listening = true } document.body.innerHTML = $foo.message() } $foo.start = function(){ $foo.sayHello() }
In this case, the communication between the properties is implemented via a single $ foo.bus bus, but it can be a scattering of individual EventEmitters. Basically, the same scheme will be: if one property depends on another, then it must somewhere subscribe to its changes, and if it changes itself, then you need to send a notification about your change. In addition, this code does not provide for a formal reply at all, when monitoring the value of a property is no longer required. Let's enter the showName property, depending on the state of which we will show or not show the username in the welcome message. The peculiarity of such a rather typical formulation of the problem is that if showName = 'false', then the message text does not depend on the value of userName and therefore we should not subscribe to this property. Moreover, if we have already subscribed to it, because previously there was showName = 'true', then we need to unsubscribe from userName, after receiving showName = 'false'. And so that life doesn’t seem to be paradise at all, let’s add another requirement: the values ​​obtained from localStorage properties must be cached so that it does not touch it again. The implementation, by analogy with the previous code, will turn out to be too voluminous for this article, so we will use a slightly more compact pseudocode:
property $foo.userName : subscribe to localStorage return string from localStorage property $foo.showName : subscribe to localStorage return boolean from localStorage property $foo.message : subscribe to $foo.showName switch test $foo.showName when true subscribe to $foo.userName return string from $foo.userName when false unsubscribe from $foo.userName return string property $foo.sayHello : subscribe to $foo.message put to dom string from $foo.message function start : call $foo.sayHello
Here duplication of information is striking: next to actually obtaining the value of a property, we have to subscribe to its changes, and when it becomes known that the value of a property is not required - on the contrary, unsubscribe from its changes. This is very important, because if in time you do not unsubscribe from the non-affecting properties, then as the application becomes more complex and the number of processed data increases, the overall performance will degrade more and more.
Thesis 2: In time, unsubscribe from non-affecting dependencies, otherwise, sooner or later, the application will begin to slow down.
The architecture described above is called Event-Driven. And this is the least terrible version of it - in the more common case, subscription, cancellation and several ways to calculate the value are scattered in different places of the project. Event-Driven architecture is very fragile, because you have to manually monitor timely subscriptions and unsubscribes, and the person is a lazy creature and not very attentive. Therefore, the best solution is to minimize the influence of the human factor, hiding the event propagation mechanism from the programmer, allowing him to concentrate on describing how some data is obtained from others.
Let's simplify the code, leaving only the minimum necessary information about dependencies:
property userName : return string from localStorage property showName : return boolean from localStorage function $foo.message : switch test $foo.showName when true return string from $foo.userName when false return string property $foo.sayHello : put to dom string from $foo.message function start : call $foo.sayHello
Keen readers have most likely already noticed that, after getting rid of a manual subscription-unsubscribe, the descriptions of the properties are so-called “pure functions”. Indeed, we have an
FRP (Functional Reactive Paradigm). Let's look at each term in more detail:
Functional - each variable is described as a function that generates its value based on the values ​​of other variables.
Reactive - a change in one variable automatically leads to an update of the values ​​of all variables depending on it.
Paradigm - a programmer needs a few turn of thinking to understand and accept the principles of building an application.
As you can see, everything described above revolves around variables and dependencies between them. Let's call such frp-variables "atoms" and formulate their main properties:
1. An atom stores in itself exactly one value. This value can be either a primitive or any object, including the exception object.
2. An atom stores a function for calculating a value based on other atoms through an arbitrary number of intermediate functions. Appeals to other atoms when it is executed are tracked so that the atom always has up-to-date information about what other atoms affect its state, as well as about the state of which atoms depend on it.
3. When changing the value of an atom, dependent on it should be updated in a cascade.
4. Exceptions should not violate the consistency of the application state.
5. The atom must be easily integrated with the imperative environment.
6. Since almost every memory slot is wrapped into an atom, the implementation of atoms should be as fast and compact as possible.
Problems with the implementation of atoms
1. Keeping dependencies up to date
If the calculation of the value of one atom required the value of another, then we can safely say that the first depends on the second. If it wasn’t required, there is no direct correlation, but an indirect one is possible. But tracking is necessary and only direct dependencies are sufficient.
This is realized very simply: at the time of starting the calculation of one atom, somewhere in the global variable, it is remembered that it is current, and at the time of getting the value of another, besides returning this value, they are linked to each other. That is, each atom in addition to the slot for the actual value should have two sets: the leading atoms (masters) and the slave (slaves).
With the link, everything is somewhat more complicated: at the moment of start, you need to replace the set of leading atoms with the empty one, and after completing the calculation, compare the set with the previous one and link those who are not in the new set.
This is how the autotracking of dependencies works in KnockOutJS and MeteorJS.
But how do atoms know when to re-run the value calculation? About this further.
2. Cascade consistent update of values
It would seem, what could be easier? Immediately after changing the value, we run over the dependent atoms and initiate their update. This is exactly what KnockOutJS does, and that is why it slows down during mass updates. If one atom (A) depends on, for example, the other two (B, C), then if we consistently change their values, then the value of atom A will be calculated twice. Now imagine that it does not depend on two, but on two thousand atoms, and each calculation takes at least 10 milliseconds.

While for KnockOutJS, developers in narrow places put throttl ingi and debouncers, MeteorJS developers approached the problem more systematically: they made a deferred call to recalculate dependent atoms instead of an immediate one. For the above case, atom A recalculates its value exactly once and does it at the end of the current event handler, that is, after all the changes we made to atoms B, C, and any others.
But this is not really a complete solution to the problem — it pops up again when the depth of atom dependencies becomes greater than 2. I will illustrate with a simple example: atom A depends on atoms B and C, and C in turn depends on D. In case we if we successively change atoms B and D, then atoms A and C will be recalculated pending, and if the C atom changes its value, then the delayed calculation of the value A will be started again. This is usually not so fatal for speed, but if the calculation of A is quite a long operation, then doubling it can be shots pour in the most unexpected place applications.

Having understood the problem, it is easy to come up with a solution: it is enough to memorize the maximum depth among the leaders plus one when linking atoms, and when iterating over the pending ones to update the atoms, first of all update the atoms with a smaller depth. Such a simple technique makes it possible to guarantee that by the time of recalculation of the value of an atom, all the atoms on which it directly depends are of current importance.

3. Exception handling
Imagine this situation: atoms B and C depend on A. Atom B started calculating the value and turned to A. A - also began to calculate its value, but at that moment an exceptional situation occurred - it could be an error in the code or the absence of data - no matter. The main thing is that the atom A must remember this exception, but allow it to surface further so that B can also remember or process it. Why is this so important? Because when C starts calculating the value and turns to A, the events for it should be the same as for B: when accessing A, an exception has surfaced that can be intercepted and processed, but you can do nothing and then the exception to be caught by the library implementing the atoms and stored in the computed atom. If atoms did not memorize exceptions, then any access to them would trigger the launch of the same code inevitably leading to the same exception. These are unnecessary expenses on processor resources, so it’s better to cache them like normal values.

Another, and even more important, point is that in the cascade update of atoms, the calculation of the values ​​of the same occurs in the opposite direction. For example, atom A depends on B, and that depends on C, and that in general from D. When initialized, A begins to calculate its value and refers to B, that of C, and that of D. But the state is updated in the reverse order: D, then C, then B, and finally A:

Subsequently, someone changes the value of the atom D. He notifies the atom C that its value is no longer relevant. Then the atom C calculates its value and if it is not equal to the previous one, then it notifies atom B, which acts in a similar way to notify A. If at some of these moments we do not catch the exception and as a result do not notify dependent atoms, then we will have a situation when the application is in an inconsistent state: half of the application contains new data, half is old, but it is sure that new, and the third half has completely fallen and cannot rise at all, waiting for the data to change.

4. Cyclic dependencies
The presence of cyclic dependencies indicates a logical error in the program. However, the program should not hang or spin in an infinite cycle of deferred calculations. Instead, the atom must detect that its value was required to calculate its value and to initiate an exception.
It is detected simply: when starting a calculation, the atom remembers that it is now calculated, and when someone calls its value, it checks whether it is in the state of calculation and if it is, throws an exception.
5. Asynchrony
Asynchronous code is always a problem, because it turns the logic into spaghetti, which is difficult to follow behind the intricacies and easy to make mistakes. When developing in javascript, you have to constantly balance between simple and clear synchronous code and asynchronous calls. The main problem of asynchrony is that it as a
monad seeps through the interfaces: you cannot write a synchronous implementation of module A, and then, invisibly from module B using it, replace implementation A with asynchronous. To make this change, you have to change the logic of module B, and the dependent C and D, and so on. Asynchrony is like a virus that breaks through all of our abstractions and sticks out the internal realization to the outside.
But atoms easily and simply solve this problem, although they didn’t even think about it at all: it’s all about reactivity. When one atom accesses another, it can issue some answer at once, and in the meantime start an asynchronous task, after which it updates its value and the entire application is updated next in accordance with the received data. Immediate response can be of several types:
a) Return some default value. For the trailing atoms, it would look like “it was one value and suddenly it changed,” but they will not be able to understand the actual data they showed or not. And it is often necessary to know that, for example, to show the user a message that his data has not disappeared anywhere and is about to be loaded.
b) Return the locally cached version. This option is suitable for relatively rarely changing data. For example, the username from the beginning of this article is okay if, between the launches of an application, it changes its name and therefore it will see the previous name for a short time, then it will be able to start working with the application almost immediately. Of course, this approach is not suitable for critical data, especially in conditions of poor connectivity, when an update can take a very long time.
c) Honestly admit that there is no data and return a special value meaning no data. For javascript this will be undefined. But in this case, the correct handling of this value should be everywhere in the dependent code - this is a fairly large amount of the same type code in which it is easy enough to make a mistake, so by choosing this path, get ready for the constantly appearing Null Pointer Exception.
d) After launching the asynchronous task, throw a special exception, which, as described above, will cascadely spread over all dependent atoms to those atoms where it can be processed. For example, an atom responsible for displaying a list of users might catch an exception and instead of silently dropping a user to draw a message “loading” or “loading error”. That is, starting with some remote atom, the exceptional situation becomes quite a regular one. The advantage of this method is that the lack of data can be processed only in a relatively small number of code points, and not all the way to these places. But here it is important to remember that with dependence on several atoms, the calculation will stop after the first to throw an exception, and the rest will not know that their data is also needed, although all the leading atoms could request their data in one single request. , try-catch .
6.
FRP, . , . , . , , , . , , .
3 :
) — , . , - .
) — , . , . , . , , , .
) — , .
7.
— 1 , .
, :
)
) ( , )
) : , , , , …
)
)
)
)
) ( , )
) ,
. , :
) , . , , . $digest AngularJS, , - , «».
) . . , .
) -. , (), , , . — , , 10 , 1 + 10 . , , , .
) . , , , .
) , , , . , , , , .
Epilogue
, :

A — . — . — .
S — , , , .
M — , .
. . , .
:
get — . , pull .
pull — . . , push .
push — , , merge
merge — . , dirty-checking, .
notify — .
fail — .
set — merge , put.
put — push, , .
, . javascript "$jin.atom". , , , . .
, . .