Reactivity in Vue
Here at XCentium our front-end developers often rely on Vue.js for prototyping and building out client sites. The framework has become our go-to as it s fast, flexible, lightweight, and has a shallow learning curve.
One of the most powerful (and least understood) features of Vue.js is its reactivity system. This is what enables your HTML to automatically update and reflect changes in your data. In this post I m going to break down exactly how this system works, so you can get a better understanding of what s going on under the hood.
Before we begin, let s define exactly what a reactivity system is. In a nutshell it s a system where variable X depends on the value of variable Y, and gets recalculated whenever any changes are made to variable Y.
Imagine an Excel spreadsheet. You can set cell A1 to 10
, and set cell A2 to a function that doubles the value of A1. Then if you update A1 to 50
, A2 becomes 100
. The A2 function is dependent upon the current value of A1 in order to run. In other words, A1 is a dependency of A2.
Excel is aware of this dependency so whenever A1 is updated the A2 function is ran again. This is an example of a reactive system.
So how does this get implemented in Vue? Let s create a simplified version of their reactivity system so we can see how it works.
Setup
First, let s pretend we re building a game. We ll start by building a basic player object, and a function that will warn the player if their hit points (health) drops below 20:
const player = {
name: 'Andrew',
hp: 100,
stamina: 100
}
function warning() {
if (player.hp < 20) {
console.log('You re low on health!')
}
}
As you can see from the above, the warning
function is dependent upon player.hp
. We also want it to be reactive, i.e. we want it run whenever player.hp
is updated.
Detecting Changes
Next, let s use the Object.defineProperty
method to convert each property in our player
object to getters and setters:
const player = {
name: 'Andrew',
hp: 100,
stamina: 100
}
function warning() {
if (player.hp < 20) {
console.log('You re low on health!')
}
}
Object.entries(player).forEach(entry => {
const [key, value] = entry
let internalValue = value
Object.defineProperty(player, key, {
get() {
return internalValue
},
set(newValue) {
internalValue = newValue
}
})
})
With this change we can now detect whenever a player property is accessed (read from) or set (written to).
Tracking Dependencies
Whenever a player property is accessed we want to register the currently running function as a dependency. Likewise, whenever a player property is set we want to run all of the dependencies.
A simplified version of that process would look something like this:
const player = {
name: 'Andrew',
hp: 100,
stamina: 100
}
function warning() {
if (player.hp < 20) {
console.log('You re low on health!')
}
}
Object.entries(player).forEach(entry => {
const [key, value] = entry
const dependencies = []
let internalValue = value
Object.defineProperty(player, key, {
get() {
dependencies.push(currentlyRunningFunc)
return internalValue
},
set(newValue) {
internalValue = newValue
dependencies.forEach(func => func())
}
})
})
Which begs the question how do we track and store the currently running function in the currentlyRunningFunc
variable that you see above?
Tracking Function Executions
We can define a run
function that will run a passed-in function. But before the passed-in function is ran, we set the currentlyRunningFunc
variable to its value. For example:
let currentlyRunningFunc = null
function run(func) {
currentlyRunningFunc = func
func()
currentlyRunningFunc = null
}
run(() => {})
Putting it all Together
If we combine all of the above, we end up with a working mini reactivity system!
We ll also need to check if there s a currently running function and that it s not already in the dependencies array before adding it:
const player = {
name: 'Andrew',
hp: 100,
stamina: 100
}
function warning() {
if (player.hp < 20) {
console.log('You re low on health!')
}
}
Object.entries(player).forEach(entry => {
const [key, value] = entry
const dependencies = []
let internalValue = value
Object.defineProperty(player, key, {
get() {
const exists = dependencies.includes(currentlyRunningFunc)
if (currentlyRunningFunc && !exists) {
dependencies.push(currentlyRunningFunc)
}
return internalValue
},
set(newValue) {
internalValue = newValue
dependencies.forEach(func => func())
}
})
})
let currentlyRunningFunc = null
function run(func) {
currentlyRunningFunc = func
func()
currentlyRunningFunc = null
}
run(warning)
So let s breakdown what s happening here, step-by-step:
-
warning
is passed intorun
-
run
storeswarning
in thecurrentlyRunningFunc
variable -
run
runswarning
-
warning
accessesplayer.hp
(inside the if statement) -
This access triggers the
set
logic we defined forplayer.hp
-
The
set
logic adds thewarning
function as a dependency -
run
resets thecurrentlyRunningFunc
variable to null
Giving it a Test Run
const monster = {
type: 'Goblin',
attack: 30
}
// the player is hit 3 times
player.hp = player.hp - monster.attack // 70 hp
player.hp = player.hp - monster.attack // 40 hp
player.hp = player.hp - monster.attack // 10 hp => "You re low on health!"
As you can see as soon as player.hp
goes below 20, the console logs You re low on health!
. Magic! Or not, but nonetheless it s still a clever pattern.
Additional Notes
-
This is a simplified version of the Vue reactivity system the actual implementation is much more complex.
-
Since Vue runs
Object.defineProperty
on initialization any dynamically added data properties won t be reactive. To dynamically add data properties, useVue.set
. -
DOM updates are asynchronous. So if you need to work with an updated DOM after updating a data property, you ll need to use
Vue.nextTick
.
Sitecore Blogs:
View more blogs and tutorials about Sitecore.
Learn about our Sitecore work.
For thought leadership on Digital Strategy, please view these videos.