📜 ⬆️ ⬇️

$ mol_atom: theory and practice of reactivity

Hello, my name is Dmitry Karlovsky and I ... a wealthy person. I have a state on the server, there are states in local storages, there is a browser window state, there is a domain model state, there is an interface state. And all this variety of states must be kept synchronized. If one state changes somehow, the other states associated with it should be updated as soon as possible. A special piquancy of the situation is given by the fact that synchronization with the server can take seconds, and the user interface can only be blocked for a split second.


Wealthy person


Then you will learn how reactivity conquers asynchrony, how imperative reactivity gets along with functional, how simple abstractions allow you to write reliable and fast code, and how I once switched to the idempotent side of force and wrap everything


Understanding Reactivity Varieties


First of all, it is necessary to determine the concept of "reactivity" (reactivity). This is a phenomenon when a change in one state leads to a cascade change in other states. Reactive programming uses this principle to describe the rules for changing one state when another changes. In the following, the unit of the reactive state for simplicity will be called the "atom".


If you have never programmed in the reactive paradigm, then I can recommend to read my article two years ago , which explains why it is needed at all. Nevertheless, it is slightly out of date, since $ mol_atom implements more advanced logic.


[ State1 ]----/ Rule1-2 /---->[ State2 ]----/ Rule2-3 /---->[ State3 ] 

Reactive rules can be described in three paradigms:


Restarts . The rules are stored separately from the state and are a procedure that changes some states based on other states. When any state involved by this procedure changes, the procedure automatically restarts. The problem of this approach is in the stationarity of synchronization rules, large overheads and the risk of going into an infinite loop.


 [ State ]<-------->[ Rule1-2( State ) ] [ State ]<-------->[ Rule2-3( State ) ] 

Examples of implementations: AngularJS @ 1 , MeteorJS .


Pushing . Inside the atom are stored the rules for the formation of states of other atoms. Usually the rules are set during application initialization and do not change in the future, and if they change, then manually. The APIs of the libraries implementing this approach are greatly inflated — tens and hundreds of methods solve its many problems: immediate execution of the rules, dependence on a single atom, response even if the new value is equal to the old one and others.


 [ State1 => Rule2( State1 ) ]-------->[ State2 => Rule3( State2 ) ]-------->[ State3 ] 

Examples of implementations: BaconJS , KefirJS , RxJS .


Pulling . Inside the atom is stored the rule of formation of its state as a function of the states of other atoms. The state is formed lazily, at the moment of addressing it. If someone ceases to depend on the state of the atom, then he can “fall asleep” losing dependence on other atoms. In the "laziness", dynamic automatic activation and deactivation of the rules the main advantage of this approach, therefore, the further narration will go about it.


 [ State1 ]-------->[ State2 = Rule2( State1 ) ]-------->[ State3 = Rule3( State2 ) ] 

Examples of implementations: KnockOutJS , MobXJS , CellX and $ mol_atom itself .


It should be noted that the above division of principles is conditional and reflects the basic principle of the work of the respective libraries.


There is no time to explain


And so wanted


Create a pair of mutable atoms with a calculated default value:


 const userName = new $mol_atom( 'userName' , next => next || 'Anonymous' ) const showName = new $mol_atom( 'showName ' , next => next || false ) 

Create a computed atom that does not allow a direct change in its value:


 const greeting = new $mol_atom( 'greeting' , next => { if( !showName.value() ) return 'Hello!' return `Hello, ${ userName.value() }!` } ) 

Create a presentation atom that displays a greeting to the console whenever something in the data changes:


 const presenting = new $mol_atom( 'presenting' , next => { console.log( greeting.value() ) } ) 

Forcibly activate the presentation atom:


 presenting.value() //Hello! 

We change 2 atoms at once, but there will be only one output:


 showName.value( true ) userName.value( 'John' ) //Hello, John! 

Forcibly present after each change of data:


 userName.value( 'Jin' ) presenting.value() // Hello, Jin! showName.value( false ) presenting.value() // Hello! 

We are trying to change the greeting directly - nothing comes out:


 greeting.value( 'Hi!' ) 

Forcibly set a greeting to bypass the rules:


 greeting.value( 'Hi!' , $mol_atom_force ) //Hi! 

Change the original data - zero reaction:


 showName.value( false ) 

Force update the value of greeting according to the rules:


 greeting.value( void null , $mol_atom_force ) //Hello! 

We enable logging of all atoms and change one of them:


 $mol_log.filter( '' ) showName.value( true ) //21:44:11 showName.value() ["push", true, false] //21:44:11 greeting.value() ["obsolete"] //21:44:11 $mol_atom.sync [] //21:44:11 userName.value() ["push", "Anonymous", undefined] //21:44:11 greeting.value() ["push", "Hello, Anonymous!", "Hello!"] //21:44:11 presenting.value() ["obsolete"] //Hello, Anonymous! 

Turn off logging:


 $mol_log.filter( null ) 

Try online. Another example.


What about her?


It would seem that it could be simpler: when changing one state, to cause recalculation of dependent states. But..


It is not that simple


First of all, you need to decide what depends on.


Suppose you are doing a blog and on the page of some post you need to display the title of the post in the window title, and on the page of posts filtered by tag - the name of the tag. It turns out that the state of the window title dynamically changes its dependencies. It depends on the name of the post, then on the name of the tag, but in both cases it depends on some condition that determines what it needs to depend on.


Already in this simple example, one can see how important the support of dynamic dependencies is. Therefore, we do not accept such an abstraction as "stream", around which the popular "push" libraries are built.


A typical "pull" implementation works as follows:


  1. When an atom calculates its state, it first puts itself in a global variable, which allows other atoms to understand which atom is being calculated.


  2. Next, he creates an empty list of his dependencies, in which all atoms will be registered, to which one way or another will be addressed.


  3. Only now is the execution of the formula - a normal function that returns some result.


  4. In the process of executing a formula, there may be a call to any other functions, objects, and browser interfaces.


  5. If in the process of executing a formula, the value of another atom is addressed, then the one, taking the dependent atom from the global variable, links them in such a way that both will know that the value of one depends on the value of the other.


  6. After the formula is calculated, the resulting value is stored in the atom, so that later it can be returned immediately, without a relatively long calculation of the formula.


  7. Finally, a new list of dependencies is compared with the old one, in order to “separate” the atoms that are no longer dependent on each other.


  8. If the value of an atom changes, then there is a notification of atoms hiding from it, that their values ​​are outdated and they also need updating.

Thus, we always have an actual network of atoms, which is dynamically rebuilt, reflecting real dependencies at a given time.


It is worth noting that the formulas (and, as a result, all the functions called in one way or another) are required to be idempotent. That is, if no dependency has changed, then the result of the function should remain unchanged. The result here is understood not only the value returned by the formula, but also the side effects produced in the calculation process.


Thanks to ideponentnosti, it is possible to cache the result for an indefinite period and reset the cache only when it is either no longer needed or not relevant anymore.


But what if there is an exception when calculating the value? If it is not intercepted, then the current "calculated atom" placed in the global variable will remain so there that in the future work of the application may lead to the appearance of very strange dependencies, which is not quite easy to debug. In addition, every time an atom is accessed, a formula will be calculated and an exception will pop up, which not only clogs the console on large amounts of data, but also causes non-weak brakes. And the cherry on the cake of problems will be that some of the atoms will remain in irrelevant condition.


In simple terms, the state of the application will no longer be consistent and will start to work unstably. To prevent this, each atom must intercept the exception and save it in itself instead of the value and allow it to surface further. Thus, the next time the atom is accessed, this exception will be thrown again. If the exception was the result of incorrect data, then as soon as the data becomes correct, the atom will recalculate its value and, instead of throwing an exception, will return the current value.


But at what point should the dependent atoms recalculate their values? If you do this right away when you receive a notice of obsolescence, an atom can fulfill a formula many times in vain, when several of its dependencies sequentially change. Therefore, in good implementations, the atom is immediately only marked out-of-date, but now it is already deferred.


Generally speaking, the problem of extra calculations is not as harmless as it may seem. It can lead to the following unpleasant consequences:


  1. Decrease in productivity up to impossibility to use the application.
  2. The occurrence of exceptions in unexpected places. A typical situation is a call to an object that has already been deleted.
  3. Unnecessary requests to the server, notifications and the like are not "collapsing" repetition.

There are several strategies for deferred actualization of atoms:


  1. In order of obsolescence. At the time of obsolescence, the atom is added to the end of the queue for recalculation. The simplest strategy. However, it leaves a fairly large number of unnecessary recalculations.


  2. In order of creation. Each atom is given a numeric identifier. Later created atoms have a larger identifier value. Atoms are counted starting at the atom with the lowest identifier. It turns out that dependent atoms are recalculated earlier than those on which they depend, since they are often created in the process of calculating dependent ones. All this again leads to unnecessary calculations.


  3. In order of increasing depth. Atoms are sorted by maximum depth of dependencies. At first, atoms without dependencies are recalculated, then dependent on atoms without dependencies, then on atoms that depend on atoms without dependencies, and so on. With this scheme, there is almost no extra recalculation. However, sometimes there are incidents when the atom has a shallow depth, but its existence is determined by an atom with greater depth. Thus, he recounts his value first, and later removes it, since it is no longer necessary.


  4. In the right order. Dependencies are updated in the same order in which they were addressed when calculating the dependent atom.

Come on, tell me how to


To ensure the correct order of actualization, each atom can be in one of 4 states:


  1. Obsolete . At the next call, its value will be calculated by the formula. When an atom enters this state, it notifies the dependent atoms that they are "possibly outdated."


  2. Perhaps outdated (checking). On the next appeal, he will first make sure that all his dependencies are up to date. As soon as one of them changes its value, the atom will become "obsolete" with all the consequences. Otherwise, it will become "relevant" without recalculating the value. When an atom enters the state of "possibly outdated", it immediately notifies the dependent atoms that they are also "possibly outdated." Thus, this state is cascaded to the entire dependent subtree. If nobody depends on this atom, then it adds itself to the queue for deferred update.


  3. Actual . When referencing, returns the memorized value. If during the transition to the current state, its value has changed, then it notifies the dependent atoms that they are "obsolete".


  4. Calculated (pulling). When an atom begins to be recalculated, it enters this state. Appealing to an atom in this state leads to an exception, since it indicates a cyclical dependence. After the calculation, even if it ended in an error, the atom remembers the result, moving into the "actual" state.

This logic may seem overly complex and redundant, but it does guarantee that:


  1. The application does not hang due to cyclic dependencies.
  2. The recalculation of an atom will occur no sooner than its dependencies take on their current meaning.
  3. The recalculation of an atom will not be performed if the actual values ​​of its dependencies have not changed.
  4. When accessing an atom, we are guaranteed to get the current value (other schemes do not guarantee this, since they do not exclude the possible need for its re-calculation).

Need more memes


Here and further examples are in TypeScript, which is ES6 with the addition of typing after the colon.


Working with atoms directly is not very convenient. They need to be given unique names so that something meaningful is displayed in the logs, and not just abstract numbers. It is necessary to store them somewhere and not to create once again. To get rid of this routine, we introduce the concept of "property" as a polymorphic method, which, depending on the number of parameters, "returns" or "sets and immediately returns" some value.


A common property has the following interface:


 { < Value >() : Value < Vlaue >( nextValue : Value ) : Value } 

For example:


 class App { title( next? : string ) { if( next !== void null ) document.title = next return document.title } } 

It can be made reactive (cached with automatic cache invalidation) by simply adding the $mol_mem() decorator:


 class App { @ $mol_mem() title( next? : string ) { if( next !== void null ) document.title = next return document.title } } 

Let's make the code of the greeting application as a class (for convenience, we use $ mol_object to help generate the correct names, but you can not use it by defining the toString method of the object manually):


 class App extends $mol_object { @ $mol_mem() userName( next? : string ) { return next || 'Anonymous' } @ $mol_mem() showName( next? : boolean ) { return next || false } greeting() { if( !this.showName() ) return 'Hello!' return `Hello, ${ this.userName() }!` } @ $mol_mem() presenting() { console.log( this.greeting() ) } } 

As you can see, the greeting property is not reactive, so this method will be called every time you access it. But showName is reactive, so the method will be called only at the first reading of the default value and when the new value is passed to it.


We could declare properties in the spirit of MobX, but for this, we would have to write more cumbersome code with duplication of the name of the property:


 class App { @observable get userName() { return 'Anonymous' } set userName( next : string ) { return next } @observable get showName() { return false } set showName( next : boolean ) { return next } get greeting() { if( !this.showName ) return 'Hello!' return `Hello, ${ this.userName }!` } @computed get presenting { console.log( this.greeting ) } } 

In addition, it would not be as easy and simple to overload the property as a whole from the outside, as in the following example:


 class My extends $mol_object { @ $mol_mem() static instance() { return new this } name(){ return `Jin #${ Date.now() }` } @ $mol_mem() showName( next ) { return ( next === void null ) ? true : next } @ $mol_mem() app() { const app = new App app.userName = ()=> this.name() app.showName = ( next )=> this.showName( next ) return app } } My.instance().app().presenting() //Hello, Jin #1481383086982! 

Try online. Another example.


Here we used property overloading to elegantly create one-sided binding for the userName property and two-sided for the showName property. This is a very powerful technique that allows you to fine-tune the behavior of any object, immediately after its creation, without the risk of breaking it.


It is worth paying attention to exceptionally clear logs, which clearly show which states have changed as:


 $mol_log.filter( '' ) My.instance().app().showName( false ) $mol_log.filter( null ) //11:27:58 My.instance().showName() ["push", false, true] //11:27:58 My.instance().app().presenting() ["obsolete"] //Hello! 

We need something more complicated


It is often necessary to work not with one value, but with a whole family of values ​​distinguished by a certain key, but according to the same logic. For these cases, the following interface is used:


 { < Key , Value >( key : Kay ) : Value < Key , Vlaue >( key : Key , nextValue : Value ) : Value } 

For example, let's create the simplest class that allows using REST resources:


 class Rest extends $mol_object { @ $mol_mem_key() static resource( uri : string , next? : any , force : $mol_atom_force ) { const request = new XMLHttpRequest const method = ( next === void null ) ? 'get' : 'put' request.onload = ( event : Event )=> { this.resource( uri , request.responseText , $mol_atom_force ) } request.onerror = ( event : ErrorEvent )=> { setTimeout( ()=> { this.resource( uri , event.error || new Error( 'Unknown HTTP error' ) , $mol_atom_force ) } ) } request.open( method , uri ) request.send( next ) throw new $mol_atom_wait( `${ method } ${ uri }` ) } } 

Separate atoms will be created for different uri. But when referring to the same uri, the same atom will be used. As you can see, the logic for obtaining data and setting values ​​is completely the same. The only difference is that when requesting data, the http-method "get" will be used, and during data transfer - "put".


Since atoms are able to work adequately with exceptional situations, here we used this property of theirs to abstract the application code from asynchrony. Since the data is not yet loaded, we cannot immediately return it. Instead, a special exception is thrown and the calculation of properties dependent on this property is interrupted pending any changes. When a response comes from the server, it is set as the property value and the dependent atoms "come to life", but this time their calculation is not interrupted. It is worth noting that the error handler can be called by the browser synchronously, so the property is set deferred to ensure that it is installed, and not $ mol_atom_wait, which rushes further.


Let's implement an application that draws all the emoji that the githab supports:


 class App extends $mol_object { @ $mol_mem() static presenting() { const emojis = JSON.parse( Rest.resource( 'http://api.github.com/emojis' ) ) document.body.innerHTML = '' for( let id in emojis ) { const image = document.createElement( 'img' ) image.src = emojis[ id ] document.body.appendChild( image ) } } } App.presenting() 

Try online.


As you can see, we did not have to dance with callbacks, promises, generators, streams, asynchronous functions and other hellish creatures. Instead, our code is simple and straightforward.


A more complex example of parallel file downloads is discussed in a detailed article about $ mol .


But I just introduced ..


Rxjs


Light version - only 250KB.


250KB!


All this is only to write combinators of combinators of a heap of small closures instead of sequential code. Maintainers of AngularJS @ 2, after all, cannot be mistaken. Speaker with * JsConfTalksMeetUpDays waving his hands convincingly about it. This is how you need to write code in 2k16:


 const greeting = showName .select( showName => { if( showName ) return userName.map( userName => `Hello, ${ userName }!` ) return Rx.Observable.from([ 'Hello!' ]) } ) .switch() 

And for such an obsolete product, you need to tear off your hands:


 greeting() { if( this.showName() ) return `Hello, ${ this.userName() }!` else return 'Hello!' } 

Promises


The godfather of the whole team


Built into almost every browser, they allow you to write ... chains of chains of small closures. After all, this is what the code of this signor looks like:


 let _config const getConfig = ()=> { if( _config ) return _config return _config = $.get( 'config.json' ).then( JSON.parse ) } let _profile const getProfile = ()=> { if( _profile ) return _profile return _profile = $.get( 'profile.json' ).then( JSON.parse ) } const getGreeting = ()=> getConfig() .then( config => { if( !config.showName ) return 'Hello!' return getProfile() .then( profile => `Hello, ${profile.userName}!` ) } ) 

This same code is simply impossible to maintain:


 @ $mol_mem() config() { return JSON.parse( Rest.resource( 'config.json' ) ) } @ $mol_mem() profile() { return JSON.parse( Rest.resource( 'profile.json' ) ) } @ $mol_mem() greeting() { if( !this.config().showName ) return 'Hello!' return `Hello, ${ this.profile().userName }!` } 

Async functions


Misunderstood genius


You are at the cutting edge of technology. On so acute that many browsers do not understand what you are writing:


 let _config const getConfig = async ()=> { if( _config ) return _config return _config = JSON.parse( await $.get( 'config.json' ) ) } let _profile const getProfile = async ()=> { if( _profile ) return _profile return _profile = JSON.parse( await $.get( 'profile.json' ) ) } const getGreeting = async ()=> { if( !( await getConfig() ).showName ) return 'Hello!' return `Hello, ${ ( await getProfile() ).userName }!` } 

webpack babel , , , :


 'use strict'; function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } var _config = void 0; var getConfig = function () { var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() { return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: if (!_config) { _context.next = 2; break; } return _context.abrupt('return', _config); case 2: _context.t0 = JSON; _context.next = 5; return $.get('config.json'); case 5: _context.t1 = _context.sent; return _context.abrupt('return', _config = _context.t0.parse.call(_context.t0, _context.t1)); case 7: case 'end': return _context.stop(); } } }, _callee, undefined); })); return function getConfig() { return _ref.apply(this, arguments); }; }(); var _profile = void 0; var getProfile = function () { var _ref2 = _asyncToGenerator(regeneratorRuntime.mark(function _callee2() { return regeneratorRuntime.wrap(function _callee2$(_context2) { while (1) { switch (_context2.prev = _context2.next) { case 0: if (!_profile) { _context2.next = 2; break; } return _context2.abrupt('return', _profile); case 2: _context2.t0 = JSON; _context2.next = 5; return $.get('profile.json'); case 5: _context2.t1 = _context2.sent; return _context2.abrupt('return', _profile = _context2.t0.parse.call(_context2.t0, _context2.t1)); case 7: case 'end': return _context2.stop(); } } }, _callee2, undefined); })); return function getProfile() { return _ref2.apply(this, arguments); }; }(); var getGreeting = function () { var _ref3 = _asyncToGenerator(regeneratorRuntime.mark(function _callee3() { return regeneratorRuntime.wrap(function _callee3$(_context3) { while (1) { switch (_context3.prev = _context3.next) { case 0: _context3.next = 2; return getConfig(); case 2: if (_context3.sent.showName) { _context3.next = 4; break; } return _context3.abrupt('return', 'Hello!'); case 4: _context3.next = 6; return getProfile(); case 6: _context3.t0 = _context3.sent.userName; _context3.t1 = 'Hello, ' + _context3.t0; return _context3.abrupt('return', _context3.t1 + '!'); case 9: case 'end': return _context3.stop(); } } }, _callee3, undefined); })); return function getGreeting() { return _ref3.apply(this, arguments); }; }(); 

, - :


 @ $mol_mem() config() { return JSON.parse( Rest.resource( 'config.json' ) ) } @ $mol_mem() profile() { return JSON.parse( Rest.resource( 'profile.json' ) ) } @ $mol_mem() greeting() { if( !this.config().showName ) return 'Hello!' return `Hello, ${ this.profile().userName }!` } 

, config , !


..



$mol_atom $mol . , . , (-, ) , . , . , , . $mol_atom+$mol_mem 25KB .


')

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


All Articles