WhatsUp - front-end framework based on ideas of streams and fractals
Dani Chu
Posted on April 2, 2021
Hi guys!
My name is Dan. Today I want to share my project with you. It is a frontend framework. I collected my most exotic ideas in it.
npm i whatsup
It is built on generators, provides functionality similar to react + mobx out of the box, has good performance, and weighs less than 5kb gzip. With a reactive soul. With minimal api. With the maximum use of native language constructs.
The architectural idea is that our entire application is a tree structure, along the branches of which the flow of data is organized in the direction of the root, reflecting the internal state. During development, we describe the nodes of this structure. Each node is a simple self-similar entity, a full-fledged complete application, all work of which is to receive data from other nodes, process it and send it next.
This is the first part of my story. We'll take a look at state management here.
Cause & Conse
Two basic streams for organizing reactive data state. For ease of understanding, they can be associated with the familiar computed and observable.
Nothing special, right? conse creates a stream with an initial value, whatsUp - add the observer. Through .set(...) we change the value - the observer reacts - a new entry appears in the console.
Cause is created from a generator, inside which the yield* expression is the "connection" of an external thread to the current one. The situation inside the generator can be viewed as if we are inside an isolated room, in which there are several yield* inputs and only one return output
constname=conse('John')constuser=cause(function*(){return{name:yield*name,// ^^^^^^ connect stream name }})whatsUp(user,(v)=>console.log(v))//> {name: "John"}name.set('Barry')//> {name: "Barry"}
yield* name sets the dependence of the user stream on the name stream, which in turn also leads to quite expected results, namely - change the name - the user changes - the observer reacts - the console shows a new record.
What is the advantage of generators?
Let's complicate our example a little. Let's imagine that in the data of the user stream, we want to see some additional parameter revision, that reflects the current revision.
It's easy to do - we declare a variable revision, the value of which is included in the dataset of the user stream, and each time during the recalculation process, we increase it by one.
But something is wrong here - revision looks out of context and unprotected from outside influences. There is a solution to this - we can put the definition of this variable in the body of the generator, and to send a new value to the stream (exit the room) use yield instead of return, which will allow us not to terminate the execution of the generator, but to pause and resume from the place of the last stops on next update.
Without terminating the generator, we get an additional isolated scope, which is created and destroyed along with the generator. In it, we can define the variable revision, available from calculation to calculation, but not accessible from outside. At the end of the generator, revision will go to trash, on creation - it will be created with it.
Extended example
The functions cause and conse are shorthand for creating streams. There are base classes of the same name available for extension.
When extending, we need to implement a whatsUp method that returns a generator.
Context & Disposing
The only argument accepted by the whatsUp method is the current context. It has several useful methods, one of which is update - allows you to force initiate the update procedure.
To avoid unnecessary and repeated computation, all dependencies between threads are dynamically tracked. When the moment comes when the stream has no observers, the generator is automatically destroyed. The occurrence of this event can be handled using the standard try {} finally {} language construct.
Consider an example of a timer thread that generates a new value with a 1 second delay using setTimeout, and when destroyed, calls clearTimeout to clear the timeout.
consttimer=cause(function*(ctx:Context){lettimeoutId:numberleti=0try{while (true){timeoutId=setTimeout(()=>ctx.update(),1000)// set a timer with a delay of 1 secyieldi++// send the current value of the counter to the stream }}finally{clearTimeout(timeoutId)// clear timeoutconsole.log('Timer disposed')}})constdispose=whatsUp(timer,(v)=>console.log(v))//> 0//> 1//> 2dispose()//> 'Timer disposed'
A simple mechanism to generate a new value based on the previous one. Consider the same example with a mutator based timer.
constincrement=mutator((i=-1)=>i+1)consttimer=cause(function*(ctx:Context){// ...while (true){// ...// send mutator to the streamyieldincrement}// ...})
A mutator is very simple - it is a method that takes a previous value and returns a new one. To make it work, you just need to return it as a result of calculations, all the rest of the magic will happen under the hood. Since the previous value does not exist on the first run, the mutator will receive undefined, the i parameter will default to -1, and the result will be 0. Next time, zero mutates to one, etc. As you can see, increment allowed us to avoid storing the local variable i in the generator body.
That's not all. In the process of distributing updates by dependencies, the values are recalculated in streams, while the new and old values are compared using the strict equality operator ===. If the values are equal, the recalculation stops. This means that two arrays or objects with the same data set, although equivalent, are still not equal and will provoke meaningless recalculations. In some cases this is necessary, in others it can be stopped by using the mutator as a filter.
classEqualArr<T>extendsMutator<T[]>{constructor(readonlynext:T[]){}mutate(prev?:T[]){const{next}=thisif (prev&&prev.length===next.length&&prev.every((item,i)=>item===next[i])){/*
We return the old array, if it is equivalent
to the new one, the scheduler will compare
the values, see that they are equal and stop
meaningless recalculations
*/returnprev}returnnext}}constsome=cause(function*(){while (true){yieldnewEqualArr([/*...*/])}})
In this way, we get the equivalent of what in other reactive libraries is set by options such as shallowEqual, at the same time we are not limited to the set of options provided by the library developer, but we ourselves can determine the work of filters and their behavior in each specific case. In the future, I plan to create a separate package with a set of basic, most popular filters.
Like cause and conse, the mutator function is shorthand for a short definition of a simple mutator. More complex mutators can be described by extending the base Mutator class, in which the mutate method must be implemented.
Look - this is how you can create a mutator for a dom element. The element will be created and inserted into the body once, everything else will boil down to updating its properties.
In this article, I described the basic capabilities of WhatsUp for organizing state management. In the next article, I will tell you how WhatsUp can work with jsx, about the event system and the exchange of data through the context.
If you liked the idea of my framework - leave your feedback or a star on the github. I'll be very happy. Thanks!