Vue3 source code analysis - response principle

preface

Vue3 rewrites the principle of response in Vue2 using Proxy. In this paper, we will analyze the source code of vue3 response.

Vue3 provides four ways to create different types of responsive data:

  • Reactive returns a proxy Object. If the property of the returned proxy Object is an Object type, it will continue to call reactive for deep recursion
  • shallowReactive returns a proxy object, but only the properties of the first layer are responsive
  • Readonly returns a proxy object. If the property is an object, you can continue to call readonly, but the property is read-only and cannot be modified. Therefore, dependency collection will not be carried out in the access phase
  • shallowReadonly returns a proxy object. Only the properties of the first layer are responsive. The properties are read-only and cannot be modified. Due to the length problem, we only analyze the response of reactive type. Other processes are similar to this, but enter different processing processes according to different parameters.

Comparison of Vue2 and Vue3

Vue3 responsive source code analysis

Responsive process

In Vue3, we can create a responsive object through Composition API instead of Options API

setup(){
    const orginData = {count: 1}
    const state = reactive(orginData) 
    return {
      state
    }
  }

When we use the reactive function to create a responsive data, the process is as follows:

Next, we analyze the key functions involved.

Source code analysis

reactive

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
export function reactive(target: object) {
 // if trying to observe a readonly proxy, return the readonly version.
 // If a read-only type of responsive object is passed in, the responsive object is returned directly
 if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
   return target
 }
 return createReactiveObject(
   target,
   false,
   mutableHandlers,
   mutableCollectionHandlers
 )
}

First, judge the type of the passed in parameters. If the passed in is a read-only responsive object, return the object directly. Otherwise, enter the createReactiveObject function. The main function of this function is to create responsive objects of different types according to the passed in parameters. Next, let's analyze the creation process of createReactiveObject in detail:

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    return target
  }
  // If it is already a proxy object, it will be returned directly. With one exception, if readOnly acts on the response, it will continue
  if (
    target[ReactiveFlags.RAW] && 
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE]) 
  ) {
    return target
  }
  const proxyMap = isReadonly ? readonlyMap : reactiveMap
  // There is already a corresponding proxy mapping
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  const targetType = getTargetType(target)
  // Only data types in the whitelist can be used as response types
  if (targetType === TargetType.INVALID) {
    return target
  }
  // Hijack the target object through the Proxy API and turn it into a responsive object
  const proxy = new Proxy(
    target,
    // Map Set WeakMap WeakSet is represented by collectionhandlers Object Array is represented by baseHandlers
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  // Stores a mapping between the original type and the proxy data type 
  proxyMap.set(target, proxy)
  return proxy
}
  • First, judge whether it is an object type. If not, return directly. Otherwise, continue
  • Judge that if the target object is already a proxy, it will be returned directly, otherwise continue
  • Then get the ProxyMap of the current target object. If it exists, return the corresponding proxy (at the end of the function, we will use an object of WeakMap type to store the mapping of the original data type and proxy data type). Otherwise, continue
  • Judge whether the target object is in the white list of responsive data types. If not, return the target object directly, otherwise continue.
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

This function is preceded by a restriction judgment function for the target type. If the responsive data types are the above six, they will be in the white list that can be processed responsively, otherwise the type of the target object is invalid.

  • Finally, different processing functions are passed in according to different target Object types. If the target Object is Object and Array data types, the corresponding processing function of TargetType=COMMON is baseHandlers. If the target Object is Map, Set, WeakMap and WeakSet, the corresponding processing function is collectionHandlers. Represented by a flow chart:

The above is the analysis of creating responsive functions. Next, we will analyze the processing flow of using baseHandlers for Object data types.

Corresponding source file: Vue next / packages / reactivity / SRC / reactive ts

baseHandlers

const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )

In the previous process, we analyzed that if the target objects are Object and Array, baseHandlers will be called. We pass parameters to createReactiveObject through the reactive function. In fact, we execute mutableHandlers.

mutableHandlers

This method is actually hijacking some operations of accessing, deleting, querying and setting the target object. Here, we focus on some set and get functions, because these two functions involve operations that rely on collecting and distributing updates. During analysis, we will delete part of the code and only analyze the main process.

const get = /*#__PURE__*/ createGetter()
const set = /*#__PURE__*/ createSetter()
export const mutableHandlers: ProxyHandler<object> = {
  get, // Intercept the read attribute of data, including target Syntax and target []
  set, // Intercept the storage attribute of data
  deleteProperty, // Intercept the delete operator to listen to the deletion operation of the attribute
  has, // Attribute interception of the in operator of the object
  ownKeys // The ownKeys function will be triggered when accessing the object property name
}

createGetter

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // evaluation 
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      // Dependency collection
      track(target, TrackOpTypes.GET, key)
    }
    // Recursive call response
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }
    // Return results
    return res
  }
}

When we access the properties of the object, we will trigger the get function. There are three main steps in this function. First, we will use reflect Get evaluates, and then determines whether it is read-only. If not, call track for dependency collection, and then judge the evaluation result. If it is an object, recursively call reactive or readonly to continue the responsive processing of the result, and finally return the obtained result.

Note: the processing method here is different from that of Vue2 response, which is also a point of Vue3 response performance optimization during initialization.

  • When Vue2 implements a response, it will judge whether the Object attribute is of Object type in the initialization stage. If so, it will recursively call Observer to turn the sub Object into a response.
  • The implementation process of Vue3 is to respond only to the attributes of the first layer in the initialization stage. When the attributes returned to the proxy are accessed and are objects, the recursive response is performed. The proxy hijacks the object itself and cannot hijack the changes of sub objects. It is this feature that can delay the implementation of sub object response, The performance will also be improved during initialization.

createSetter

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    // 1. Get oldValue first
    const oldValue = (target as any)[key]
    // 2. Set new value
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    // Distribute updates
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

When we update the properties of the responsive object, the set function will be triggered. The main internal steps of the set function are also three. First, get the oldValue of the property, and then use reflect Set assigns an attribute to the attribute, and finally calls trigger to update the distribution. If the new attribute is added to the update stage, the type of trigger is add, if value = = = oldValue, then the trigger type is set.

Source code file: Vue next / packages / reactivity / SRC / basehandlers ts

track - dependency collection

Before analyzing the dependency collection process, we need to understand a concept targetMap, which is a data structure of WeakMap, which is mainly used to store a mapping relationship such as original data - > key - > DEPs, such as:

const orginData = {count:1,number:0}
    const state = reactive(orginData) 
    const ef1 = effect(() => {
      console.log('ef1:',state.count)
    })
    const ef2 = effect(() => {
      console.log('ef2:',state.number)
    })
    const ef3 = effect(() => {
      console.log('ef3:', state.count)
    })
    state.number = 2

Firstly, orginData is stored as the key of targetMap. Value is a depsMap, which is a Map data structure used to store all dependencies on the original data. The key in depsMap is the key corresponding to the original data, and value is deps. It is a Set data structure used to store all dependencies on the key. We use a graph to represent the above dependency mapping relationship:

After sorting out the above correspondence, let's analyze the internal implementation mechanism of track:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // Gets the depsMap corresponding to the current target object
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // Get dep dependency corresponding to current key
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    // Collect the current effect as a dependency
    dep.add(activeEffect)
    // The current effect collects dep sets as dependencies
    activeEffect.deps.push(dep)
  }
}

The dependency collection process is very simple. First, obtain the dependency Map corresponding to the current target. If not, take the current target as the key and Set one as the value in the Map data structure. Then, obtain the dependency Set corresponding to the current key according to depsMap. If not, Set one with the current key as the key and Set data structure as the value, Then judge whether the currently activated side effect function is not in the dependency Set corresponding to the current key. If not, push the currently activated side effect function (activeeffect) into the dependency Set corresponding to the current key. After understanding the correspondence of the dependency mapping table, the process of analyzing dependency collection is much simpler.

trigger - distribute updates

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // Gets the dependency mapping table of the current target
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    return
  }
  // Declare a collection and method to add the dependent collection corresponding to the current key
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => effects.add(effect))
    }
  }
 // Select different ways to add the dependency of the current key to effects according to different types
 if (type === TriggerOpTypes.CLEAR) {
    ...Judgment logic omission
  }
  // Declare a scheduling method
  const run = (effect: ReactiveEffect) => {
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }
  // Loop traversal runs the corresponding dependencies according to a certain scheduling method
  effects.forEach(run)
}

When the response is that the attribute value of the object changes, the set function will be triggered. When setting a new value for the attribute, trigger will be called to distribute the update. The logic of distributing the update is also very simple:

  • First, get the dependency mapping table corresponding to the current target. If not, it means that the target has no dependency and returns directly. Otherwise, proceed to the next step
  • Then declare a collection and a method to add elements to the collection
  • You can add the dependency corresponding to the current key to effects in different ways according to different types
  • Declare a scheduling method, and use different scheduling methods according to different parameters in the effect function
  • Loop through all dependent sets of the current key and run according to the corresponding scheduling method

Effect - side effect function

In the above example code, we can open the console and find:

When we pass an original function to the effect, it will be executed immediately. If there is access to responsive data in the function, the current effect will be collected into the dependency collection of access attributes as dependencies. So what is the internal operation mechanism of effect? Let's analyze it in detail.

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}

Through the above source code, we can see that effect acts as a bridge in response. Its main function is to wrap the original function and turn it into a side-effect function of response. It mainly accepts two parameters. The first parameter is the function passed above, and the second parameter is the configuration parameter, which is used to control the scheduling behavior of the first parameter. First, judge whether the current function is already a side effect function. If so, the original function of the current response function will be obtained. Then pass the parameter to createReactiveEffect to create a reactive side effect function. Finally, execute the returned reactive side effect function. Let's take a look at how the createReactiveEffect function creates a reactive side effect function:

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined // Used to save the currently active side effect function
function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  const effect = function reactiveEffect(): unknown {
    if (!effectStack.includes(effect)) {
      cleanup(effect)
      try {
        // Enable global shouldtrack to allow dependency collection
        enableTracking()
        // Stack the current effect
        effectStack.push(effect)
        activeEffect = effect
        // Execute the original function
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
  • First, we declare an effectStack stack at the beginning to store the activated effects and activeEffect to store the currently activated effects.
  • Then judge whether the current effect is in the stack. If not, execute cleanup to clear all dependencies of the current responsive function.
  • Then press the current effect into the stack, assign the current effect to activeEffect, and execute the original function passed in. After execution, pop the current effect from the effectStack stack, and change the value of activeEffect to the last one in the stack.
  • Finally, add some attributes raw to the effect function to save the original function of the current side effect function_ Whether isEffect is a side effect function, and the properties subscribed in deps side effect function. Why use a stack to store the active side-effect functions? Wouldn't it be better to use a variable directly to save the current effect? Let's take a look at the following example:
const parent = effect(() => {
     const child=  effect(() => {
        console.log('child:', state.count)
      })
      console.log('parent:', state.number)
    })
    state.count = 2

When a side-effect function is nested with a side-effect function, when we execute the internal side-effect function, if we only use a variable to save the current active side-effect function, after the internal side-effect function is executed, when we execute the code below, the activeEffect point is incorrect, Therefore, we use a stack structure to save the activated side-effect function, because the execution of the function is also a sequence of out of the stack and into the stack, so we design a stack to save the activated side-effect function. When we execute the side-effect function nested inside the side-effect function, the activeEffect is the side-effect function nested inside, After the internal side-effect function is executed, pop up the internal side-effect function from the effectStack stack. At this time, the activeEffect points to the external side-effect function, which is correct.

Corresponding source file: Vue next / packages / reactivity / SRC / effect ts

summary

So far, the creation process of an Object type of responsive data has been analyzed. In the process of analyzing the source code, we can find that:

  • Functional programming is adopted, and the implementation process is very clear when analyzing the function of each function
  • The delay recursive response is adopted for the of sub objects, which improves the performance in the initialization stage
  • Using the WeakMap data structure to establish a weak mapping between the original data and the dependent set is conducive to garbage collection

Added by Drumminxx on Thu, 20 Jan 2022 11:33:40 +0200