Reactivity and how it's done inside Vue

Aug. 7, 2018

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

The article demonstrates a step-by-step example of the development of a reactivity system in pure JavaScript. This system implements the same mechanisms that are used in Vue.

Reactivity system

 The one who first collides with the work of the Vue reactivity system, it may seem a mysterious black box. Let's consider a simple Vue application. Here's the markup:

<div id="app">
    <div>Price: ${{ price }}</div>
    <div>Total: ${{ price*quantity }}</div>
    <div>Taxes: ${{ totalPriceWithTax }}</div>
</div>

Here is the command line for connecting the framework and the 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 knows that, when changing the price, the engine needs to perform three actions:

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

What is happening here is shown in the following illustration.

vue

Where does Vue know what to do when the price property changes?
Now we have questions about where Vue knows what exactly needs to be updated when price changes, and how the engine tracks what's happening on the page. What you can see here does not seem to work for a regular JS application.

Perhaps this is not yet obvious, but the most important problem that we need to solve here is that JS programs usually do not work like this. 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 nothing is used here except for the usual JS, 10 will display to the console.

And when using the Vue capabilities, we, in a similar situation, can implement a scenario in which the value of total was recalculated when the price or quantity variables are changed. That is, if the reactivity system were applied when executing the above-described code, it would not be 10, but 40.

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

Task: storage of rules for calculation of indicators

We need somewhere to store information about how the total is calculated, which will allow us to perform its recalculation by changing the values of the price or quantity variables.    

Solution

 First, we need to tell the application the following: "Here's the code I'm going to run, save it, I might need to run it some other time." Then we will need to run the code. Later, if the price or quantity indicators have changed, you will need to call the saved code for the recalculation of total. It looks like this:

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

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

The code, which you can call in JavaScript for performing some actions, is formalized in the form of functions. Therefore, we will write a function that calculates 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() // Put the function in the repository in case you need to call it later
target() // Call the function

Note that we store the anonymous function in the target variable, and then call the record function. We'll talk about it below. I also want to note that the target function, using the syntax of arrow functions ES6, 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 = [] // Here we store the target functions

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

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

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

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

Then in our code we can do the following:

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

It does not look so complicated, does it? That's all the code, the fragments of which we discussed above, in case you find it more convenient to deal with it definitively. By the way, this code is not accidentally written this 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 its launch.

Results of working code

 Results of working code 

Task: A reliable solution for storing functions

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

Solution: class Dependency

One approach to solving the above problem is to encapsulate the behavior we need in a class, which 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 is close to how similar mechanisms are implemented in Vue), it can look like this:

class Dep { // Dep - Dependency
    constructor () {
        this.subscribers = [] // dependent functions that
                     // must be run when calling notify ()
    }
    depend () { // replacement of record function
        if (target && !this.subscribers.includes(target)){
            // only if there is a target and this function
            // is not yet in the list of subscribers to the changes
            this.subscribers.push(target)
        }
    }
    notify () { // replacement of replay function
        this.subscribers.forEach(sub => sub())
        // launching subscription functions or observers
    }
}

Note 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 function replay, the function notify is used. 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() // adding target function into the subscribers
target() // starting function for counting the total

console.log(total) // 10 — right value
price = 20
console.log(total) // 10 — now it's not value what we need
dep.notify() // starting function - subscribers
console.log(total) // 40 — now all is right

Our new code works the same as before, but now it's better decorated, and it feels like it's better suited for reuse. He also briefly describes how Vue behaves with always storing the actual state of variables, which is called the reactivity.

Task: mechanism for creating anonymous functions

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

let target = () => { total = price * quantity }
dep.depend()
target()
Strictly speaking, calling the function watcher, that replacing this code, will look like this:


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

Solution: function watcher

Inside the function watcher, the code of which is presented below, we can perform a few simple steps:    

function watcher(myFunc) {
    target = myFunc // active function target becomes function myFunc
    dep.depend() // add target to the list of subscribers
    target() // calling function
    target = null // reset variable target
}

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

price = 20
console.log(total)
dep.notify()
console.log(total)
Perhaps you are wondering why we implemented target as a global variable, rather than, if necessary, pass this variable to our functions. We have good reasons to do so, later you will understand this.

Task: Own Dep object for each variable

We have a single object of class Dep. What if we want each of our variables to have its own Dep object? Before we continue, let's move the data we are working to 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.

Properties price and quantity

Properties price and quantity

Now we can call the watcher function like this:

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

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

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

Functions fall into arrays of subscribers of objects of class Dep, corresponding to different properties

 Functions fall into arrays of subscribers of objects of class Dep, corresponding to different properties

If we have one more anonymous function, which works only with the data.price property, then the corresponding anonymous function should only get into the Dep object store of the Dep class of this property. 

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

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

When might it be necessary to call dep.notify () for functions that are signed for changes to the price property? This is required when changing the price. This means that when our example is completely ready, the following code should work for us.

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


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

In order for everything to work that way, we need some way to intercept the access events to properties (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, execute the function stored in this array.

Solution: Object.defineProperty()

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

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

Object.defineProperty(data, 'price', { // assign getter and setter only to the property price

    get() { // getter
        console.log(`I was accessed`)
    },

    set(newVal) { // setter
        console.log(`I was changed`)
    }
})
data.price // when accessing a property, a getter is called
data.price = 20 // when setting a property, the setter is called

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

The results of the work of the getter and setter


The results of the work of the getter and setter 

As you can see, our example just outputs a couple lines of text to the console. However, it does not read or set the values, since we have redefined the standard getter and setter functionality. Let us restore the functional of these methods. Namely, it is expected that getters return values of corresponding methods, and setters install them. So add a new variable, internalValue, to the code, which we will use to store the current price.
let data = { price: 5, quantity: 2 }
let internalValue = data.price // startup value

Object.defineProperty(data, 'price', { // assign getter and setter only to the property price

    get() { // getter
        console.log(`Getting price: ${internalValue}`)
        return internalValue
    },

    set(newVal) {
        console.log(`Setting price to: ${newVal}`)
        internalValue = newVal
    }
})
total = data.price * data.quantity // when accessing a property, a getter is called
data.price = 20 // // when setting a property, the setter is called

Now that the getter and setter are working the way they should work, what do you think that will get into the console while executing this code? Take a look at the following figure.

Data output to the console


Data output to the console

So, now we have a mechanism that allows to receive notifications when reading property values and when new values are written in them. Now, after having slightly reworked the code, we can equip all properties of the data object with getters and setters. Here we use the method Object.keys(), which returns an array of keys of the object passed to it.

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

Object.keys(data).forEach(key => { // we execute this code for each property of the data object
    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 properties of the data object have getters and setters. That's what will appear in the console after running this code.

Data output to the console by getters and setters


Data output to the console by getters and setters

Reactivity system assembly

When a code fragment like total = data.price * data.quantity is executed and the value of the price property is obtained, 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 - set to a new value, this will cause the function to be called to repeat the operations it performed, since it knows that a certain line of code depends on it. As a result, operations performed in getters and setters can be imagined as follows:

  • Getter - you need to remember the anonymous function, which we will call again when changing the value.
  • Setter - you need to execute the stored anonymous function, which will result in the change of the corresponding result value.

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

  • When reading the property value, dep.depend () is called to save the current target function.
  • When writing a value to a property, dep.notify () is called to restart all stored functions.

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

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


// This is the same class that we have already considered

class Dep {
    constructor () {
        this.subscribers = []
    }
    depend () {
        if (target && !this.subscribers.includes(target)){
            this.subscribers.push(target)
        }
    }
    notify () {
        this.subscribers.forEach(sub => sub())
    }
}
// We also considered this procedure, but
// here it is supplemented by new teams
Object.keys(data).forEach(key => {
    let internalValue = data[key]

    // Each property will be associated with its own

    // instance of class Dep
    const dep = new Dep()

    Object.defineProperty(data, key, {
        get() {
            dep.depend() // remember the executed function target
            return internalValue
        },
        set(newVal) {
            internalValue = newVal
            dep.notify() // re-execute the saved functions
        }
    })
})

// Now the function watcher does not cause dep.depend(),
// since this call is made in getter
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 the finished code

Experiments with the finished code 

As you can see, it works exactly as we need it! The properties of price and quantity became reactive! All code that is responsible for generating total, when the price or quantity is changed, is repeated. Now, after we have written our own reactivity system, this illustration from the Vue documentation will seem familiar and understandable to you.

Other posts