Vue3 reading source code series (four): responsive principle (reactive, ref)

In the last chapter, we know how components are mounted and rendered, but one question remains: How is the responsive data collected as an effect object? In this chapter, we start with two APIs for declaring responsive data: reactive and ref:

reactive

// packages/reactivity/src/reactive.ts
export function reactive(target: object) {<!-- -->
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {<!-- -->
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers, // handlers for common objects
    mutableCollectionHandlers, // handlers for collection objects
    reactiveMap
  )
}

The actual call is createReactiveObject

createReactiveObject

// Create a responsive object
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {<!-- -->
  if (!isObject(target)) {<!-- --> // Determine whether it is an object type
    if (__DEV__) {<!-- -->
      console.warn(`value cannot be made reactive: ${<!-- -->String(target)}`)
    }
    return target
  }
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  // If it has been proxied, return directly
  if (
    target[ReactiveFlags.RAW] & amp; & amp;
    !(isReadonly & amp; & amp; target[ReactiveFlags.IS_REACTIVE])
  ) {<!-- -->
    return target
  }
  // target already has corresponding Proxy
  const existingProxy = proxyMap. get(target)
  // If the proxy object already exists, return directly
  if (existingProxy) {<!-- -->
    return existingProxy
  }
  // only specific value types can be observed.
  const targetType = getTargetType(target)
  // Only specific value types can be observed, and return directly if they do not match
  if (targetType === TargetType.INVALID) {<!-- -->
    return target
  }
  // create proxy object
  const proxy = new Proxy(
    target,
    // If the type is a collection, use the collection processor, otherwise use the basic processor (collection type: Map Set WeakMap WeakSet)
    targetType === TargetType. COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

Proxy is used here to proxy the target, and the handler is judged according to the incoming type. We generally use baseHandlers, which are actually incoming mutableHandlers. PS: If you are not familiar with Proxy API, please get familiar with it first

export const mutableHandlers: ProxyHandler<object> = {<!-- -->
  get,
  set,
  deleteProperty,
  has, // handle in operator
  ownKeys // handle Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertySymbols(), for...in
}

We mainly look at get and set handlers, which are the most commonly used proxy executions triggered when we get and set values. They are created by createGetter and createSetter respectively.

createGetter

// packages/reactivity/src/baseHandlers.ts
// create get handler
function createGetter(isReadonly = false, shallow = false) {<!-- -->
  return function get(target: Target, key: string | symbol, receiver: object) {<!-- -->
    // some boundary handling
    ...
    // Whether target is an array
    const targetIsArray = isArray(target)

    if (!isReadonly) {<!-- -->
      if (targetIsArray & amp; & amp; hasOwn(arrayInstrumentations, key)) {<!-- -->
        // Special handling of arrays arrayInstrumentations is the rewritten array method object
        return Reflect.get(arrayInstrumentations, key, receiver)
      }
      if (key === 'hasOwnProperty') {<!-- -->
        return hasOwnProperty
      }
    }
    // return value processing
    const res = Reflect. get(target, key, receiver)

    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {<!-- -->
      return res
    }

    if (!isReadonly) {<!-- -->
      // capture dependencies
      track(target, TrackOpTypes. GET, key)
    }

    if (shallow) {<!-- -->
      return res
    }

    if (isRef(res)) {<!-- -->
      // ref unwrapping - skip unwrap for Array + integer key.
      return targetIsArray & amp; & amp; isIntegerKey(key) ? res : res.value
    }

    if (isObject(res)) {<!-- -->
      // When the return value is an object, execute reactive (recursive operation)
      return isReadonly? readonly(res) : reactive(res)
    }

    return res
  }
}

The core here is the execution of the track function, which will perform the collection effect operation. Let’s look at the specific implementation:

track
// packages/reactivity/src/effect.ts
export function track(target: object, type: TrackOpTypes, key: unknown) {<!-- -->
  if (shouldTrack & amp; & amp; activeEffect) {<!-- -->
    // targetMap is a WeakMap instance
    let depsMap = targetMap. get(target)
    if (!depsMap) {<!-- -->
      // If there is no depsMap, set target as the key value of targetMap and the value is a Map
      targetMap.set(target, (depsMap = new Map()))
    }
    // get dep
    let dep = depsMap. get(key)
    if (!dep) {<!-- -->
      // If there is no dep, set the key to be the key of depsMap. The value is a Set collection. Here is the effect collection collected by the key.
      depsMap.set(key, (dep = createDep()))
    }

    const eventInfo = __DEV__
      ? {<!-- --> effect: activeEffect, target, type, key }
      : undefined
    // Collect effects into dep
    trackEffects(dep, eventInfo)
  }
}
trackEffects
export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {<!-- -->
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {<!-- -->
    if (!newTracked(dep)) {<!-- -->
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {<!-- -->
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }
  // only track it if it's not already being tracked
  if (shouldTrack) {<!-- -->
    // Collect effect side effects
    dep. add(activeEffect!)
    // effect collects dep for cleanup
    activeEffect!.deps.push(dep)
    if (__DEV__ & amp; & amp; activeEffect!.onTrack) {<!-- -->
      activeEffect!.onTrack(
        extend(
          {<!-- -->
            effect: activeEffect!
          },
          debuggerEventExtraInfo!
        )
      )
    }
  }
}

The logic here is also very simple. The only doubt may be where the activeEffect comes from. In fact, activeEffect is the instance we declared using the ReactiveEffect class. When we execute effect.run, it will assign activeEffect to effect. Let’s take a look at the specific implementation

class ReactiveEffect {<!-- -->
  ...
  run() {<!-- -->
    if (!this.active) {<!-- -->
      return this.fn()
    }
    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack
    while (parent) {<!-- -->
      if (parent === this) {<!-- -->
        return
      }
      parent = parent.parent
    }
    try {<!-- -->
      // record activeEffect
      this.parent = activeEffect
      // Assign activeEffect to this where this points to the instance, which is the effect we instantiated
      activeEffect = this
      // Set shouldTrack to true before executing fn
      shouldTrack = true

      trackOpBit = 1 << + + effectTrackDepth

      if (effectTrackDepth <= maxMarkerBits) {<!-- -->
        initDepMarkers(this)
      } else {<!-- -->
        cleanupEffect(this)
      }
      return this.fn()
    } finally {<!-- -->
      if (effectTrackDepth <= maxMarkerBits) {<!-- -->
        finalizeDepMarkers(this)
      }

      trackOpBit = 1 << --effectTrackDepth
      // activeEffect is reassigned to this.parent here to deal with the nesting of effects with the operation in the try code block
      activeEffect = this. parent
      shouldTrack = lastShouldTrack
      this.parent = undefined

      if (this. deferStop) {<!-- -->
        this. stop()
      }
    }
  }
  ...
}

So far we already know how the responsive data is collected as an effect object. When we execute the function update function, execute render, trigger the get of responsive data, and then the dep corresponding to the corresponding key will collect the current effect .
The relationship between responsive objects and dep in data structure: Vue3 uses a targetMap global WeakMap instance to store, its key is our target, and its value is a Map instance. The key of the changed Map instance is the key of the target, and the value is corresponding The effect collection (Set) collected by the key.
The above is the collection process, let’s look at the trigger process:

createSetter

function createSetter(shallow = false) {<!-- -->
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {<!-- -->
    // Get the old value first
    let oldValue = (target as any)[key]
    if (isReadonly(oldValue) & amp; & amp; isRef(oldValue) & amp; & amp; !isRef(value)) {<!-- -->
      return false
    }
    if (!shallow) {<!-- -->
      if (!isShallow(value) & amp; & amp; !isReadonly(value)) {<!-- -->
        oldValue = toRaw(oldValue)
        value = toRaw(value)
      }
      if (!isArray(target) & amp; & amp; isRef(oldValue) & amp; & amp; !isRef(value)) {<!-- -->
        oldValue. value = value
        return true
      }
    } else {<!-- -->
      // in shallow mode, objects are set as-is regardless of reactive or not
    }
    const hadKey =
      isArray(target) & amp; & amp; isIntegerKey(key)
        ?Number(key) < target.length
        : hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    // don't fire if target is something in the prototype chain
    if (target === toRaw(receiver)) {<!-- -->
      if (!hadKey) {<!-- -->
        // trigger add without key
        trigger(target, TriggerOpTypes. ADD, key, value)
      } else if (hasChanged(value, oldValue)) {<!-- -->
        // Trigger set when there is a key
        trigger(target, TriggerOpTypes. SET, key, value, oldValue)
      }
    }
    return result
  }
}

Eventually the trigger will be triggered:

trigger
// trigger effect function
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {<!-- -->
  // get depsMap
  const depsMap = targetMap. get(target)
  if (!depsMap) {<!-- -->
    // never been tracked
    return
  }
  // Create an array of deps to execute
  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes. CLEAR) {<!-- -->
    // collection being cleared
    // trigger all effects for target
    // Trigger the effect function corresponding to all keys when the array or map is cleared
    deps = [...depsMap. values()]
  } else if (key === 'length' & amp; & amp; isArray(target)) {<!-- -->
    const newLength = Number(newValue)
    depsMap.forEach((dep, key) => {<!-- -->
      if (key === 'length' || key >= newLength) {<!-- -->
        deps.push(dep)
      }
    })
  } else {<!-- -->
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {<!-- -->
      // set add delete operation Add the effect function corresponding to the key to the deps array
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE |
    // Push the corresponding dep according to different operations
    switch (type) {<!-- -->
      case TriggerOpTypes. ADD:
        if (!isArray(target)) {<!-- -->
          // Add the effect function of the loop operation to the deps array
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {<!-- -->
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {<!-- -->
          // new index added to array -> length changes
          deps.push(depsMap.get('length'))
        }
        break
      case TriggerOpTypes. DELETE:
        if (!isArray(target)) {<!-- -->
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {<!-- -->
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {<!-- -->
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

  const eventInfo = __DEV__
    ? {<!-- --> target, type, key, newValue, oldValue, oldTarget }
    : undefined
  // Execute triggerEffects Execute all effects in dep
  if (deps. length === 1) {<!-- -->
    if (deps[0]) {<!-- -->
      if (__DEV__) {<!-- -->
        triggerEffects(deps[0], eventInfo)
      } else {<!-- -->
        triggerEffects(deps[0])
      }
    }
  } else {<!-- -->
    const effects: ReactiveEffect[] = []
    for (const dep of deps) {<!-- -->
      if (dep) {<!-- -->
        effects. push(...dep)
      }
    }
    if (__DEV__) {<!-- -->
      triggerEffects(createDep(effects), eventInfo)
    } else {<!-- -->
      triggerEffects(createDep(effects))
    }
  }
}
triggerEffects
export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {<!-- -->
  // spread into array for stabilization
  const effects = isArray(dep) ? dep : [...dep]
  // The computed effect will be executed first
  // Prevent render from obtaining computed values when _dirty has not been set to true
  for (const effect of effects) {<!-- -->
    if (effect.computed) {<!-- -->
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  for (const effect of effects) {<!-- -->
    if (!effect.computed) {<!-- -->
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
}
// execute effect
function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {<!-- -->
  if (effect !== activeEffect || effect.allowRecurse) {<!-- -->
    if (__DEV__ & amp; & amp; effect.onTrigger) {<!-- -->
      effect.onTrigger(extend({<!-- --> effect }, debuggerEventExtraInfo))
    }
    // If the effect has a scheduler, execute the scheduler, otherwise execute the run and eventually execute the function update function to update
    if (effect.scheduler) {<!-- -->
      effect. scheduler()
    } else {<!-- -->
      effect. run()
    }
  }
}

The component’s scheduler uses queueJob, which uses an asynchronous method to optimize the performance of multiple changes in responsive data triggering multiple function update function executions in a synchronous update, so that the function update function is only executed once. There is no specific development here. Those who are interested can see the specific source code implementation, which is not complicated.

ref

ref is aimed at the responsive processing of a single value, which is simpler and does not have the problem of loss of reactive responsiveness. Let’s see its specific implementation:

// packages/reactivity/src/ref.ts
export function ref(value?: unknown) {<!-- -->
  // create ref
  return createRef(value, false)
}
function createRef(rawValue: unknown, shallow: boolean) {<!-- -->
  if (isRef(rawValue)) {<!-- -->
    return rawValue
  }
  // Instantiate the RefImpl class and return
  return new RefImpl(rawValue, shallow)
}
class RefImpl<T> {<!-- -->
  private_value: T
  private_rawValue: T
  // dependency collection
  public dep?: Dep = undefined
  public readonly __v_isRef = true
  // Constructor
  constructor(value: T, public readonly __v_isShallow: boolean) {<!-- -->
    this._rawValue = __v_isShallow? value : toRaw(value)
    // value can also be a complex data type, it will execute reactive API to make it responsive
    this._value = __v_isShallow ? value : toReactive(value)
  }

  get value() {<!-- -->
    // collect dependencies
    trackRefValue(this)
    return this._value
  }

  set value(newVal) {<!-- -->
    const useDirectValue =
      this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
    newVal = useDirectValue ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {<!-- -->
      this._rawValue = newVal
      this._value = useDirectValue ? newVal : toReactive(newVal)
      // Trigger dependencies
      triggerRefValue(this, newVal)
    }
  }
}

You can see that ref returns a RefImpl instance, which uses the get set accessor to collect dependencies in get and trigger dependencies in set. The difference from reactive is that the dependency collection of ref is stored in its own dep attribute, not globally The targetMap object. Next, look at the specific trackRefValue and triggerRefValue operations:

trackRefValue

// packages/reactivity/src/ref.ts
export function trackRefValue(ref: RefBase<any>) {<!-- -->
  if (shouldTrack & amp; & amp; activeEffect) {<!-- -->
    ref = toRaw(ref)
    if (__DEV__) {<!-- -->
      // call trackEffects
      trackEffects(ref.dep || (ref.dep = createDep()), {<!-- -->
        target: ref,
        type: TrackOpTypes. GET,
        key: 'value'
      })
    } else {<!-- -->
      trackEffects(ref.dep || (ref.dep = createDep()))
    }
  }
}

Finally, trackEffects is called. We also called this function during the reactive collection process. They are public.

triggerRefValue

export function triggerRefValue(ref: RefBase<any>, newVal?: any) {<!-- -->
  ref = toRaw(ref)
  const dep = ref.dep
  if (dep) {<!-- -->
    if (__DEV__) {<!-- -->
      // trigger the effect
      triggerEffects(dep, {<!-- -->
        target: ref,
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {<!-- -->
      triggerEffects(dep)
    }
  }
}

Here is also the dependency of reusing triggerEffects to execute collection