===
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 segment.
In Vue 3.0, the syntax for computed has also changed. You can refer to the Composition API for the new syntax, with examples provided as follows:
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 values inside that function change or are assigned, the returned value of computed will be updated.
In the following example, you can see that computed(() => value.foo)
uses value.foo
, so when value.foo = 1
, the value
in cValue
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, since dirty
is true
, the runner will start, meaning the effect will begin collecting dependencies.
The method for collecting dependencies has already been mentioned in the previous chapter, which theoretically is push activeReactiveEffectStack
, run getter
, 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 currently, whenever the statement value.foo = 1
is executed, it will trigger the dependencies, which is the previously mentioned trigger function
, as follows.
Looking at the addRunners function, if the effect is computed, it will add this effect to the computedRunners Set, which is to ensure that the computedRunners can be executed before general effects later on. Why is this done?
Because when a general 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 will get 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, when value.foo = 1
is executed, it will set cValue
's dirty to true, and the next line expect(cValue.value).toBe(1)
will trigger the cValue getter function
to update and return the latest value.
This Test Case can come to a conclusion.
Computed Chained#
But what if it involves computed chained?
The following code can demonstrate the dependency relationship of c2 -> c1 -> value
, and the establishment of computed dependencies can be summarized in one sentence: it all starts with the getter function
collecting, 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 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 when triggering dependency updates.
In this example, value.foo++
is sufficient to update both c1
and c2
to set 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 talk about what happens when a general effect depends on computed.
In this example, the dependencies are effect -> c2 -> c1 -> value
, and since the effect is not lazy, it will run directly. Below is the expression in 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 is all dependencies of c1 and c2)
pop effect, activeReactiveEffectStack => []
At this point, it is very 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 dependents: effect
, c2
, and c1
, and of course, since both computedRunners
and effects
are sets, they will only execute once and trigger their respective functions, completing this Test Case.
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 difficulties are still ahead. The subsequent articles in this series will gradually delve into the core or virtual-dom aspects.