Red Huang

Red Huang

What exactly does Vue 3.0 reactive and effect do?

What Does Vue 3.0 Reactive and Effect Actually Do#


After the release of vue-next (vue 3.0), everyone is very curious about the source code, and since Vue 3.0 has switched to the Composition API, the most curious aspect should still be how Vue 3.0 has utilized the Proxy API. Therefore, the main focus of this article will be on what Vue 3.0 reactive actually does.

First Look at Test Case#

Since Vue 3.0 has switched to the Composition API, it is strongly recommended to read through the Composition API before diving deeper.
Looking at the unit testing code below, we can see that it sets { num: 0 } as reactive, and as long as the value of num is set, the fourth line () => (dummy = counter.num) will be called.

 it('should observe basic properties', () => {
    let dummy
    const counter = reactive({ num: 0 })
    effect(() => (dummy = counter.num))

    expect(dummy).toBe(0)
    counter.num = 7
    expect(dummy).toBe(7)
  }) 

Reactive Implementation#

We can see that the reactive function returns a reactive object.

 export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (readonlyToRaw.has(target)) {
    return target
  }
  // target is explicitly marked as readonly by user
  if (readonlyValues.has(target)) {
    return readonly(target)
  }
  return createReactiveObject(
    target,
    rawToReactive,
    reactiveToRaw,
    mutableHandlers,
    mutableCollectionHandlers
  )
} 

createReactiveObject first checks whether the target has already been observed or is itself a proxy. Next, it checks the target.constructor; if it is Set, Map, WeakMap, or WeakSet, it uses collectionHandlers; otherwise, it uses baseHandlers. In this test case, mutableHandlers will be used.

 function createReactiveObject(
  target: any,
  toProxy: WeakMap<any, any>,
  toRaw: WeakMap<any, any>,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  // target already has corresponding Proxy
  let observed = toProxy.get(target)
  if (observed !== void 0) {
    return observed
  }
  // target is already a Proxy
  if (toRaw.has(target)) {
    return target
  }
  // only a whitelist of value types can be observed.
  if (!canObserve(target)) {
    return target
  }
  const handlers = collectionTypes.has(target.constructor)
    ? collectionHandlers
    : baseHandlers
  observed = new Proxy(target, handlers)
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  if (!targetMap.has(target)) {
    targetMap.set(target, new Map())
  }
  return observed
} 

Next, we look at mutableHandlers. Based on the previous values, mutableHandlers must be a proxy handler, and according to the experience of vue2.x, getters are specifically used to collect dependencies, so we continue to look further.

 export const mutableHandlers: ProxyHandler<any> = {
  get: createGetter(false),
  set,
  deleteProperty,
  has,
  ownKeys
} 

Here we can see that under normal circumstances, it will go to the track function, and as before, if it detects that res is an Object, it will recursively perform reactive operations on it.

 function createGetter(isReadonly: boolean) {
  return function get(target: any, key: string | symbol, receiver: any) {
    const res = Reflect.get(target, key, receiver)
    if (typeof key === 'symbol' && builtInSymbols.has(key)) {
      return res
    }
    if (isRef(res)) {
      return res.value
    }
    track(target, OperationTypes.GET, key)
    return isObject(res)
      ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
          // circular dependency
          readonly(res)
        : reactive(res)
      : res
  }
} 

We can see that the track function has an operation similar to Vue 2.x's Dep.target stack, which is the activeReactiveEffectStack. Here, it retrieves the last effect to set up the targetMap. The targetMap is a WeakMap that stores all the deps in the target, and then pushes the effect onto a deps.

  • targetMap = target corresponding depsMap
  • depsMap = key corresponding deps
 export const targetMap: WeakMap<any, KeyToDepMap> = new WeakMap() 
 export function track(
  target: any,
  type: OperationTypes,
  key?: string | symbol
) {
  if (!shouldTrack) {
    return
  }
  const effect = activeReactiveEffectStack[activeReactiveEffectStack.length - 1]
  if (effect) {
    if (type === OperationTypes.ITERATE) {
      key = ITERATE_KEY
    }
    let depsMap = targetMap.get(target)
    if (depsMap === void 0) {
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key!)
    if (dep === void 0) {
      depsMap.set(key!, (dep = new Set()))
    }
    if (!dep.has(effect)) {
      dep.add(effect)
      effect.deps.push(dep)
      if (__DEV__ && effect.onTrack) {
        effect.onTrack({
          effect,
          target,
          type,
          key
        })
      }
    }
  }
} 

But at this point, you might be wondering where the activeReactiveEffectStack comes from.

Remember the code effect(() => (dummy = counter.num))?
Let's take a look at the implementation details of effect. It is clear that in the absence of lazy, it directly calls createReactiveEffect(fn, options) and returns the function.
That is, this part run(effect as ReactiveEffect, fn, args).

 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
} 
 function createReactiveEffect(
  fn: Function,
  options: ReactiveEffectOptions
): ReactiveEffect {
  const effect = function effect(...args): any {
    return run(effect as ReactiveEffect, fn, args)
  } as ReactiveEffect
  effect.isEffect = true
  effect.active = true
  effect.raw = fn
  effect.scheduler = options.scheduler
  effect.onTrack = options.onTrack
  effect.onTrigger = options.onTrigger
  effect.onStop = options.onStop
  effect.computed = options.computed
  effect.deps = []
  return effect
} 

And in run, we see the key stack push activeReactiveEffectStack.push(effect) and return fn(...args), and of course, the finally block pops the stack activeReactiveEffectStack.pop().

One thing to understand here is that the finally block always executes, even after a return.

 function run(effect: ReactiveEffect, fn: Function, args: any[]): any {
  if (!effect.active) {
    return fn(...args)
  }
  if (activeReactiveEffectStack.indexOf(effect) === -1) {
    cleanup(effect)
    try {
      activeReactiveEffectStack.push(effect)
      return fn(...args)
    } finally {
      activeReactiveEffectStack.pop()
    }
  }
} 

Alright, we have understood everything we need to know. Let's look back at this test case and see how reactive completes its work.

 it('should observe basic properties', () => {
    let dummy
    const counter = reactive({ num: 0 })
    effect(() => (dummy = counter.num))

    expect(dummy).toBe(0)
    counter.num = 7
    expect(dummy).toBe(7)
  }) 

First, we can see that the third line converts { num: 0 } into reactive.
According to the previous example, counter will be the return value of createReactiveObject, which is

 new Proxy({ num: 0 }, mutableHandlers) 

At this point, in the fourth line, we know that the effect passes a function () => (dummy = counter.num) and is not lazy.

So the effect will be called directly, which means it will execute the following line.

 const effect = function effect(...args): any {
    return run(effect as ReactiveEffect, fn, args)
  } as ReactiveEffect 

When run is executed, we know it will do three things:

  1. push activeReactiveEffectStack
  2. run fn
  3. pop activeReactiveEffectStack

So when it reaches the second step, there is already an effect data on the stack, and fn is dummy = counter.num. At this point, it will call the proxy getter function of counter.
Do you remember what will happen? It will go to the track function,
And if you remember (I believe everyone has forgotten by now), track will take the top effect from the stack to establish a correspondence between a certain key of the target and that effect.

Now we are almost done, but there is one thing we haven't mentioned, which is what happens when we set

 counter.num = 7 

First, let's take a look at the set handler code.

 function set(
  target: any,
  key: string | symbol,
  value: any,
  receiver: any
): boolean {
  value = toRaw(value)
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  if (isRef(oldValue) && !isRef(value)) {
    oldValue.value = value
    return true
  }
  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)) {
    /* istanbul ignore else */
    if (__DEV__) {
      const extraInfo = { oldValue, newValue: value }
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key, extraInfo)
      } else if (value !== oldValue) {
        trigger(target, OperationTypes.SET, key, extraInfo)
      }
    } else {
      if (!hadKey) {
        trigger(target, OperationTypes.ADD, key)
      } else if (value !== oldValue) {
        trigger(target, OperationTypes.SET, key)
      }
    }
  }
  return result
} 

Generally, when a proxy proxies an array, performing basic push may trigger many gets and sets (set value, set length). If not handled properly, it could lead to multiple trigger executions.
Here, the author has some wisdom; if you now push 'world' into ['hello'], it will first do set [1] = "world", at which point hadKey is false, which will lead to trigger(target, OperationTypes.ADD, key, extraInfo), then set length = 2, at which point hadKey is true, and since value is equal to oldValue, it can perfectly avoid executing multiple triggers.

Back to the main topic, what does trigger do?

 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: Set<ReactiveEffect> = new Set()
  const computedRunners: Set<ReactiveEffect> = new Set()
  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)
} 

Of course, you should know what to do; just take out the targetMap correspondence table and run all its effects.

This is just the surface of reactive. In the next article, I will discuss the implementation of computedRunners and the readonly feature.

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