📜 ⬆️ ⬇️

Reactivity in JavaScript: a simple and clear example

Many front-end frameworks written in JavaScript (for example, Angular, React and Vue) have their own reactivity systems. Understanding the features of these systems will be useful to any developer, will help him to more effectively use modern JS frameworks.



The material, the translation of which we are publishing today, shows a step-by-step example of developing a reactivity system in pure JavaScript. This system implements the same mechanisms that are used in Vue.

Reactivity system


To someone who first encounters the Vue reactivity system, it may seem like a mysterious black box. Consider a simple Vue application. Here is the markup:
')
<div id="app">    <div>Price: ${{ price }}</div>    <div>Total: ${{ price*quantity }}</div>    <div>Taxes: ${{ totalPriceWithTax }}</div> </div> 

Here is the framework connection command and application code.

 <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script>   var vm = new Vue({       el: '#app',       data: {           price: 5.00,           quantity: 2       },       computed: {           totalPriceWithTax() {               return this.price * this.quantity * 1.03           }       }   }) </script> 

Somehow, Vue learns that when the price changes, the engine needs to perform three actions:

  1. Update the price value on the webpage.
  2. Recalculate the expression in which price is multiplied by quantity , and output the resulting value to the page.
  3. Call the function totalPriceWithTax and, again, put what it returns on the page.

What happens here is shown in the following illustration.


How does Vue know what to do when the price property changes?

Now we have questions about where Vue knows what needs to be updated when the price changes, and how the engine tracks what happens on the page. What can be observed here is not similar to the work of a regular JS application.

Perhaps this is not yet obvious, but the main problem that we need to solve here is that the JS programs usually do not work. For example, let's run the following code:

 let price = 5 let quantity = 2 let total = price * quantity //  10 price = 20; console.log(`total is ${total}`) 

What do you think will be displayed in the console? Since there is nothing but the usual JS, it is not used, the console will get 10 .


The result of the program

And when using the Vue features, we, in a similar situation, can implement a scenario in which the value of total recalculated when the price or quantity variables change. That is, if the system of reactivity were applied when executing the above described code, not 10, but 40 would be output to the console:


Output to console generated by hypothetical code using reactivity system

JavaScript is a language that can function both as procedural and object-oriented, but there is no built-in reactivity system in it, so the code that we considered when changing the price would not bring the number 40 to the console. In order for the total figure to be recalculated when the price or quantity changes, we will need to create a reactivity system on our own and thereby achieve the behavior we need. The path to this goal we will break into several small steps.

Task: storage of calculation rules for indicators


We need to save somewhere information about how the total indicator is calculated, which will allow us to perform its recalculation when the price or quantity variable values ​​change.

▍Decision


First we need to tell the application the following: “Here’s the code I'm going to run, save it, I may need to run it another time.” Then we need to run the code. Later, if the price or quantity indicators have changed, you will need to call the saved code to recalculate the total . It looks like this:


The total calculation code must be saved somewhere in order to be able to access it later.

The code that in JavaScript can be called to perform some actions, is made in the form of functions. Therefore, we will write a function that is responsible for calculating the total , and also create a mechanism for storing functions that we may need later.

 let price = 5 let quantity = 2 let total = 0 let target = null target = function () {   total = price * quantity } record() //       ,       target() //   

Notice that we store the anonymous function in the target variable, and then call the record function. We will talk about it below. I would also like to note that the target function, using the syntax of ES6 arrow functions, can be rewritten as follows:

 target = () => { total = price * quantity } 

Here is the declaration of the record function and the data structure used to store the functions:

 let storage = [] //     target function record () { // target = () => { total = price * quantity }   storage.push(target) } 

Using the record function, we store the target function (in our case, it is { total = price * quantity } ) in the storage array, which allows us to call this function later, possibly using the replay function, the code of which is shown below. This will allow us to call all the functions stored in storage .

 function replay () {   storage.forEach(run => run()) } 

Here we go through all the anonymous functions stored in the storage array and execute each of them.

Then in our code we can do the following:

 price = 20 console.log(total) // 10 replay() console.log(total) // 40 

It doesn't look so complicated, does it? Here is all the code, the fragments of which we discussed above, in case it would be more convenient for you to finally deal with it. By the way, this code is not accidentally written that way.

 let price = 5 let quantity = 2 let total = 0 let target = null let storage = [] function record () {   storage.push(target) } function replay () {   storage.forEach(run => run()) } target = () => { total = price * quantity } record() target() price = 20 console.log(total) // 10 replay() console.log(total) // 40 

This is what will be displayed in the browser console after it is launched.


The result of the code

Task: reliable solution for storing functions


We can continue to write down the functions we need when the need arises, but it would be nice if we had a more reliable solution that can scale with the application. Perhaps - it will be a class that maintains the list of functions originally written to the target variable, and which receives notifications in case we need to re-execute these functions.

▍ Solution: Dependency Class


One approach to solving the above task is to encapsulate the behavior we need in a class that can be called Dependency. This class will implement the standard observer programming pattern.

As a result, if we create a JS class used to manage our dependencies (which will be close to how similar mechanisms are implemented in Vue), it can look like this:

 class Dep { // Dep -    Dependency   constructor () {       this.subscribers = [] //  ,                               //    notify()   }   depend () { //   record       if (target && !this.subscribers.includes(target)){           //    target                //                this.subscribers.push(target)       }   }   notify () { //   replay       this.subscribers.forEach(sub => sub())       //  -     } } 

Notice that instead of the storage array, we now store our anonymous functions in the subscribers array. Instead of the record function, the depend method is now called. Also here, instead of the replay function, the notify function is notify . Here's how to run our code using the Dep class:

 const dep = new Dep() let price = 5 let quantity = 2 let total = 0 let target = () => { total = price * quantity } dep.depend() //   target    target() //     total console.log(total) // 10 -   price = 20 console.log(total) // 10 -    ,    dep.notify() //   -  console.log(total) // 40 -    

Our new code works the same as before, but now it is better decorated, and there is a feeling that it is better suited for reuse.

The only thing that while in it seems strange is to work with the function stored in the variable target .

Task: mechanism for creating anonymous functions


In the future, we will need to create a Dep object for each variable. In addition, it would be good to encapsulate the behavior of creating anonymous functions somewhere, which should be called when updating the relevant data. Perhaps this will help us an additional function, which we call watcher . This will lead us to replace this function from the previous example with a new function:

 let target = () => { total = price * quantity } dep.depend() target() 

As a matter of fact, the call of the watcher function, replacing this code, will look like this:

 watcher(() => {   total = price * quantity }) 

▍Solution: watcher function


Inside the watcher function, the code of which is presented below, we can perform several simple actions:

 function watcher(myFunc) {   target = myFunc //   target   myFunc   dep.depend() //  target      target() //     target = null //   target } 

As you can see, the watcher function accepts, as an argument, the myFunc function, writes it to the global target variable, calls dep.depend() to add this function to the list of subscribers, calls this function and resets the target variable.
Now we will get the same values ​​10 and 40 if we execute the following code:

 price = 20 console.log(total) dep.notify() console.log(total) 

You may be wondering why we implemented target as a global variable, instead of passing this variable to our functions, if necessary. We have good reason to do just that, you will understand later.

Task: own Dep object for each variable


We have a single object of class Dep . What if we need each of our variables to have its own class object Dep ? Before we continue, let's move the data we are working with into object properties:

 let data = { price: 5, quantity: 2 } 

Imagine for a moment that each of our properties ( price and quantity ) has its own internal object of class Dep .


Price and quantity properties

Now we can call the watcher function like this:

 watcher(() => {   total = data.price * data.quantity }) 

Since we are working here with the value of the data.price property, we need the Dep object of the price property to put the anonymous function (stored in the target ) in its subscriber array (by calling dep.depend() ). In addition, since we also work with data.quantity , we need the object Dep quantity property to put an anonymous function (again, stored in the target ) into its array of subscribers.

If you depict this in the form of a diagram, you get the following.


Functions fall into subscriber arrays of Dep class objects corresponding to different properties.

If we have another anonymous function in which we work only with the data.price property, then the corresponding anonymous function should only go into the repository of the Dep object of this property.


Additional observers can be added to only one of the available properties.

When can I call dep.notify() for functions that are subscribed to price property changes? This will be needed when changing the price . This means that when our example is completely ready, the following code should work for us.


Here, when the price is changed, you need to call dep.notify () for all functions that are subscribed to the price change.

In order for everything to work that way, we need some way to intercept property access events (in our case, this is price or quantity ). This will allow, when this happens, to store the target function in an array of subscribers, and, when the corresponding variable changes, to execute the function stored in this array.

▍Solution: Object.defineProperty ()


Now we need to get acquainted with the standard method of ES5 Object.defineProperty (). It allows you to assign getters and setters to properties of objects. Let, before we proceed to their practical use, demonstrate the work of these mechanisms with a simple example.

 let data = { price: 5, quantity: 2 } Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`I was accessed`)   },   set(newVal) { //        console.log(`I was changed`)   } }) data.price //       data.price = 20 //      

If you run this code in the browser console, it displays the following text.


Results of the getter and setter

As you can see, our example simply displays a couple of lines of text in the console. However, it does not read or set values, since we have redefined the standard functionality of the getters and setters. Restore the functionality of these methods. Namely, it is expected that the getters return the values ​​of the corresponding methods, and the setters set them. Therefore, we will add a new variable to the code, internalValue , which we will use to store the current price value.

 let data = { price: 5, quantity: 2 } let internalValue = data.price //   Object.defineProperty(data, 'price', { //       price   get() { //        console.log(`Getting price: ${internalValue}`)       return internalValue   },   set(newVal) {       console.log(`Setting price to: ${newVal}`)       internalValue = newVal   } }) total = data.price * data.quantity //       data.price = 20 //      

Now that the getter and setter work the way they should work, what do you think will get to the console when executing this code? Take a look at the following drawing.


The data displayed in the console

So, now we have a mechanism that allows receiving notifications when reading property values ​​and when writing new values ​​in them. Now, by slightly reworking the code, we can equip all the properties of the data object with getters and setters. Here we will use the Object.keys() method, which returns an array of keys of the object passed to it.

 let data = { price: 5, quantity: 2 } Object.keys(data).forEach(key => { //        data   let internalValue = data[key]   Object.defineProperty(data, key, {       get() {           console.log(`Getting ${key}: ${internalValue}`)           return internalValue       },       set(newVal) {           console.log(`Setting ${key} to: ${newVal}`)           internalValue = newVal       }   }) }) let total = data.price * data.quantity data.price = 20 

Now all the properties of the data object have getters and setters. This is what will appear in the console after running this code.


Data output to the console by getters and setters

Reactivity assembly


When a code snippet like total = data.price * data.quantity and it retrieves the value of the price property, we need the price property to “remember” the corresponding anonymous function ( target in our case). As a result, if the price property is changed, that is, it is set to a new value, this will result in calling this function to repeat the operations performed by it, since it knows that a certain line of code depends on it. As a result, operations performed in getters and setters can be thought of as follows:


If you use the Dep class you already know in this description, you get the following:


Now we will combine these two ideas and, finally, we will come to the code that allows us to achieve our goal.

 let data = { price: 5, quantity: 2 } let target = null //  -    ,     class Dep {   constructor () {       this.subscribers = []   }   depend () {       if (target && !this.subscribers.includes(target)){           this.subscribers.push(target)       }   }   notify () {       this.subscribers.forEach(sub => sub())   } } //      ,  //      Object.keys(data).forEach(key => {   let internalValue = data[key]   //         //   Dep   const dep = new Dep()   Object.defineProperty(data, key, {       get() {           dep.depend() //    target           return internalValue       },       set(newVal) {           internalValue = newVal           dep.notify() //           }   }) }) //   watcher   dep.depend(), //        function watcher(myFunc){   target = myFunc   target()   target = null } watcher(() => {   data.total = data.price * data.quantity }) 

Let's experiment now with this code in the browser console.


Experiments with ready-made code

As you can see, it works exactly as we need! The price and quantity properties have become reactive! All code that is responsible for forming a total , when the price or quantity changes, is re-executed.

Now, after we have written our own reactivity system, this illustration from the Vue documentation will seem familiar and understandable to you.


Reactivity system in vue

See this beautiful purple circle in which the is written, containing getters and setters? Now he should be familiar to you. Each instance of the component has an instance of the observer method (blue circle), which collects dependencies on getters (red line). When, later, the setter is called, it notifies the observer method, which causes the component to be re-rendered. Here is the same scheme, provided with explanations relating it to our development.


Scheme of reactivity in Vue with explanations

We believe that now, after we have written our own reactivity system, this scheme does not need additional explanations.

Of course, in Vue all this is more complicated, but now you need to understand the mechanism underlying the reactivity systems.

Results


After reading this material, you learned the following:


All this, collected in a single example, led to the creation of a reactivity system in pure JavaScript, having understood that you will be able to understand the features of the functioning of such systems used in modern web frameworks.

Dear readers! If, before reading this material, you had no idea about the features of the mechanisms of reactivity systems, tell me, have you managed to deal with them now?

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


All Articles