Red Huang

Red Huang

Vue 3.0 Reactive, Computed Source Code Analysis

===

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.

  1. push c2, activeReactiveEffectStack => [ c2 ]
  2. c2 run fn // which is c1.value + 1
  3. push c1, activeReactiveEffectStack => [ c2, c1 ]
  4. c1 run fn // which is value.foo
  5. value depends on activeReactiveEffectStack top // which is c1
  6. pop c1, activeReactiveEffectStack => [ c2 ]
  7. c1 run trackChildRun, making activeReactiveEffectStack top, which is c2, depend on all dependencies of c1
  8. 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.

  1. push effect, activeReactiveEffectStack => [ effect ]
  2. effect run fn // which is dummy = c2.value
  3. push c2, activeReactiveEffectStack => [ effect, c2 ]
  4. c2 run fn // which is c1.value + 1
  5. push c1, activeReactiveEffectStack => [ effect, c2, c1 ]
  6. c1 run fn // which is value.foo
  7. value depends on activeReactiveEffectStack top // which is c1
  8. pop c1, activeReactiveEffectStack => [ effect, c2 ]
  9. c1 run trackChildRun, making activeReactiveEffectStack top, which is c2, depend on all dependencies of c1
  10. pop c2, activeReactiveEffectStack => [ effect ]
  11. 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)
  12. 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.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.