In the last article Vue 3.x responsive principle — REF source code analysis, the author briefly describes the implementation principle of Vue 3.x REF API, this paper is one of the core parts of the responsive principle, effect module is used to describe Vue 3.x storage response, tracking changes, Starting from track and trigger of effect module, this article explores that when a reactive object is created, its getter is immediately triggered once, and track will be used to collect its dependencies. When the reactive object changes, trigger will be immediately triggered to update the dependencies of the reactive object.

Before reading this article, if you don’t know enough about the following points, you should know the following points:

  • Proxy
  • WeakMap
  • WeakSet
  • Reflect
  • Vue Composition API

I have written about it before, and you can also combine it with related articles:

  • ES6 syntax you may have overlooked – reflection and proxy
  • Vue 3.0 update, Composition API
  • Vue 3.0 preview, experience Vue Function API
  • Vue Composition API responsively wraps object principles
  • Vue 3.x reactive principle — Reactive source code analysis
  • Vue 3.x responsive principle — REF source code analysis

Starting from the track

Track is a function for collecting dependencies. For example, when we use computed attributes, the update of dependent attributes causes recalculation of computed attributes. In reactive modules, we see that getters for reactive objects call this track internally:

function createGetter(isReadonly: boolean) {
  return function get(target: object, key: string | symbol, receiver: object) {
    // Reflect gets the original get behavior
    const res = Reflect.get(target, key, receiver)
    // If it is a built-in method, no additional proxy is required
    if (isSymbol(key) && builtInSymbols.has(key)) {
      return res
    }
    // If it is a ref object, proxy to ref.value
    if (isRef(res)) {
      return res.value
    }
    // track is used to collect dependencies
    track(target, OperationTypes.GET, key)
    // If it is a nested object, it needs to be handled separately
    // If it is a primitive type, return the value directly
    return isObject(res)
      // createGetter is used to create a responsive object. IsReadonly is passed in as false
      // For nested objects, call Reactive recursively to get the result
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
}
Copy the code

Reading the code of the Reactive module raises the question: what is the interpretation of the track call? Vue 1.x: Dep and Watcher: Vue 1.x: Dep and Watcher

We’re assuming that when we initialize a reactive object, we call its getter once, and before the getter is called, we initialize a structure, let’s say DEP, and during the initialization of this reactive object, its getter call, if we value some other reactive object, In the getter methods of other responsive objects, the track method is called, and the track method stores the dependent responsive object and its related feature properties into its corresponding DEP. In this way, when the dependent is updated, The initialized reactive object reinvokes the getter, triggering recalculation.

Now, let’s look at Track and verify our conjecture:

// Global switch. Track is turned on by default. If track is turned off, it will stop tracking changes inside Vue
let shouldTrack = true

export function pauseTracking() {
  shouldTrack = false
}

export function resumeTracking() {
  shouldTrack = true
}

export function track(target: object, type: OperationTypes, key? : unknown) {
  // The global switch is off or the effectStack is empty, no dependency collection is required
  if(! shouldTrack || effectStack.length ===0) {
    return
  }
  // Fetch a variable called effect from the effectStack. Effect describes the current reactive object
  const effect = effectStack[effectStack.length - 1]
  // If the current operation is traversal, mark it as traversal
  if (type === OperationTypes.ITERATE) {
    key = ITERATE_KEY
  }
  // The targetMap is initialized when the reactive object is created. The target is the reactive object, and the targetMap maps to an empty map, which refers to depsMap
  DepsMap = depsMap = depsMap; depsMap = depsMap; SET, ADD, DELETE, CLEAR, GET, HAS, ITERATE to a SET, which contains the effect
  // If depsMap is empty, initialize an empty Map in targetMap
  let depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // Get the dep Set from key
  let dep = depsMap.get(key!)
  // If dep is empty, initialize dep to a Set
  if (dep === void 0) { depsMap.set(key! , (dep =new Set()))
  }
  // Start collecting dependencies: place effect in dep, update the deps property in effect, and place DEP in effect.deps, which describes the dependencies of the current reactive object
  if(! dep.has(effect)) { dep.add(effect) effect.deps.push(dep)// In the development environment, trigger the corresponding hook function
    if (__DEV__ && effect.options.onTrack) {
      effect.options.onTrack({
        effect,
        target,
        type,
        key
      })
    }
  }
}
Copy the code

The above code basically confirms our conjecture, but there are a few things we may not understand:

  • effectStackWhat structure is it? Why is it fromeffectStackThe top of stackeffectStack[effectStack.length - 1]What you get is exactly the reactive object that describes the dependencies you currently need to collecteffect?
  • effectWhat is the structure of, and where is it initialized?
  • Collected dependenciesdepsAnd how does the corresponding reactive object update a dependent reactive object when the corresponding reactive object is updated?

In view of the above three possible questions, return to the source code of the Effect module to find the answer:

Look at the structure of effect

First, let’s look at the structure of effectStack and effect:

export interface ReactiveEffect<T = any> {
  (): T // ReactiveEffect is a function type with an empty argument list and a return value of type T
  _isEffect: true // Identify it as effect
  active: boolean // Active is the effect activated switch. If turned on, dependencies will be collected. If turned off, dependencies will be collected
  raw: (a)= > T // The original listener function
  deps: Array<Dep> // Store dependent deps
  options: ReactiveEffectOptions // Related options
}

export interfaceReactiveEffectOptions { lazy? :boolean // Delay calculation identifiercomputed? :boolean // Is a listener function for computed dependencyscheduler? :(run: Function) = > void // A custom dependency collection function, usually used when introducing @vue/reactivity externallyonTrack? :(event: DebuggerEvent) = > void // The related hook function used for local debuggingonTrigger? :(event: DebuggerEvent) = > void // The related hook function used for local debuggingonStop? :(a)= > void // The related hook function used for local debugging
}

// To check whether a function is effect, just check _isEffect
export function isEffect(fn: any) :fn is ReactiveEffect {
  returnfn ! =null && fn._isEffect === true
}
Copy the code

As you can see from the above code, Effect is a function with properties mounted to describe its dependencies and states. The raw function is used to save the original listener function, and effect is also used to collect dependencies when it is called.

Export const effectStack: ReactiveEffect[] = [] export function effect<T = any>(fn: () => T, options: ReactiveEffectOptions = EMPTY_OBJ ): ReactiveEffect<T> {// fn is already an effect function, If (isEffect(fn)) {fn = fn.raw} const effect = createReactiveEffect(fn, Options) // If not delayed, call effect immediately to collect dependent on if (! Options.lazy) {effect()} return effect} export function stop(effect: ReactiveEffect) {// if (effect.active) {// cleanup all dependencies on effect; Call the hook function if (effect.options.onstop) {effect.options.onstop ()} // Active marked as false, Function createReactiveEffect<T = any>(fn: () => T, options: ReactiveEffectOptions ): ReactiveEffect<T> {const effect = function ReactiveEffect (... args: unknown[]): Unknown {return run(effect, fn, args)} as ReactiveEffect _isEffect = true effect.active = true effect.raw = fn effect.deps = [] effect.options = options Function run(effect: ReactiveEffect, fn: function, args: unknown[]): Unknown {// When active is false, call the original listener if (! effect.active) { return fn(... Args)} // If effect is not currently in the effectStack, start collecting dependencies on if (! Effectstack.includes (effect)) {effectStack.includes(effect) {effectStack.includes(effect)) { Effectstack.push (effect) // Call the original function. If the original function evaluates a responsive object, the getter of the responsive object is fired, and track is called in its getter, collecting data that depends on the return fn(... Args)} finally {effectstack.pop ()}} // cleanup the dependent method, walk through deps, and cleanup the function cleanup(effect: ReactiveEffect) { const { deps } = effect if (deps.length) { for (let i = 0; i < deps.length; i++) { deps[i].delete(effect) } deps.length = 0 } }Copy the code

The above code is pretty straightforward. Effect is called once when a listener is created, and as long as effect is active, dependency collection is triggered. The core of dependency collection is that when the original listener function is called here, if the original function values a reactive object, the getter for that reactive object is fired, and track is called in its getter.

Use the following code to understand track:

  • intrackWhen,effectStackThe top of the stack is the current oneeffectIs executed before calling the original listener functioneffectStack.push(effect)At the end of the call, it executeseffectStack.pop()Out of the stack.
  • effect.activeforfalseWill lead toeffectStack.length === 0, there is no need to collect dependenciestrackThis judgment is made at the beginning of the function call.
  • judgeeffectStack.includes(effect)The purpose is to avoid circular dependencies: imagine the following listener, while listening, recursively calling the original listener to modify the dependent data, if not judgedeffectStack.includes(effect).effectStackIt’s going to be the sameeffectPut it on the stack, incrementeffectStack.includes(effect)This type of situation is avoided.
const counter = reactive({ num: 0 });
const numSpy = (a)= > {
  counter.num++;
  if (counter.num < 10) {
    numSpy();
  }
}
effect(numSpy);
Copy the code

trigger

Through the analysis of effect and track above, we have basically understood the process of dependent collection. For the understanding of the whole effect module, only trigger is missing. Since track is used to collect dependencies, it is easy for us to know that trigger is a method to notify the change of responsive data dependent on it after the change of responsive data. By reading trigger, we can answer the above question: how do the collected dependent DEPs update the responsive objects with dependencies when they are updated?

Trigger:

export function trigger(
  target: object,
  type: OperationTypes, key? : unknown, extraInfo? : DebuggerEventExtraInfo) {
  // From the original object, map to the corresponding dependency depsMap
  const depsMap = targetMap.get(target)
  // If the object has no dependencies, return it directly. Not triggering updates
  if (depsMap === void 0) {
    // never been tracked
    return
  }
  / / set the effects
  const effects = new Set<ReactiveEffect>()
  // Effects collection for comptuted
  const computedRunners = new Set<ReactiveEffect>()
  // If you want to clear the entire set of data, that is, every item in the set will change, and call addRunners to queue up the dependencies that need to be updated
  if (type === OperationTypes.CLEAR) {
    // collection being cleared, trigger all effects for target
    depsMap.forEach(dep= > {
      addRunners(effects, computedRunners, dep)
    })
  } else {
    / / SET | ADD | DELETE three operations are for a responsive object attribute, only need to inform depend on the status update of an attribute
    // schedule runs for SET | ADD | DELETE
    if(key ! = =void 0) {
      addRunners(effects, computedRunners, depsMap.get(key))
    }
    // In addition, for additions and deletions, there are updates to the data that depend on iteration identifiers for reactive objects
    // also run for iteration key on ADD | DELETE
    if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
      // The array is length and the object is ITERATE_KEY
      // Why is length treated separately? The reason is that when we call push/pop/delete/add on an array or Set, we do not trigger the Set corresponding to the array subscript. Instead, we do this by hijacking length and ITERATE_KEY changes
      // Update the length or ITERATE_KEY dependencies to ensure that push/pop/delete/add is notified of the status update of the dependent responsive data
      const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
      // Update the data based on the iteration identifier of the reactive object
      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.
  // Update
  // The effect of the calculated property must be performed first, because normal reactive properties may depend on the calculated property data
  computedRunners.forEach(run)
  // Execute normal listener function
  effects.forEach(run)
}

// Add effect to the execution queue
function addRunners(
  effects: Set<ReactiveEffect>,
  computedRunners: Set<ReactiveEffect>,
  effectsToAdd: Set<ReactiveEffect> | undefined
) {
  // effectsToAdd is all the dependencies
  if(effectsToAdd ! = =void 0) {
    // Place an effect dependency on the execution queue
    effectsToAdd.forEach(effect= > {
      // Treat computed objects separately. Computed is a separate queue
      if (effect.options.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
}

// Triggers all dependency updates
function scheduleRun(
  effect: ReactiveEffect,
  target: object,
  type: OperationTypes, key: unknown, extraInfo? : DebuggerEventExtraInfo) {
  // In the development environment, trigger the corresponding hook function
  if (__DEV__ && effect.options.onTrigger) {
    const event: DebuggerEvent = {
      effect,
      target,
      key,
      type
    }
    effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event)
  }
  // Call effect, that is, listen on the function, to update
  if(effect.options.scheduler ! = =void 0) {
    effect.options.scheduler(effect)
  } else {
    effect()
  }
}
Copy the code

The code above is also well understood from the comments. Trigger is a notification to the dependent data when a reactive property is updated. Two queues, Effects and computedRunners, are maintained inside trigger, which are the dependency update queues of common attributes and calculated attributes respectively. When trigger is invoked, Vue will find the dependency corresponding to the updated attributes and then put the effect to be updated into the execution queue. The execution queue is of type Set, which ensures that the same effect will not be called twice. After the dependency lookup is done, effects and computedRunners are traversed and scheduleRun is called for updates.

summary

This paper describes the principle of effect module. Starting from track, we know the structure of Effect and the dePS attribute inside effect, which is an array used to store the dependency of the listening function. When a reactive object is initialized, track will be called by getter to collect dependencies. When its attributes are changed, deleted or added, trigger will be called to update dependencies, completing data notification and response.