===
Vue 3.0 Reactive, Computed Source Code Analysis#
This document makes extensive use of code, please make good use of the search (Ctrl+F) to trace the entire code.
In Vue 3.0, the syntax for computed has also changed. You can refer to the Composition API, which provides the following example:
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
Let’s use a Test Case to see what computed can do#
Computed can take a function as an argument, and whenever the value inside the function changes or is assigned a value, the return value of computed will be updated.
In the following example, we can see that computed(() => value.foo)
uses value.foo
, so when value.foo = 1
, cValue
's value
will be updated to 1
.
it('should return updated value', () => {
const value = reactive<{ foo?: number }>({})
const cValue = computed(() => value.foo)
expect(cValue.value).toBe(undefined)
value.foo = 1
expect(cValue.value).toBe(1)
})
Computed#
Now that we understand the functionality of computed, let’s trace how the source code allows computed to update and the intricacies involved.
First, let’s take a look at the implementation of the computed function. In the original Test Case, we can see that the passed method conforms to the type getterOrOptions: (() => T) | WritableComputedOptions<T>
, so there is no setter function
. Next, on line 23, an effect
is declared, and note that at this point it is lazy: true
.
export function computed<T>(getter: () => T): ComputedRef<T>
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: (() => T) | WritableComputedOptions<T>
): any {
const isReadonly = isFunction(getterOrOptions)
const getter = isReadonly
? (getterOrOptions as (() => T))
: (getterOrOptions as WritableComputedOptions<T>).get
const setter = isReadonly
? __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
: (getterOrOptions as WritableComputedOptions<T>).set
let dirty = true
let value: T
const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
dirty = true
}
})
In the previous chapter, we briefly mentioned the actual effect of effect
. Let's review the code as follows.
We can see that as long as options.lazy
is true
, the activation time can be controlled by external parties.
export function effect(
fn: Function,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect {
if ((fn as ReactiveEffect).isEffect) {
fn = (fn as ReactiveEffect).raw
}
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}
Returning to computed, when expect(cValue.value).toBe(undefined)
is executed, it will reach this getter function get value()
, and at this point, dirty
is true
, so the runner will start, meaning the effect will begin to collect dependencies.
The method of collecting dependencies has been mentioned in the previous chapter, which theoretically involves push activeReactiveEffectStack
, run getter
, and pop activeReactiveEffectStack
.
At this point, after executing expect(cValue.value).toBe(undefined)
, it has collected the value
reactiveObject, and the work of collecting dependencies comes to an end.
const runner = effect(getter, {
lazy: true,
// mark effect as computed so that it gets priority during trigger
computed: true,
scheduler: () => {
dirty = true
}
})
return {
[refSymbol]: true,
// expose effect so computed can be stopped
effect: runner,
get value() {
if (dirty) {
value = runner()
dirty = false
}
// When computed effects are accessed in a parent effect, the parent
// should track all the dependencies the computed property has tracked.
// This should also apply for chained computed properties.
trackChildRun(runner)
return value
},
set value(newValue: T) {
setter(newValue)
}
}
}
So now, whenever the statement value.foo = 1
is executed, the triggering of dependencies will start, which is the previously mentioned trigger function
, as follows.
Looking at the addRunners function, if the effect is computed, this effect will be added to the computedRunners Set, so that the computedRunners can be executed before regular effects. Why is this done?
Because when a regular effect needs to use any information from the computedObject, it must first set the computedObject's dirty to true, which means prioritizing the execution of the scheduler function
, ensuring that the effect running later gets the latest information from the computedObject.
export function trigger(
target: any,
type: OperationTypes,
key?: string | symbol,
extraInfo?: any
) {
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 === OperationTypes.CLEAR) {
// collection being cleared, trigger all effects for target
depsMap.forEach(dep => {
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
if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY
addRunners(effects, computedRunners, depsMap.get(iterationKey))
}
}
const run = (effect: ReactiveEffect) => {
scheduleRun(effect, target, type, key, extraInfo)
}
// 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)
}
function addRunners(
effects: Set<ReactiveEffect>,
computedRunners: Set<ReactiveEffect>,
effectsToAdd: Set<ReactiveEffect> | undefined
) {
if (effectsToAdd !== void 0) {
effectsToAdd.forEach(effect => {
if (effect.computed) {
computedRunners.add(effect)
} else {
effects.add(effect)
}
})
}
}
Back to the topic, after the result of tracking is completed, after executing value.foo = 1
, cValue
's dirty will be set to true, so the next line expect(cValue.value).toBe(1)
will trigger the cValue getter function
and update and return the latest value.
This Test Case can be concluded.
Computed Chained#
But what if it involves computed chained?
The following code can show the dependency relationship of c2 -> c1 -> value
, and the establishment of computed dependencies can be summarized in one sentence: they are all collected starting from the getter function
, in this case, c2.value
, which will execute the runner in the computedObject. Since this is more complex, I will express it in simple terms.
push c2, activeReactiveEffectStack => [ c2 ]
c2 run fn // which is c1.value + 1
push c1, activeReactiveEffectStack => [ c2, c1 ]
c1 run fn // which is value.foo
value depends on activeReactiveEffectStack top // which is c1
pop c1, activeReactiveEffectStack => [ c2 ]
c1 run trackChildRun, making activeReactiveEffectStack top, which is c2, depend on all dependencies of c1
pop c2, activeReactiveEffectStack => []
In this way, the parent node will depend on all dependencies of the child nodes, and this is recursively effective, meaning that the parent node can depend on all descendant nodes, and the dependencies are flattened.
The benefit of this is that it allows the hierarchical structure to not be affected by the number of layers in terms of update performance triggered by dependencies.
In this example, value.foo++
is enough to set both c1
and c2
's dirty to true, so regardless of the order of expect(c2.value).toBe(2)
and expect(c1.value).toBe(1)
, both values will update.
it('should work when chained', () => {
const value = reactive({ foo: 0 })
const c1 = computed(() => value.foo)
const c2 = computed(() => c1.value + 1)
expect(c2.value).toBe(1)
expect(c1.value).toBe(0)
value.foo++
expect(c2.value).toBe(2)
expect(c1.value).toBe(1)
})
function trackChildRun(childRunner: ReactiveEffect) {
const parentRunner =
activeReactiveEffectStack[activeReactiveEffectStack.length - 1]
if (parentRunner) {
for (let i = 0; i < childRunner.deps.length; i++) {
const dep = childRunner.deps[i]
if (!dep.has(parentRunner)) {
dep.add(parentRunner)
parentRunner.deps.push(dep)
}
}
}
}
Triggered effect when chained#
Next, let’s discuss what happens when a regular effect depends on computed.
In this example, the dependency is effect -> c2 -> c1 -> value
, and since the effect is not lazy, it will run directly. Below is a direct expression of the previous order of text.
push effect, activeReactiveEffectStack => [ effect ]
effect run fn // which is dummy = c2.value
push c2, activeReactiveEffectStack => [ effect, c2 ]
c2 run fn // which is c1.value + 1
push c1, activeReactiveEffectStack => [ effect, c2, c1 ]
c1 run fn // which is value.foo
value depends on activeReactiveEffectStack top // which is c1
pop c1, activeReactiveEffectStack => [ effect, c2 ]
c1 run trackChildRun, making activeReactiveEffectStack top, which is c2, depend on all dependencies of c1
pop c2, activeReactiveEffectStack => [ effect ]
c2 run trackChildRun, making activeReactiveEffectStack top, which is effect, depend on all dependencies of c2 (and at this time c2 has all dependencies of c1, so it includes all dependencies of both c1 and c2)
pop effect, activeReactiveEffectStack => []
At this point, it’s simple; dummy will naturally be 1 at line 12, and both getter1
and getter2
will execute once. Then, when value.foo++
is executed, it will trigger three dependencies: effect
, c2
, and c1
, and of course, since both computedRunners
and effects
are sets, they will only execute once and trigger their respective functions. This Test Case is complete.
it('should trigger effect when chained', () => {
const value = reactive({ foo: 0 })
const getter1 = jest.fn(() => value.foo)
const getter2 = jest.fn(() => {
return c1.value + 1
})
const c1 = computed(getter1)
const c2 = computed(getter2)
let dummy
effect(() => {
dummy = c2.value
})
expect(dummy).toBe(1)
expect(getter1).toHaveBeenCalledTimes(1)
expect(getter2).toHaveBeenCalledTimes(1)
value.foo++
expect(dummy).toBe(2)
// should not result in duplicate calls
expect(getter1).toHaveBeenCalledTimes(2)
expect(getter2).toHaveBeenCalledTimes(2)
})
This explains how complex and challenging all computed situations can be, but the difficulty is still ahead. The subsequent articles in this series will gradually move towards the core or virtual-dom aspects.