How does reactivity work?
One of Vue’s most distinctive features is the unobtrusive reactivity system. Component state consists of reactive JavaScript objects. When you modify them, the view updates. It makes state management simple and intuitive, but it’s also important to understand how it works to avoid some common gotchas. In this section, we are going to dig into some of the lower-level details of Vue’s reactivity system.
This term comes up in programming quite a bit these days, but what do people mean when they say it? Reactivity is a programming paradigm that allows us to adjust to changes in a declarative manner. The canonical example that people usually show, because it’s a great one, is an Excel spreadsheet.
A | B | C | |
---|---|---|---|
0 | 1 | ||
1 | 2 | ||
2 | 3 |
Here cell A2 is defined via a formula of =
A0 + A1 (you can click on A2 to view or edit the formula), so the spreadsheet gives us 3. No surprises there. But if you update A0 or A1, you'll notice that A2 automagically updates too.
JavaScript doesn’t usually work like this. If we were to write something comparable in JavaScript:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) // 3
A0 = 2
console.log(A2) // Still 3
So how would we do this in JavaScript? First, in order to re-run the code that updates A2
, let's wrap it in a function:
let A2
function update() {
A2 = A0 + A1
}
A0 and A1 are considered dependencies of the effect, as their values are used to perform the effect. The effect is said to be a subscriber to its dependencies.
The update() function produces a side effect, or effect for short, because it modifies the state of the program.
What we need is a magic function that can invoke update() (the effect) whenever A0 or A1 (the dependencies) change:
whenDepsChange(update)
Track when a variable is read. E.g. when evaluating the expression A0 + A1, both A0 and A1 are read;
Detect when a variable is mutated. E.g. when A0
is assigned a new value, notify all its subscriber effects to re-run.
We can't really track the reading and writing of local variables like in the example. There's just no mechanism for doing that in vanilla JavaScript. What we can do though, is intercept the reading and writing of object properties.
There are two ways of intercepting property access in JavaScript: getter/setters and Proxies. Vue 2 used getter/setters exclusively due to browser support limitations. In Vue 3, Proxies are used for reactive objects and getter/setters are used for refs.
First step what we need to create a Map structure
// Component's dependencies entry point, where all dependencies are allocated
const dependencies = new Map()
Under the hood of the Vue framework the Map
structure is used for a component dependencies itself, while WeakMap is used for whole Vue's components.
The track function will registrate your effects into a data's field which is defined.
Second step what we need to create the track function
function track (target, effects) {
let dependency = dependencies.get(target)
if (!dependency) {
dependencies.set(target, (dependency = new Set()))
}
effects.forEach((effect) => { dependency.add(effect) })
}
The Set
object lets you store unique values of any type, whether primitive values or object references.
The trigger function will invoke all effects (which you difine into computed's object) when you set a new value to object's property (which is called data).
Third step what we need to create the trigger function
function trigger (target) {
const dependency = dependencies.get(target)
if (!dependency) { return }
dependency.forEach((effect) => {
target[effect.name] = effect.call(target)
})
}
The Proxy pattern which will track and trigger the effects by using get and set trap.
Fourth step what we need to create the reactive function
function reactive (obj, effects) {
return new Proxy(obj, {
get (target, key) {
track(target, effects)
return Reflect.get(target, key)
},
set (target, key, value) {
Reflect.set(target, key, value)
trigger(target)
},
})
}
Merge computed and data between each other
function merge ({ data, computed }) {
const target = Object.values(computed).reduce((result, fn) => Object.assign(result, {
[fn.name]: fn.call({ ...data, ...result })
}), {})
return Object.assign(data, target)
}
Init our reactivity system
function init (obj) {
Object.keys(obj).forEach(key => obj[key])
}
Cue (Vue) framework
function Cue (obj) {
const target = merge(obj)
const effects = Object.values(obj.computed)
const result = reactive(target, effects)
init(result)
return result
}
Final code
((environment) => {
const dependencies = new Map()
function track (target, effects) {
let dependency = dependencies.get(target)
if (!dependency) {
dependencies.set(target, (dependency = new Set()))
}
effects.forEach((effect) => { dependency.add(effect) })
console.log('after track -> dependency', dependency)
}
function trigger (target) {
const dependency = dependencies.get(target)
if (!dependency) { return }
console.log('before trigger -> dependency', dependency)
console.log('before trigger -> target', target)
dependency.forEach((effect) => {
target[effect.name] = effect.call(target)
})
console.log('after trigger -> target', target)
}
function reactive (obj, effects) {
return new Proxy(obj, {
get (target, key) {
console.log('reactive -> get -> target', target)
track(target, effects)
return Reflect.get(target, key)
},
set (target, key, value) {
console.log('reactive -> set -> target', target)
Reflect.set(target, key, value)
trigger(target)
}
})
}
function init (obj) {
Object.keys(obj).forEach(key => obj[key])
}
function merge ({ data, computed }) {
const target = Object.values(computed).reduce((result ,fn) => Object.assign(result, {
[fn.name]: fn.call({ ...data, ...result })
}), {})
console.log('merge -> target', target)
return Object.assign(data, target)
}
function Cue (obj) {
const target = merge(obj)
const effects = Object.values(obj.computed)
const result = reactive(target, effects)
init(result)
return result
}
environment.Cue = Cue
})(window)
var cue = Cue({
data: {
a: 2
},
computed: {
getA () { return this.a * 2 },
getB () { return this.getA * 10 }
}
})
console.log(cue)
What if we change a computed property, will be this property changed?