Before we begin, let’s briefly explain how reactive Proxies work.

The principle is briefly


Proxy is a new object in ES6. You can perform a layer Proxy for a specified object.

The objects we create through Proxy go through no more than two steps to meet our responsive requirements.

  1. collectwatchDependencies in functions.
  2. Change and call againwatchFunction.

How to collect dependencies

After the object is Proxy, we can add a layer of interception to read the object’s properties, usually in the form of:

const p = new Proxy({}, {
  get(target, key, receiver) {
    / / intercept}});Copy the code

In the get interception method, we can get the object itself target, the read property key, and the caller receiver (the P object), where we can get the currently accessed property key.

Normally we access the proxied object in a method:

function fn(){
  console.log(p.value);
}
fn();
Copy the code

When we execute the fn function, we will trigger our GET interception. We only need to record the function currently executed in the GET interception to establish a mapping of key => fn, and we can call the FN function again after the property value changes.

So the puzzle is how to perform our GET interception and still get which function called the proxy object.

In the Vue3 implementation, we use an effect function to wrap our own function.

effect(() = >{
  console.log(p.value)
})
Copy the code

To save the function that called the proxy object.

let activeEffect;

function effect(fn){
  activeEffect = fn;
  fn(); / / execution
  activeEffect = null;
}
// ...
get(target, key, receiver) {
  // Get intercepts access the global activeEffect, which is the currently invoked function
  // key => activeEffect
}
Copy the code

Another important point to note in get interception is that if the object we need to broker is an array, we will actually trigger the GET interception when we call most array methods such as push, POP, includes, etc., all of which access the length property of the array.

Trigger the Watch function

We will trigger the function for the saved key => fn map after the value changes. Set interception is triggered when a property value is set.

const p = new Proxy({}, {
  set(target, key, value, receiver) {
    // Retrieve the fn corresponding to the key to execute}});Copy the code

Other interception methods

In addition to get interception when we read properties, we need to collect dependencies in other operations to improve responsiveness.

  • has.inOperator interception.
  • ownKeys
    • interceptObject.getOwnPropertyNames().
    • interceptObject.getOwnPropertySymbols().
    • interceptObject.keys().
    • interceptReflect.ownKeys().

Remove the set interception that sets the property to fire the dependent function, and also need to fire when the property is deleted.

  • deletePropertyIntercepts when deleting properties.

In addition to proxies for ordinary objects and arrays, there is another difficulty in proxies for Map and Set objects.

The detailed implementation of the principle can be found in my previous links, but is not implemented in this article.

  1. How to implement a responsive object using Proxy
  2. How to use Proxy to intercept Map and Set operations

Next, enter the body part.

Source analyses

Vue3 is Monorepo, and the reactive package ReacitVity is a separate package.

Reactivity is inspired by these three packages and, as I happen to have read the observer-util source code, there are many clever improvements and enhancements to ReActivity compared to its predecessor.

  1. increasedshallowMode, only the first value is responsive.
  2. increasedreadonlyMode, does not collect dependencies, cannot be modified.
  3. increasedrefObject.

File structure

├ ─ ─ baseHandlers. Ts ├ ─ ─ collectionHandlers. Ts ├ ─ ─ computed. The ts ├ ─ ─ effect. The ts ├ ─ ─ but ts ├ ─ ─ operations. The ts ├ ─ ─ Reactive. Ts └ ─ ─ ref. TsCopy the code

BaseHandlers and collectionHandlers are the main implementation files of the function, which is the interceptor function corresponding to the Proxy object, and Effect is the observer function file.

This paper mainly analyzes these three parts.

Object data structure

The Target type is the original object that needs a Proxy, which defines four internal properties.

export interfaceTarget { [ReactiveFlags.SKIP]? :boolean[ReactiveFlags.IS_REACTIVE]? :boolean[ReactiveFlags.IS_READONLY]? :boolean[ReactiveFlags.RAW]? :any
}
Copy the code

TargetMap is a WeakMap of the dependency functions that are internally saved.

type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
Copy the code

Its key name is the raw object without Proxy responsiveness, and its value is Map of key => Set< dependent function >.

We get the Map of the current object’s key => Set< dependent functions > via targetMap, extract all dependent functions from the key, and then call them when the value changes.

The following four maps are mappings between the original Target object and reactive or Readonly object.

export const reactiveMap = new WeakMap<Target, any> ()export const shallowReactiveMap = new WeakMap<Target, any> ()export const readonlyMap = new WeakMap<Target, any> ()export const shallowReadonlyMap = new WeakMap<Target, any> ()Copy the code

baseHandlers

The baseHandlers file basically creates a Proxy interceptor function for ordinary objects, arrays.

Start with the collection-dependent GET interceptor.

get

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: Target, key: string | symbol, receiver: object) {
    // The Target internal key name is not stored on the object. Instead, get intercepts the return of the closure
    if (key === ReactiveFlags.IS_REACTIVE) {
      return! isReadonly }else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
            ? shallowReactiveMap
            : reactiveMap
        ).get(target)
    ) {
      // Target is the raw value, provided that the receiver is the same as the object in the raw => proxy
      return target
    }

    const targetIsArray = isArray(target)

    // Special handling for arrays
    if(! isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {return Reflect.get(arrayInstrumentations, key, receiver)
    }

    const res = Reflect.get(target, key, receiver)

    // Ignore the built-in symbol and non-trackable keys
    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    // readonly cannot be changed without tracing
    if(! isReadonly) {// Collect dependencies
      track(target, TrackOpTypes.GET, key)
    }

    Shallow returns the result directly and does not respond to the nested object
    if (shallow) {
      return res
    }

    // ref processing
    if (isRef(res)) {
      // ref unwrapping - does not apply for Array + integer key.
      constshouldUnwrap = ! targetIsArray || ! isIntegerKey(key)return shouldUnwrap ? res.value : res
    }

    // If the value is an object, delay converting the object
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}
Copy the code

In the GET interceptor, the first is a clever manipulation that returns the value of ReactiveFlags without actually assigning its value to the object, followed by special manipulation of arrays. The collection depends on the function track, defined in effect.ts, which we’ll look at later. If the value returned is an object, converting the object is delayed.

set

function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ) :boolean {
    let oldValue = (target as any)[key]
    if(! shallow) { value = toRaw(value) oldValue = toRaw(oldValue)// if the old value is ref, there is also set interception inside ref,
      if(! isArray(target) && isRef(oldValue) && ! isRef(value)) { oldValue.value = valuereturn true}}else {
      // in shallow mode, objects are set as-is regardless of reactive or not
    }

    // The array determines whether the index exists, and the object determines whether the key exists
    const hadKey =
      isArray(target) && 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
    if (target === toRaw(receiver)) {
      if(! hadKey) {/ / there is no key to ADD
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        / / a key SET
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}
Copy the code

The set interceptor mainly determines whether the set key exists, and then divides two parameters to trigger. Trigger function is the method to trigger and collect effect function, which is also defined in effect.ts, so it is not mentioned here.

ownKeys

function ownKeys(target: object) : (string | symbol) []{
  // For arrays, key is length; for objects, ITERATE_KEY is used only as a key identifier
  track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}
Copy the code

The ownKeys interceptor is also a collection dependency. Note that the key argument passed in is length (target) and ITERATE_KEY (object). ITERATE_KEY is only a symbol identifier. In the future, the corresponding effect function will be obtained by this value. In fact, there is no such key.

effect

As mentioned in the principle statement of this article, if we want to know which function called the object, we need to put the function into our own running function to call. In the actual code we wrap the function passed into the effect method with a new type of ReactiveEffect.

The data structure

export interface ReactiveEffect<T = any> {
  (): T
  _isEffect: true
  id: number
  active: boolean // Whether it is valid
  raw: () = > T // The original function
  deps: Array<Dep> // The Set that holds effect depending on the key
  options: ReactiveEffectOptions
  allowRecurse: boolean
}	
Copy the code

The most important field is deps. If we collect dependencies by executing this effectFn function, we get the following dependency structure:

{
  "key1": [effectFn] // Set
  "key2": [effectFn] // Set
}
Copy the code

So the deps property of our ReactiveEffect method effectFn is the Set corresponding to these two keys.

export function effect<T = any> (
  fn: () => T, // The function passed in
  options: ReactiveEffectOptions = EMPTY_OBJ
) :ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options) / / create ReactiveEffect
  if(! options.lazy) { effect()/ / ReactiveEffect execution
  }
  return effect
}
Copy the code

A ReactiveEffect is created with createReactiveEffect in the effect function.

function createReactiveEffect<T = any> (fn: () => T, options: ReactiveEffectOptions) :ReactiveEffect<T> {
  const effect = function reactiveEffect() :unknown {
    if(! effect.active) {return fn()
    }
    if(! effectStack.includes(effect)) { cleanup(effect)try {
        enableTracking()
        effectStack.push(effect)
        // Assign activeEffect to current effect
        activeEffect = effect
        // Execute the function. The corresponding interceptor can save the corresponding effect via activeEffect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        / / reset activeEffect
        activeEffect = effectStack[effectStack.length - 1]}}}asReactiveEffect effect.id = uid++ effect.allowRecurse = !! options.allowRecurse effect._isEffect =true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}
Copy the code

Before executing the effect function, save the function to the global variable activeEffect so that the interceptor can know which function is currently executing when collecting dependencies.

cleanup

The cleanup method cleans up dependencies.

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 structure of the dePS attribute is described above. It saves sets that depend on effectFn, iterates through them, and removes effectFn from all sets.

track

The track method collects dependencies and is very simple to add ActiveEffects to the Dep.

export function track(target: object.type: TrackOpTypes, key: unknown) {
  if(! shouldTrack || activeEffect ===undefined) {
    return
  }
  let depsMap = targetMap.get(target)
  // Target => Map
      ,dep>
  if(! depsMap) { targetMap.set(target, (depsMap =new Map()))}let dep = depsMap.get(key)
  // Set key 
      
  if(! dep) { depsMap.set(key, (dep =new Set()))}ActiveEffect does not exist in the Set corresponding to the current key
  if(! dep.has(activeEffect)) { dep.add(activeEffect)// add to Set
    activeEffect.deps.push(dep) // Add Effect deps as well
    if (__DEV__ && activeEffect.options.onTrack) {
      activeEffect.options.onTrack({
        effect: activeEffect,
        target,
        type,
        key
      })
    }
  }
}
Copy the code

Trigger

The trigger method performs a ReactiveEffect and makes some type judgments internally, such as triggeroptypes. CLEAR only exists on maps and sets.

export function trigger(
  target: object.type: TriggerOpTypes, key? : unknown, newValue? : unknown, oldValue? : unknown, oldTarget? :Map<unknown, unknown> | Set<unknown>
) {
  const depsMap = targetMap.get(target)
  if(! depsMap) {return
  }

  // Copy all effects that need to be executed into the Effects Set
  const effects = new Set<ReactiveEffect>()
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) = > {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect= > {
        if(effect ! == activeEffect || effect.allowRecurse) { effects.add(effect) } }) } }The CLEAR type is present in the collectionHandlers of Map and Set
  if (type === TriggerOpTypes.CLEAR) {
    // The first argument to the Map forEach is the value, that is, the key pair to the Dep Set
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) { / / array
    depsMap.forEach((dep, key) = > {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // key ! == undefined SET | ADD | DELETE
    if(key ! = =void 0) {
      // Add only the effect function for the current key
      add(depsMap.get(key))
    }

    / / ITERATE_KEY is a built-in identification variables ADD | DELETE | Map. The SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if(! isArray(target)) { add(depsMap.get(ITERATE_KEY))if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          // New index => length changes
          add(depsMap.get('length'))}break
      case TriggerOpTypes.DELETE:
        if(! isArray(target)) { add(depsMap.get(ITERATE_KEY))if (isMap(target)) {
            add(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          add(depsMap.get(ITERATE_KEY))
        }
        break}}const run = (effect: ReactiveEffect) = > {
    if (__DEV__ && effect.options.onTrigger) {
      effect.options.onTrigger({
        effect,
        target,
        key,
        type,
        newValue,
        oldValue,
        oldTarget
      })
    }
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }

  / / execution
  effects.forEach(run)
}
Copy the code

Special handling of arrays

Although Proxy is used, array methods need special handling to avoid boundary cases that do not override array methods.

includes, indexOf,lastIndexOf

The special processing of these three methods is to be able to determine whether responsive data exists at the same time.

  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ... args: unknown[]) {
    const arr = toRaw(this)
    for (let i = 0, l = this.length; i < l; i++) {
      // Collect each subscript as a dependency
      track(arr, TrackOpTypes.GET, i + ' ')}// Start with the current parameters
    const res = method.apply(arr, args)
    if (res === -1 || res === false) {
      // If there is no result, change the parameter to the RAW value before executing
      return method.apply(arr, args.map(toRaw))
    } else {
      return res
    }
  }
})
Copy the code

To ensure that both reactive and non-reactive values can be judged, it may be traversed twice.

Avoid circular dependencies

; (['push'.'pop'.'shift'.'unshift'.'splice'] as const).forEach(key= > {
  const method = Array.prototype[key] as any
  arrayInstrumentations[key] = function(this: unknown[], ... args: unknown[]) {
    pauseTracking()
    const res = method.apply(this, args)
    resetTracking()
    return res
  }
})
Copy the code

Array methods generally rely implicitly on lengh attributes, and in some cases may have circular dependencies (#2137).

conclusion

The above is the source analysis of Vue 3 responsive object and array interception. This paper only briefly analyzes the important interceptors in baseHandlers, and will bring the analysis of collectionHandlers later.