Implement Vue.js Reactive System From Scratch
A tutorial on implementing a Vue.js Reactive system.
Hello there! As promised, in this article, we are going to implement the Vue.js reactive system, which we learned from my previous article. Let’s begin!
Effect
In this section, we are going to implement The Effect
class; Please see the code below:
let activeEffect;
class Effect {
constructor(fn) {
this.fn = fn
}
run() {
activeEffect = this
const result = this.fn()
activeEffect = undefined
return result
}
}
function effect(fn) {
const _effect = new Effect(fn)
_effect.run()
return _effect
}
As you can see if the run method of any Effect
is executed, the activeEffect
will refer to our current effect, whose run
method is currently being executed. By executing the run, we add the activeEffect
to all of the target’s dependencies in the fn
function.
Track and Trigger
As I mentioned in my previous article, we need to trigger our target whenever there are updates to it in order to update all values or obtain new values from it. This is necessary because we require all the new values or updates for this target. If we read a value from our target, we need to track it to determine which targets this current target is dependent on. I also need to mention that the target could be a computed value or a ref value and etc.
function track(target) {
// if we have any activeEffect
if (activeEffect) {
target.dep.add(activeEffect)
}
}
function trigger(target) {
const effects = [...target.dep]
for (const effect of effects) {
effect.run()
}
}
Ref
It’s time to implement our first target: Ref!
This is very simple. We just need to trigger after each set, track the value retrieval, and it’s done!
class Ref {
constructor(value) {
this.dep = new Set()
this.value = value
}
set value(value) {
this._inner_value = value
trigger(this)
}
get value() {
track(this)
return this._inner_value
}
}
function ref(value) {
return new Ref(value)
}
Test Effect with Ref
Now let’s test it with this code:
const firstname = ref('Ehsan')
const lastname = ref('Movaffagh')
let fullname = ''
effect(() => {
fullname = `${firstname.value} ${lastname.value}`
})
console.log(fullname) // Ehsan Movaffagh
firstname.value = 'banana'
console.log(fullname) // banana Movaffagh
Wow! This is great. The fullname
value has changed after we modified the firstname
. It is because, after each change, the effect method will be executed.
Computed
Now it is time for a more complicated one: computed values! Computed values have getter functions, and within these functions, we have one or more targets. Therefore, we need to create an effect for that getter function to understand when those target values are updated.
class Computed {
constructor(name, getter) {
this.dep = new Set()
this._cached_value = undefined
this.effect = effect(() => {
console.log(`run computed effect ${name}`)
this._cached_value = getter()
trigger(this)
})
}
get value() {
track(this)
return this._cached_value
}
}
function computed(name, fn) {
return new Computed(name, fn)
}
I added that console.log
to show you when the computed value effect will run!
Test Computed
Now let’s test it with this code:
const number = ref(1)
const number2 = ref(2)
const sum = computed('sum', () => {
return number.value + number2.value
})
// run computed effect sum
console.log(sum.value) // 3
number.value = 2
// run computed effect sum
console.log(sum.value) // 4
Let’s create another computed value with sum
:
const sumDescription = computed('sumDescription', () => {
return `sum(${number.value}, ${number2.value}) = ${sum.value}`
})
// run computed effect sumDescription
console.log(sumDescription.value) // sum(2, 2) = 4
number.value = 1
// run computed effect sum
// run computed effect sumDescription
// run computed effect sumDescription
console.log(sumDescription.value) // sum(1, 2) = 3
Wait a second! Why there are two logs of sumDescription
? The answer is one of them is for number
and another one is for sum
. Well, this is bad for performance! What can we do to prevent this issue?
Solve performance issue
To solve this issue, we need to determine the appropriate time to update the target’s dependencies values. We already know when that is: when we need to read the value, right?!
To achieve this, we need to add a scheduler
method to our effects. This scheduler should indicate when we should execute the run method of our effect. In the trigger
, we will execute the scheduler
instead of the run
method of any effect.
Effect
Initiate effect with fn
and scheduler
.
class Effect {
constructor(fn, scheduler) {
this.fn = fn
this.scheduler = scheduler
}
run() {
activeEffect = this
const result = this.fn()
activeEffect = undefined
return result
}
}
function effect(fn, scheduler) {
const _effect = new Effect(fn, scheduler)
_effect.run()
return _effect
}
Trigger
The scheduler
should be executed instead of run
.
function trigger(target) {
const effects = [...target.dep]
for (const effect of effects) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
Computed
Our changes in computed are simple. We added a new _dirty
attribute to track whether the computed value is dirty, indicating that it needs to be updated when we read the computed value.
After we read the new value and update the _cached_value
, we don’t need to execute the effect again; we can simply read from the _cached_value
.
class Computed {
constructor(name, getter) {
this.dep = new Set()
this._cached_value = undefined
this._dirty = true
this._name = name
this.effect = new Effect(getter, () => {
if (!this._dirty) {
this._dirty = true
trigger(this)
}
})
}
get value() {
track(this)
if (this._dirty) {
this._dirty = false
console.log(`run computed effect ${this._name}`)
this._cached_value = this.effect.run()
}
return this._cached_value
}
}
I need to mention that we use the Effect
constructor instead of the effect
function because the effect function will execute the getter
, which is not what we want. We want to run the getter
function only when we need to read the computed value.
Test
And now we need to test our new implementations.
const sumDescription = computed('sumDescription', () => {
return `sum(${number.value}, ${number2.value}) = ${sum.value}`
})
console.log(sumDescription.value)
// run computed effect sumDescription
// sum(2, 2) = 4
number.value = 1
console.log(sumDescription.value)
// run computed effect sum
// run computed effect sumDescription
// sum(1, 2) = 3
As you can see, there is only one sumDescription
log because the effect runner will only be executed when we need it!
Conclusion
And here we are! We are done! We have implemented Effect, trigger, track, ref, and computed. The reactive and computed with setter functionalities remain. I will leave it to you to implement them yourself. I will provide you with some tips for the reactive part: you can utilize Proxy and Reflect.
💡 Tip: If you find yourself using these functions in multiple projects, use Bit to share and reuse it. This way, you’ll have independent versioning, tests, and documentation for them too, making it easier for others to understand and use your code. No more copy/pasting repeatable code from repos.
Learn more:
Hope you enjoy it!
Build composable Vue apps with reusable components, just like Lego
Bit is an open-source toolchain for the development of composable software.
With Bit, you can develop any piece of software — a modern web app, a UI component, a backend service or a CLI script — as an independent, reusable and composable unit of software. Share any component across your applications to make it easier to collaborate and faster to build.
Join the 100,000+ developers building composable software together.
Get started with these tutorials: