Over the last weekend, I've decided to take a look at Vue 3. VueMastery provided a free weekend for their courses, so it was a perfect chance to get started (although a bit too late from my side). I watched Vue 3 Reactivity and Vue 3 Essentials.
I was positively surprised, to say the least! The main reason for that is because I've never read or written such abstract code. I mostly took Vue (and other libraries) for granted, unless I specifically needed to understand something. I decided to change this for the composition API. To see how the fundamentals work was an eye-opener, I never realized such things were possible in Javascript.
Learning about tracking effects, triggering them, using object accessors, proxies and more taunted me to find out even more. That's why I made my mind up on writing this article. I'll try to explain most of the things that happen in Vue 3 (following the current source code) when you declare a ref(). I think it's done in a really clever way and an opportunity to improve your understanding of the language. I'll also urge you to check out the source code. Doing this, you can learn so much, but achieve a deeper understanding of the library you're using!
The implementation
We're going to take a look at the ref.ts file first. I have stripped some of the type declarations that aren't that useful to us at the moment.
const convert = <T extends unknown>(val: T): T =>
isObject(val) ? reactive(val) : val
export function ref(value?: unknown) {
return createRef(value)
}
export function shallowRef<T = any>(): Ref<T | undefined>
export function shallowRef(value?: unknown) {
return createRef(value, true)
}
function createRef(value: unknown, shallow = false) {
if (isRef(value)) {
return value
}
if (!shallow) {
value = convert(value)
}
const r = {
_isRef: true,
get value() {
track(r, TrackOpTypes.GET, 'value')
return value
},
set value(newVal) {
value = shallow ? newVal : convert(newVal)
trigger(
r,
TriggerOpTypes.SET,
'value',
__DEV__ ? { newValue: newVal } : void 0
)
}
}
return r
}
As you can see, when you call ref(), the createRef function gets called. Here we have some pretty standard stuff, checking if it already is a ref and converting an object to a reactive (this will always be false since the 'shallow' argument is not passed).
Now we have some pretty cool stuff! We declare an object called r with an '_isRef' property and some object accessors. By declaring a get and a set function for a single property, we can declare what happens when you're trying to get the value or update the value. By doing that, we can add our own logic, for instance, to add reactivity to our objects. As you can see, the return value is the object we created, which is why we have to call .value on a ref variable. In Vue 2, this is done with the Object.defineProperty().
Our getters and setters are looking very slim, but the whole magic happens in the track() and trigger() functions. We will see how it all fits together in the effect.ts file. Since there is a bit more to comprehend, I'll split the two functions into their own blocks.
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (!shouldTrack || activeEffect === undefined) {
return
}
let depsMap = targetMap.get(target)
if (depsMap === void 0) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (dep === void 0) {
depsMap.set(key, (dep = new Set()))
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
if (__DEV__ && activeEffect.options.onTrack) {
activeEffect.options.onTrack({
effect: activeEffect,
target,
type,
key
})
}
}
}
This is what fascinated me, which might say that I have a really low threshold of what impresses me.
In the track() function, we pass in the target object, a tracking type and a key. From our targetMap, we get our depsMap. The targetMap is just a map of our tracked reactive objects and their depsMap (dependencies map). A depsMap contains each of the object's properties and their dependency; the exact effect that needs to get re-run when a value changes. This might be a lot to get your head around, so I'll show you a quick example.
| targetMap |
|-----------|---------|
| health | depsMap |
| damage | depsMap |
Health and damage are our objects which properties we are tracking (which makes it reactive). Now, each object has a depsMap:
| depsMap (health) |
|------------------|-----|
| baseAmount | dep |
| currentAmount | dep |
| bonusAmount | dep |
Each of the properties is represented here and they all have a value of dep. 'dep' represents a set of functions that get run if that specific property changes. For instance:
() => {totalHealth = health.baseAmount + health.bonusAmount}
BaseAmount and bonusAmount will have the same function (effect) written in 'dep'. If either of those change, this function will be run and 'totalHealth' will contain the right value.
This is basically what happens in the track function, with the addition that it creates a new Map or Set if a depsMap or dep doesn't already exist. After I've explained how this works, I'm sad to inform you that none of this code gets executed when we just declare an instance of ref. This is because there is no effect to be tracked, so it just gets returned on the first line. But this will happen if you add a dependency to a property.
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (depsMap === void 0) {
// never been tracked
return
}
const effects = new Set<ReactiveEffect>()
const computedRunners = new Set<ReactiveEffect>()
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
depsMap.forEach(dep => {
addRunners(effects, computedRunners, dep)
})
} else if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
addRunners(effects, computedRunners, dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
addRunners(effects, computedRunners, depsMap.get(key))
}
// also run for iteration key on ADD | DELETE | Map.SET
if (
type === TriggerOpTypes.ADD ||
(type === TriggerOpTypes.DELETE && !isArray(target)) ||
(type === TriggerOpTypes.SET && target instanceof Map)
) {
const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
addRunners(effects, computedRunners, depsMap.get(iterationKey))
}
}
const run = (effect: ReactiveEffect) => {
scheduleRun(
effect,
target,
type,
key,
__DEV__
? {
newValue,
oldValue,
oldTarget
}
: undefined
)
}
// Important: computed effects must be run first so that computed getters
// can be invalidated before any normal effects that depend on them are run.
computedRunners.forEach(run)
effects.forEach(run)
}
Now that we know how targetMap, depsMap and deps are generated, it's a lot easier to understand triggers.
If the object has been tracked, we find our depsMap from our targetMap and get the value. If you remember, depsMap's value is deps which contains all the effects for a specific property. With the addRunnders() function, we add all of the effects to the effects or computedRunners sets, depending on the fact whether they are computed or not.
After all of that, we run each effect for our computed properties first and then for effects. This is what makes the reactivity work after you update a single property on a tracked object.
And that's the basic behaviour of our refs, calling track() and trigger() when getting or setting a property. I hope it has been clear enough and that I haven't made any wrong assumptions. There is a lot to comprehend here, but the main point is that you have gotten the gist of the refs' reactivity and how it all actually works.
I've pasted the links to the files I've been looking at, so you can take a closer look and see for yourself that it's not that complicated. It's also a really good learning material for patterns that you might not know!
Top comments (0)