Ultra detailed Vue3 responsive principle introduction plus source code reading

Posted by atkman on Thu, 03 Feb 2022 15:25:30 +0100

published: true
date: 2022-2-3
tags: 'front end frame Vue'

Vue3 Reactivity

This chapter introduces another very important module in Vue, responsive. This paper introduces the basic principle (including the diagram), the simple implementation and how to read the source code.

Thanks for Vue master's excellent course, which can be reproduced, but please state the source link: article source link justin3go.com (some latex formulas cannot be rendered on some platforms. You can view this website)

Reactivity

effect

  • Learn the responsive principle of Vue3;
  • Improve debugging ability;
  • Use the responsive module for some operations;
  • Contribute to Vue

How does Vue know to update these templates?

When the price changes, Vue knows how to update these templates.

JavaScript will not be updated!

Realize a responsive principle by yourself

Basic idea: when the price or quantity is updated, let it run again;

// The effct function is to recalculate the total;
let effect = () => {total  = price * quantity}  // Shorten the above intermediate code
let dep = new Set()  // Store the effect to ensure that duplicate values are not added
  • The * * track() * * function is to add this data;
  • The * * trigger() * * function < trigger function > will traverse every effect we save, and then run them;

How to store and let each attribute have its own dependency

Usually, our object will have multiple attributes, and each attribute needs its own dep (dependency), or Set of effect;

  • dep is an effect set, which should be rerun when the value changes;
  • To store these DEPs and facilitate us to find them later, we need to create a depsMap, which is a graph that stores the dep object of each attribute;

  • Using the attribute name of the object as the key, such as quantity and price, the value is a dep(effects set)

  • Code implementation:

 const depsMap = new Map()
 
 function track(key) {
     // The key value is the price or quantity just now
     let dep = depsMap.get(key);  // Dependency found for attribute
     if (!dep){  //If not, create one
         depsMap.set(key, (dep = new Set()))
     }
     dep.add(effect)  // Add effect. Note that dep(Set) will remove duplication
 }
 function trigger(key) {
     let dep = depsMap.get(key) // Find dependency of key value
     if (dep) {  // If so, traverse and run each effect
         dep.forEach(effect => {
             effect()
         })
     }
 }
 // main
 let product = { price: 5, quantity: 2};
 let total = 0;
 let effect = () => {
     total = product.price * product.quantity;
 }
 // Storage effect
 track('quantity')
 effect()
  • result:

What if we have multiple responsive objects?

The previous is as follows:

Here, depsMap stores each attribute. On the next level, we need to store each object. Each object includes different attributes, so we use a table (Map) to store each object;

This figure in Vue3 is called targetMap. It should be noted that the data structure is weakmap < < that is, when the key value is empty, it will be cleared by the garbage collection mechanism, that is, after the responsive object disappears, the relevant dependencies stored here will also be cleared automatically >

const targetMap = new WeakMap();  // Store dependencies for each responsive object
// Then the track() function needs to get the depsMap of targetMap first
function track(target, key) {
    let depsMap = targetMap.get(target);  // target is the name of the responsive object, and key is the name of the attribute in the object
    if (!depsMap){  // If it does not exist, create a new deps diagram for this object
        targetMap.set(target, (depsMap = new Map())
    };
    let dep = depsMap.get(key);  // Get the dependent object of the attribute, which is consistent with the previous one
    if(!dep){  //Also create a new one if it doesn't exist
        depsMap.set(key, (dep = new Set()))
    };
    dep.add(effect);
}
function trigger(target, key){
    const depsMap = targetMap.get(target) // Check whether the object has dependent properties
    if(!depsMap){return}  // If not, return immediately
    let dep = depsMap.get(key)  // Check whether the attribute has dependencies
    // If so, dep will be traversed and each effect will be run
    if (dep){
        dep.forEach(effect => {effect()})
    }
}
// main
let product = {price: 5, quantity: 2}
let total = 0
let effect = () => {
    total = product.price * product.quantity
}
track(product, "quantity")
effect()

function:

summary

The response formula is recalculated every time the value is updated. However, due to some dependencies, the attributes corresponding to some objects will lead to changes in other variables. Therefore, some data structures are needed to record these changes to form the following tables (Map, Set)

But at present, we have no way to make our effect run again automatically, which will be discussed later;

Agency and reflection

introduce

In the previous part, we used track() and trigger() to explicitly construct a responsive engine. In this part, we want it to automatically track and trigger;

Requirements:

  • Accessing the product properties or using the get method is when we want to call track to save the effect
  • The attribute of the product is changed or the set method is used, that is, we want to call trigger to run those saved effect s

solve:

  • In Vue2, the object in ES5 is used Defineproperty() to intercept get or set;
  • In Vue3, proxy and reflection in ES6 are used to achieve the same effect;

Fundamentals of agency and reflection

We usually use three methods to obtain the properties in an object:

  • let product = {price: 5, quantity: 2};
    product.quantity
    
  • product[quantity]
    
  • Reflect.get(product, 'quantity')
    

As shown in the figure below, when printing, the calling sequence: it will call the agent first, which is simply an object delegate. The second parameter of the agent is a processing function, as shown in the figure below, in which we can print a string;

The following changes the default behavior of get directly and completely. Usually, we only need to add some code to the original behavior. At this time, reflection is needed to call the original behavior;

Then the log here will call the get method, and the get method is in the agent, so it won't look up again. The arrow in the figure below stops;

To use reflection in the proxy, we need to add an additional parameter (the receiver passes it to our call to rely), which ensures that when our object has a value or function inherited from other objects, this pointer can point to it correctly;

Then, we intercept the set:

Run call:

encapsulation

Output:

Join track and trigger

Then we return to the original code. We need to call track and trigger:

Recall:

  • The function of track: add the effect corresponding to the attribute value (key) to the collection;
  • track is added to get: < combined with the overall operation process below > for example, I defined a responsive object, which contains two attributes of unit price and quantity, and then I defined a variable total = product price+product. quantity; Here we call get, that is, the change of price and quantity will affect the variable of total. track is called in get to save the calculation code to the collection.
  • The function of trigger: rerun all effect functions;
  • trigger added to set: when the value changes each time, run the corresponding effect function. For example, when the unit price changes (execute set), the total price will also be recalculated by the effect function;

Overall operation process

ActiveEffect&Ref

The track in the previous part will traverse the attribute values and various dependencies in the target < responsive Object > to ensure that the current effect will be recorded and saved, but this is not what we want. We should only call the tracking function < in the effect, that is, only the attributes of the responsive object used in the effect will be saved and recorded, not used in the effect, For example, the log will not be saved once, and the track() function will not be called to track. This is the effect we need to achieve >;

First of all, we introduce an activeEffect variable, which is currently running effect (this is also Vue3's way to solve this problem)

let activeEffect = null
function effect(eff){
    activeEffect = eff
    activeEffect()
    activeEffect = null
}
// What is the difference between this function and directly calling the incoming eff function, that is, what is the role of mu lt iple activeEffect variables to save< This variable will be used to judge some things >
// Cooperate with the following if to solve the initial problem < avoid traversal >

Then we call this function, which means that we don't need to use the following effect() function, because it will be called in the activeEffect() step of the above function:

Now we need to update the track function to use this new activeEffect:

  • First, we just want to run this code when we have activeEffect:
  • When we add dependencies, we add activeEffect

Test (add more variables or objects)

When product When price = 10, it is obvious that the above two effect s will run;

Operation results:

We will find that when we use salePrice to calculate the total, it will stop working:

In this case, when the sales price is determined, it is impossible to recalculate the total, because salePrice is not responsive < the change of salePrice will not lead to the recalculation of total >;

How to achieve:

We will find that this is a good place to use Ref;

let salePrice = ref(0)

Ref accepts a value and returns a responsive and variable ref object. The ref object has only one ". Value" attribute, which points to the internal value,

Now let's consider how to define Ref()

1. We can simply use reactive to set the key value as the initial value:

function ref(intialValue) {
    return reactive({value: intialValue})
}

2. < solution in vue3 > calculation attribute in javascript:

Object accessors are used here: object accessors are functions that get or set values < getter and setter >

Next, use the object accessor to define the Ref:

function ref(raw) {
    const r = {
        get value(){
            track(r, 'value')  // Call the track function here
            return raw
        }
        set value(newVal){
            raw = newVal
            trigger(r, 'value')  // Call the trigger function here
        }
    return r
    }
}

Let's test whether it is normal:

When the quantity is updated, the total will change; When the price is updated, the total will also change with salePrice.

**There is a question: * * when actually using Vue3, there are only reactive and ref, and you don't see the defined effect, so where does the effect added in the track come from? Is that the effect should also be encapsulated or operated in the source code?

Compute&Vue3-Source

Maybe we should use the calculation attribute for the sales price and total price here

How should we define the calculation method?

The calculated attribute or calculated value and reactive are very similar to Ref - when the dependent attribute changes, the result changes

  • Create a responsive reference called result;
  • Run getter in effect, because we need to listen to the response value and assign it to result value;
  • Return results
  • Here is the actual code:

  • function computed(getter) {
        let result = ref()
        effect(() => (result.value = getter()))
        return result
    }
    
  • The test results are as follows:

Compare Vue2's response:

In Vue2, we cannot add new responsive attributes after creating a responsive object, which is obviously more powerful here;

Because this name is not responsive, in Vue2, get and set hooks are added to each attribute, so when we want to add a new attribute, we need to do other things:

However, the use of proxies now means that we can add new properties, and then they will automatically become responsive

View source code:

Q&A

Question one

In Vue2, we will call depend to save the code (function), and use notify to run the saved code (function); But in Vue3, we call track and trigger. Why?

  • Basically, they still do the same thing; A bigger difference is that when you name them, dependent and notify are verbs related to the owner (Dep). It can be said that a dependent instance is dependent or that it is notifying its subscribers
  • In Vue3, we have made some changes to the implementation of dependency relationship. Technically, there is no Dep class. The logic of deopend and notify is now separated into two independent functions (track and trigger). When we call track and trigger, they are more like tracking what is dependent than what is dependent (a.b – > b.Call (a))

Question two

In Vue2, we have an independent Dep class; In Vue3, we only had one Set. Why did we make such a change?

  • The Dep class in Vue2 makes it easier for us to think about dependencies. As an object, it has some behavior
  • However, in Vue3, we separate dependent and notify from track and trigger. Now there are two independent functions, so there is only one Set left in the class itself. At this time, it is meaningless to encapsulate the Set class with another class, so we can declare it directly instead of letting an object do it;
  • For performance reasons, this class is really not needed

Question three

How did you get this solution (the method of dealing with responsiveness in Vue3)?

  • When using getter s and setter s in ES5, when you traverse the keys on the object (forEach), there will naturally be a small closure to store the associated Dep for the attribute;
  • In Vue3, after using Proxy, the processing function of Proxy will directly receive the target and key. You don't get the real closure to store the associated dependencies for each attribute;
  • Given a target object and a key on the object, the only way to always find the corresponding dependent instances is to divide them into two nested graphs of different levels;
  • The name of targetMap comes from Proxy

Question 4

When defining Ref, you can define Ref by returning a reactive. What is the difference between this method and using object accessors in Vue3 source code?

  • Firstly, according to the definition of Ref, only one attribute (value itself) should be exposed, but if reactive is used, technically, you will attach some new attributes to it, which is contrary to the purpose of Ref;
  • ref can only wrap an internal value and should not be regarded as a general responsive object;
  • isRef check: the returned ref object actually has some special things. Let us know that it is actually a ref, not a reactive, which is necessary in many cases;
  • Performance issues, because the responsive object does more than what we do in Ref. when you try to create a responsive object, we need to check whether there is a corresponding responsive copy and whether it has a read-only copy. When you create a responsive object, there will be a lot of extra work to do, Using an object literal to create a ref here will save more performance

Question five

What are the other benefits of using reflection and proxies to add attributes

  • When we use agents, the so-called responsive transformation will become lazy loading;
  • In Vue2, when we convert, we must complete the conversion as soon as possible, because when you pass the object to the response of Vue2, we must traverse all keys and convert on the spot. Later, when they are visited, they have been transformed;
  • However, for Vue3, when you call reactive, all we do for an object is to return a proxy object and nest the object only when you need to convert (when you need to access it), which is similar to lazy loading;
  • In this way, if your app has a huge list of objects, but for paging, you only render the first 10 of the page, that is, only the first 10 must undergo responsive conversion, which can save a lot of time for starting the application;

Source code

// packages\reactivity\src\baseHandlers.ts
const get = /*#__PURE__*/ createGetter()
const shallowGet = /*#__PURE__*/ createGetter(false, true)
const readonlyGet = /*#__PURE__*/ createGetter(true)
const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true)

const set = /*#__PURE__*/ createSetter()
const shallowSet = /*#__PURE__*/ createSetter(true)

createGetter

// packages\reactivity\src\baseHandlers.ts
function createGetter(isReadonly = false, shallow = false) {
    // There are isReadonly and shallow versions
    // isReadonly allows you to create only read-only objects that can be read and tracked, but cannot be changed
    // Share means that when you put an object into another object, as a nested property, it doesn't try to convert it into a responsive property
  return function get(target: Target, key: string | symbol, receiver: object) {
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (key === ReactiveFlags.IS_SHALLOW) {
      return shallow
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
          ? shallowReactiveMap
          : reactiveMap
        ).get(target)
    ) {
      return target
    }

    const targetIsArray = isArray(target)
	
    // (1)
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }

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

    if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
      return res
    }

    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)  // Code actually executed
    }

    if (shallow) {
      return res
    }
	// If you nest Ref in a responsive object, it will be unpacked automatically when you access it.
    if (isRef(res)) {
      // ref unwrapping - does not apply for Array + integer key.
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      return shouldUnwrap ? res.value : res
    }
	// Ensure that it is converted only if it is an object
    if (isObject(res)) {
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}
const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
  const instrumentations: Record<string, Function> = {}
  // instrument identity-sensitive Array methods to account for possible reactive
  // values
  ;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      const arr = toRaw(this) as any
      for (let i = 0, l = this.length; i < l; i++) {
        track(arr, TrackOpTypes.GET, i + '')
      }
      // we run the method using the original args first (which may be reactive)
      const res = arr[key](...args)
      if (res === -1 || res === false) {
        // if that didn't work, run it again using raw values.
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })
  // instrument length-altering mutation methods to avoid length being tracked
  // which leads to infinite loops in some cases (#2137)
  ;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      pauseTracking()
      const res = (toRaw(this) as any)[key].apply(this, args)
      resetTracking()
      return res
    }
  })
  return instrumentations
}

We have an array (responsive array). When we access something nested in it, we get a responsive version of the original data

// An edge case
const obj = {}
const arr = reactive([obj])
const reactiveObj = arr[0]
// Compare objects with responsive objects
obj === reactiveObj  // is false

This problem is caused by using indexOf to find obj < < this problem will be caused if there is no array detector (1) >

const obj = {}
const arr = reactive([obj])
arr.indexOf(obj)  // -1

createSetter

// packages\reactivity\src\baseHandlers.ts
function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    let oldValue = (target as any)[key]
    // Users cannot operate on read-only attributes
    // isRef(oldValue) && ! Isref (value) detects whether the property is being set
    if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
      return false
    }
    if (!shallow && !isReadonly(value)) {
      if (!isShallow(value)) {
        value = toRaw(value)
        oldValue = toRaw(oldValue)
      }
      if (!isArray(target) && isRef(oldValue) && !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) && 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)) {
        // Add if there is no key
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

Use delete to delete:

function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  const result = Reflect.deleteProperty(target, key)
  if (result && hadKey) {
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

track

// packages\reactivity\src\effect.ts
export function track(target: object, type: TrackOpTypes, key: unknown) {
  if (!isTracking()) {
      // Some internal flags should not be tracked in some cases (1)
    return
  }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }
	// This is the effect
  const eventInfo = __DEV__
    ? { effect: activeEffect, target, type, key }
    : undefined
	// (2)
  trackEffects(dep, eventInfo)
}

(1)

export function isTracking() {
  return shouldTrack && activeEffect !== undefined
}
// activeEffect means that track will be called anyway. If the responsive object has just been accessed, it will still be called without any currently running effect

(2)

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!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!)
    // This is a two-way relationship. It is many to many between dep and effect
            //We need to track all this and clean up
    activeEffect!.deps.push(dep)
    if (__DEV__ && activeEffect!.onTrack) {
      activeEffect!.onTrack(
        Object.assign(
          {
            effect: activeEffect!
          },
          debuggerEventExtraInfo
        )
      )
    }
  }
}

trigger

When cleaning up a collection, you must trigger all the effects associated with it

// packages\reactivity\src\effect.ts
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) {
    // never been tracked
    return
  }

  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes.CLEAR) {
    // collection being cleared
    // trigger all effects for target
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        deps.push(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          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

  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))
    }
  }
}
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

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))
}
}
}

<Comment/>

Topics: Javascript Front-end Vue.js