Like my content? Sign up to get occasional emails about new blog posts and other content.
Unsubscribe anytime here.Building Vue 3 Reactivity from Scratch
Vue 3 has a super neat reactivity system based on the ES6 Proxy object. In this article we build a simplified version of the reactivity system. We will stay as close the Vue 3 source code as possible. The idea is to prepare you better to read and understand it.
The source code repository is for this article is exclusive to my GitHub Sponsors.
By the end of this article, you'll be in a position to read through Vue's reactivity
package and have a general understand of what's going on under the hood.
As we work through our implementation, we will compare what we have written to the actual source code and see what's different, and why.
The initial goal will be the following:
test('ref', () => {
const fooref = ref('foo')
let foo
effect(() => {
foo = fooref.value
})
foo //=> 'foo'
expect(foo).toBe('foo')
fooref.value = 'bar'
expect(foo).toBe('bar') // foo is now 'bar' via reactivity!
})
The implementation will scale to something a (little) more interesting, too:
test('multiple refs', () => {
const fooref = ref('')
const barref = ref('')
let foo
let foobar
effect(() => {
foo = fooref.value
foobar = fooref.value + barref.value
})
expect(foo).toBe('')
expect(foobar).toBe('')
fooref.value = 'foo'
expect(foo).toBe('foo')
expect(foobar).toBe('foo')
barref.value = 'bar'
expect(foo).toBe('foo')
expect(foobar).toBe('foobar')
})
If you have used Vue with the Composition API, you will be familiar with ref
. You may not have seen effect
- that's because it's not actually exposed to the end user. What you will be familiar with is watch
, watchEffect
and computed
- the idea is more or less the same. All of these, as well as Vue's reactivity APIs, are powered by effect
under the hood. Since we are doing everything from scratch, we will need to implement effect
before implementing the rest.
Wrapping something in effect
sets up reactivity. Take this snippet:
const fooref = ref('foo')
let foo
effect(() => {
foo = fooref.value
})
fooref.value = 'bar'
console.log(foo) // now it's 'bar' via reactivity!
By wrapping foo = fooref.value
in effect
, we are saying that the value of foo
depends on the value of fooref.value
. Now, any time that fooref.value
changes, the effect
will be executed and foo
will also be updated.
Defining ref
Let's start by defining ref
and a simple test case:
class RefImpl {
private _value: string
constructor(val: string) {
this._value = val
}
get value() {
return this._value
}
}
const ref = (val: string) => {
return new RefImpl(val)
}
const effect = (fn: Function) => {
// TODO
}
test('ref', () => {
const fooref = ref('foo')
let foo
effect(() => {
foo = fooref.value
})
expect(foo).toBe('foo')
})
Compare this to the actual source code. It's pretty similar so far.
This currently fails - effect
doesn't do anything. The quickest way to get this to pass would simply be to implement effect
like this:
const effect = (fn: Function) => {
fn()
}
But this is not exactly reactive. It is enough to get the test to pass, though, which will help us to see the problem with this implementation.
Triggering Reactivity
Update the test to see why our naive effect
implementation is not going to work:
test('ref', () => {
const fooref = ref('foo')
let foo
effect(() => {
foo = fooref.value
})
expect(foo).toBe('foo')
fooref.value = 'bar'
expect(foo).toBe('bar')
})
foo
is not going to be reactively updated. What we need is some way to tell our system to re-run the effect (that is, re-run () => { foo = fooref.value }
) every time fooref.value
changes.
First, update RefImpl
to have a set value
method:
class RefImpl {
private _value: string
constructor(val: string) {
this._value = val
}
get value() {
return this._value
}
set value(val: string) {
this._value = val
}
}
Trigger and Track
The entire reactivity system is instrumented by two key methods: trigger
and track
. Whenever a value is accessed (eg, via get
), we need to call track
to see, or track who is depending on it. In this case, when we call fooref.value
inside of effect
, get value()
is called. We need to track which effect
s need to be executed.
Next, when we do fooref.value = 'bar'
, set value()
is called. If fooref.value
is changing, we will need to call trigger
to execute the relevant effect
- this will give us "reactivity". See the below diagram:
The next step is to implement track
and trigger
. First, however, we will define effect
a little better.
Track and the Target Map
We need some way to track the current effect (since there could be many, although in our example we only have declared one) and which values have effects depending on them.
This might sound a bit confusing - hopefully it'll make more sense soon. For now, we need two variables. targetMap
, which will map values to dependencies and effects, and activeEffect
, which will track the effect that is currently getting tracked.
type Dep = Set<any>
type KeyToDepMap = Map<any, Dep>
const targetMap = new Map<any, KeyToDepMap>()
let activeEffect: any
any
is not really ideal here. Once things are working and we understand the model for reactivity, the types will be improved.
targetMap
is a Map
. The nice thing about the Map
object is, unlike a regular object, you may use objects as keys. We will be using the RefImpl
object as a key, which will map to a KeyToDepMap
.
KeyToDepMap
is another Map
. Why? We are future-proofing ourselves. At the moment ref
can only be a string
. Eventually, we'd like to implement objects, too, so you could do something like:
const foobar = ref({
foo: 'foo',
bar: 'bar'
})
let foo
effect(() => {
foo = foobar.value.foo
})
On this example, foo
depends on the value of foobar.value.foo
. Another effect
might set up a dependency on foo.value.bar
- we want to track these separately. That's what KeyToDepMap
is used for. This is lifted directly from the Vue 3 source code.
Here is the start of our implementation for track
. It's worth noting that while this is very close to the actual implementation in Vue 3. Note the snippet below does deps.add(activeEffect)
which is currently undefined - this will be fixed soon. The code is followed by a diagram explaining how things work.
const targetMap = new Map<any, KeyToDepMap>()
let activeEffect: any
const track = (target: object) => {
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get('value')
if (!deps) {
deps = new Set()
depsMap.set('value', deps)
}
deps.add(activeEffect)
}
Make sure to add track
in RefImpl
. Every time fooref.value
is accessed, we call track
to see if any is depending on it.
class RefImpl {
// ...
get value() {
track(this)
return this._value
}
}
Assuming that activeEffect
is () => { foo = fooref.value }
, we can now work through track
:
const track = (target: object) => {
let depsMap = targetMap.get(target)
/**
* targetMap is currently empty.
* depsMap is undefined.
*/
if (!depsMap) {
/**
* define depsMap as a new Map
* add it to targetMap. The key
* is target, which is the fooref RefImpl
*/
depsMap = new Map()
targetMap.set(target, depsMap)
}
// ...
}
Now targetMap looks like this:
{
[fooref]: Map // empty Map
}
Next we handle the dependencies:
const track = (target: object) => {
// ...
let deps = depsMap.get('value')
/**
* deps is also undefined.
* value refers to `fooref.value`.
* eventually we'd like to support objects
* with keys other than just value
*/
if (!deps) {
/**
* create a new Set
* add it to depsMap.
* deps will be a Set (like an array with no duplicate values)
* of all the effects depending on `value`
*
* when `value` changes, we invoke all the effects!
*/
deps = new Set()
depsMap.set('value', deps)
}
}
Now targetMap
is something like:
{
[fooref]: Map: {
value: Set // empty set
}
}
Finally we add the activeEffect
(which is () => { foo = fooref.value }
) to the deps
Set
:
const track = (target: object) => {
// ...
deps.add(activeEffect)
}
After track
is called, targetMap
looks like this:
{
[fooref]: Map: {
value: Set: () => { foo = fooref.value }
}
}
The last issue we need to address is making sure activeEffect
is defined. We can just set it before invoking the effect, then unset it afterwards. Update effect
:
const effect = (fn: Function) => {
activeEffect = fn
fn()
activeEffect = undefined
}
If you do a console.log(targetMap)
at the bottom of track
and run the test now, you get the following:
Map(1) {
RefImpl { _value: 'foo' } => Map(1) { 'value' => Set(1) { [Function (anonymous)] } }
}
Trigger
Now we are tracking dependencies when get value()
is called, we need the other part of the reactivity system - trigger
when set value()
is called.
All we need to do is grab the correct depsMap
from targetMap
, iterate over each dep
and invoke the associated effect! Note the console.log
which will help us highlight a problem:
const trigger = (target: object) => {
let depsMap = targetMap.get(target)
if (!depsMap) {
return
}
depsMap.forEach(dep => {
console.log(dep)
dep.forEach(eff => {
eff()
})
})
}
Make sure to call trigger
in set value()
:
class RefImpl {
// ...
set value(val: string) {
this._value = val
trigger(this)
}
}
We get the following log:
Map(1) {
RefImpl { _value: 'bar' } => Map(1) {
'value' => Set(2) { [Function (anonymous)], undefined }
}
}
Note the set contains two values... one anonymous function and one undefined
. The function is the effect. This is correct. undefined
should not be there, though. This is, somewhat confusingly, because when we invoke eff()
, we do () => { foo = fooref.value }
which calls get value()
. This in turns calls track
and adds activeEffect
to deps
(which is undefined
at this point)`.
The fix is to simply return early in track
if activeEffect
is undefined
. No undefined
effects allowed:
const track = (target: object) => {
/**
* return early if activeEffect is undefined.
*/
if (!activeEffect) {
return
}
// ... implementation ...
}
With this fix, our simple test case is now passing! We have reactivity. Try the complex example from the start of the post - it works, too!
Conclusion
We implemented a very basic reactivity system in a similar fashion to Vue 3. With your new found understanding, try reading through the actual source code. The ideas are similar - the ES6 Proxy
object is used to handle get
and set
for more complex objects, like reactive
, Map
and Set
. I'd like to make a follow up article implementing reactive
using Proxy
- if this is interesting to you, let me know.
The source code repository is for this article is exclusive to my GitHub Sponsors.
Like my content? Sign up to get occasional emails about new blog posts and other content.
Unsubscribe anytime here.