2

In the previous article "Responsive Principles and Reactive" due to space limitations, the author left two small suspense tracks. The reliance on the collection processor and the trigger distribution update processor were not explained in detail. In this article, the author will take you Let's learn about the dependency collection part and side-effect functions in the Vue3 reactive system.

How does Vue track changes?

When we use responsive variables in the template, or pass in the getter function in the calculated property, when the source data in the calculated property changes, Vue can always notify the update and re-render the component immediately. How are these magical phenomena? Realized?

Vue uses an effect function to track the currently running function. The side effect is a function wrapper that starts tracking before the function is called, and Vue can accurately find these collected side-effect functions when it dispatches an update, and execute it again when the data is updated.

In order to better understand the collection process of dependencies, I start with the implementation of side-effect functions.

Type of effect

Before introducing the side effects, the old rules will take a look at the types of side effects. This can help everyone first have an intuitive concept of what the side effects look like.

export interface ReactiveEffect<T = any> {
  (): T
  _isEffect: true
  id: number
  active: boolean
  raw: () => T
  deps: Array<Dep>
  options: ReactiveEffectOptions
  allowRecurse: boolean
}

From the side-effect type definition, it can be clearly seen that it defines a generic parameter. This generic type will be used as the return value of the internal side-effect function, and the type itself is a function. There is also an _isEffect attribute to identify this as a side effect; the active attribute is used to identify the state of the side effect being enabled and disabled; the raw attribute stores the initial incoming function; the deps attribute is all dependencies of this side effect, for the elements in this array The author will introduce the Dep type; the options store some configuration items of the side effect object; and allowRecurse does not pay attention to it for the time being, it is an identification of whether the side effect function can be called by itself.

Global variables for side effects

Three variables are global variables defined in the side effect module. Knowing these variables in advance can help us understand the generation and calling process of the entire side effect function.

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

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined

targetMap:

This targetMap is a very important variable. It is of WeakMap type and stores the link of {target -> key -> dep }.

The value type of targetMap is KeyToDepMap, and KeyToDepMap is a Map object with Dep as its value. Dep is the dependency that I have been mentioning. Vue collection dependency is actually collecting the Dep type. So compared with the source code of Vue2, conceptually speaking, it is easier to understand the dependency as a Dep class that maintains the subscriber Set collection. In the targetMap, the Dep is only stored in an original Set collection to reduce memory. Cost considerations.

effectStatck

This is a stack that stores the side effects that are currently being called. When a side effect is pushed onto the stack before execution, it will be pushed out of the stack after the end.

activeEffect

This variable marks the side effect currently being executed, or can also be understood as the top element in the side effect stack. When a side effect is pushed onto the stack, the side effect will be assigned to the activeEffect variable, and when the function in the side effect is executed, the side effect will pop out of the stack and assign activeEffect to the next element on the stack. So when there is only one element in the stack, the activeEffect will be undefined after the stack is executed.

The realization of side effects (effect)

After learning the types and variables that need to be understood beforehand, the author began to explain the implementation of side-effect functions, not much to say, just look at the code.

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // 如果 fn 已经是一个副作用函数,则返回副作用的原始函数
  if (isEffect(fn)) {
    fn = fn.raw
  }
  // 创建一个副作用
  const effect = createReactiveEffect(fn, options)
  // 如果不是延迟执行的,则立即执行一次副作用函数
  if (!options.lazy) {
    effect()
  }
  // 返回生成的副作用函数
  return effect
}

The function of the effect api is relatively simple. When the incoming fn is already a side effect function, it will be assigned to the original function of the side effect. createReactiveEffect will be called to create a ReactiveEffect type function. If the delayed execution is not set in the side effect option, the side effect function will be executed immediately, and the generated side effect function will be returned.

Then let's look at the logic of createReactiveEffect that creates a side effect function.

createReactiveEffect

In createReactiveEffect, a function expression with a variable name of effect is first created, and then some properties mentioned in the ReactiveEffect type are set for this function, and finally the function is returned.

When this effect function is executed, it will first determine whether it has been disabled. If it is disabled, it will check whether there is a scheduling function in the options. If there is a scheduling function, it will not be processed, and return undefined directly. If there is a scheduling function, the fn function passed in is executed and returned, and then it will not run anymore.

If the effect function status is normal, it will judge whether the current effect function is already in the side effect stack. If it has been added to the stack, it will not continue processing to avoid loop calls.

If the current effect function is not on the stack, the dependency of the side-effect function will be cleaned up through the cleanup function, and the dependency collection switch will be turned on, the side-effect function will be pushed into the side-effect stack, and the current side-effect function will be recorded as activeEffect. This logic is already mentioned when the author introduced these two variables, and it is triggered here.

Next, the passed fn function will be executed and the result will be returned.

When the function is executed, the side effect function will be popped from the stack, and the dependent collection switch will be reset to the state before the side effect is executed, and then the activeEffect will be marked as the top element of the current stack. At this point, the execution of a side-effect function is completely finished, let's take a look at the implementation of the source code with the author.

function createReactiveEffect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // 通过一个函数表达式,创建一个变量名为 effect ,函数名为 reactiveEffect 的函数
  const effect = function reactiveEffect(): unknown {
    // 如果 effect 已停用,当选项中有调度函数时返回 undefined,否则返回原始函数
    if (!effect.active) {
      return options.scheduler ? undefined : fn()
    }
    if (!effectStack.includes(effect)) {
      // 清理依赖
      cleanup(effect)
      try {
        // 允许收集依赖
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  } as ReactiveEffect
  // 为副作用函数设置属性
  effect.id = uid++
  effect.allowRecurse = !!options.allowRecurse
  effect._isEffect = true
  effect.active = true
  effect.raw = fn
  effect.deps = []
  effect.options = options
  return effect
}

When the side-effect function is returned in the last line, the previous paragraph mentioned that when lazy in the options parameter is false, this side-effect function will be called for the first time, and this function will be triggered at the sixth line const effect create the function After the internal logic of the function.

After understanding the execution order of createReactiveEffect, and with detailed logic explanation, I believe you have also mastered the creation of effect side-effect functions.

Collect dependencies and distribute updates

In order to more logically and smoothly lead to the work and implementation process of dependency collection and distribution updates, the author decided to introduce a simple unit test case of the effect module in Vue3 here. I will explain the examples and talk about dependency collection and distribution updates by the way. .

let foo
const counter = reactive({ num: 0 })
effect(() => (foo = counter.num))
// 此时 foo 应该是 0
counter.num = 7
// 此时 foo 应该是 7

This is an example of the simplest effect. We all know that foo will change with the change of counter.num. So how is it updated?

First, the counter generates a proxy object through the reactive api. This generation process has been explained in the previous article, so I won't go into details here.

Then use effect and pass a function to it. At this time, the effect starts its creation process, and it will execute to this step of the code below in the effect function.

const effect = createReactiveEffect(fn, options)

Start creating the effect function through createReactiveEffect and return.

When the effect function is returned, it will be judged whether there is a need to delay execution in the current side-effect options, and here we do not pass in any parameters, so it is not delayed loading, and needs to be executed immediately, so the returned effect function will start to execute.

if (!options.lazy) {
    effect() // 不需要延迟执行,执行 effect 函数
}

Then it will start to execute the internal code logic when createReactiveEffect creates the effect function.

const effect = function reactiveEffect(): unknown {/* 执行此函数内的逻辑 */}

Since the effect function is in the active state and is not in the side-effect stack, the dependencies are cleared first. Since no dependencies are collected now, the cleanup process does not need to be concerned. Then the effet will be pushed onto the stack and set to activeEffect, and then the initial incoming fn will be executed: () => (foo = counter.num) .

When assigning a value to foo, the num attribute of the counter will be accessed first, so the get trap of the proxy handler of the counter will be triggered:

// get 陷阱
return function get(target: Target, key: string | symbol, receiver: object) {
    /* 忽略逻辑 */
  // 获取 Reflect 执行的 get 默认结果
  const res = Reflect.get(target, key, receiver)
  if (!isReadonly) {
    // 依赖收集
    track(target, TrackOpTypes.GET, key)
  }
  return res
}

Here I simplified the code in get and only kept the key parts. You can see that after getting the value of res, it will start dependency collection through track. (🥺 Be careful to start talking about dependency collection, don’t be distracted)

track collection dependencies

The path of the track function is also in the effect.ts file of the @vue/reactivity

In the track process, it will first determine whether to allow the collection of dependencies. This state is controlled by the pair of functions enableTracking() and pauseTracking() Then it will determine whether there is a side effect function currently being executed, and if not, return directly. Because the dependency collection is actually collecting the side-effect function .

Then try to obtain the dependency set of the corresponding traget from the targetMap introduced at the beginning of this article, and store it in the depsMap variable. If the acquisition fails, the current target will be added to the dependency set, and the value will be initialized to new Map( ). For example, in the current example, target is {num: 0 }, which is the value of the counter object.

After having depsMap, according to the key read in the target, check whether there is a dependency on the corresponding key in the dependency set, and assign it to dep. If not, just like the logic of creating depsMap, create a set of Set type as the value.

If the currently executed side-effect function is not collected as a dependency by the Set collection of dep, the current side-effect function will be added to dep, and the dependent dep will be added to the dep attribute of the current side-effect function.

Seeing this, you can imagine what kind of structure the dependent collection is. Taking key as the dimension, collect the side-effect functions associated with each key, store them in a Set data structure, and store them in the Map structure of depsMap in the form of key-value pairs. At this time, look at the beginning of the article and describe the storage form of targetMap {target -> key -> dep }. It should be said that it is very clear.

The code of the track processor function is as follows:

export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 不启用依赖收集,或者没有 activeEffect 则直接 return
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  // 在 targetMap 中获取对应的 target 的依赖集合
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 如果 target 不在 targetMap 中,则加入,并初始化 value 为 new Map()
    targetMap.set(target, (depsMap = new Map()))
  }
  // 从依赖集合中获取对应的 key 的依赖
  let dep = depsMap.get(key)
  if (!dep) {
    // 如果 key 不存在,将这个 key 作为依赖收集起来,并初始化 value 为 new Set()
    depsMap.set(key, (dep = new Set()))
  }
  // 如果依赖中并不存当前的 effect 副作用函数
  if (!dep.has(activeEffect)) {
    // 将当前的副作用函数收集进依赖中
    dep.add(activeEffect)
    // 并在当前副作用函数的 deps 属性中记录该依赖
    activeEffect.deps.push(dep)
  }
}

After reading the track, continue to look at our example:

effect(() => (foo = counter.num))

When the track collects the dependencies, the get trap returns the result of Reflect.get, reads the value of counter.num as 0, and assigns this result to the foo variable. At this point, the first run of the side-effect function ends, and foo already has a value of 0. When the side-effect function is executed, the current side-effect function will be popped from the stack, and activeEffect will be assigned to undefeind.

trigger dispatch update

After understanding the dependency collection, continue to look at the process of distributing updates.

The last line of code in the example assigns num to 7.

counter.num = 7

We know that foo will be updated to 7 synchronously. So what is the process?

When counter.num is assigned, a set trap will be triggered:

const result = Reflect.set(target, key, value, receiver)
if (target === toRaw(receiver)) {
  if (!hadKey) {
    // 当 key 不存在时,触发 trigger 的 ADD 事件
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    // 当 key 存在时,当新旧值变化后,触发 trigger 的 SET 事件
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
}
return result

Let's take a look at the part of the code of the set trap. The trigger of the trigger will pass in an enumeration of TriggerOpTypes. The enumeration has four types, corresponding to add, delete, modify, and clear operations.

export const enum TriggerOpTypes {
  SET = 'set',
  ADD = 'add',
  DELETE = 'delete',
  CLEAR = 'clear'
}

Since the counter has already added the num key when creating the proxy object through the reactive api, the SET event will be triggered when the old and new values change.

Then the trigger function will be executed.

The trigger function will immediately obtain the depsMap from the targetMap through the target. If there is no corresponding depsMap, it means that the current traget has never been used for dependency collection through the track, so it returns directly and does not continue execution.

Next, a set of Set structures named effects will be created, which is used to store all side-effect functions of this key that need to be dispatched and updated.

At the same time, declare an add function. The role of the add function is to traverse the incoming side-effect functions, and add side-effect functions that are not currently executing or self-executing to the effects collection.

Then it will judge the special situation of clearing the dependency and the array, and call the add function to add the dependency as needed.

After that, it will judge whether the current key is not undefined. Note that the judgment condition void 0 here represents undefined in the form of the void operator. If there is a key, the key-related dependencies are added to the effects collection through the add function.

The subsequent Switch Case handles some special logic of iteration keys by distinguishing triggerOpTypes.

The function of the run function declared later is to execute the side effect function added to the effects array.

The end of the trigger function is to traverse all the side-effect functions in the collection and execute them effects.forEach(run)

Let's take a look at the trigger code first:

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) {
        // 该 target 从未被追踪,不继续执行
    return
  }
    
  // effects 集合存放所有需要派发更新的副作用函数。
  const effects = new Set<ReactiveEffect>()
  // 将不是当前副作用函数以及能执行自身的副作用函数加入集合中
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        if (effect !== activeEffect || effect.allowRecurse) {
          effects.add(effect)
        }
      })
    }
  }

  if (type === TriggerOpTypes.CLEAR) {
        // 当需要清除依赖时,将当前 target 的依赖全部传入
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) {
    // 处理数组的特殊情况
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // 在 SET | ADD | DELETE 的情况,添加当前 key 的依赖
    if (key !== void 0) {
      add(depsMap.get(key))
    }

    // 对 ADD | DELETE | Map.SET 执行一些迭代键的逻辑
    switch (type) { /* 暂时忽略 */ }
  }
    
  // 执行 effect 的函数
  const run = (effect: ReactiveEffect) => {
    // 判断是否有调度器,如果有则执行调度函数并将 effect 作为参数传入
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      // 否则直接执行副作用函数
      effect()
    }
  }
    // 遍历集合,执行收集到的副作用函数
  effects.forEach(run)
}

After hiding the special logic of SwitchCase and the special logic of the DEV environment, the length of the trigger function has been simplified and the logic is clear.

Going back to our example, when the trigger determines whether there is a key, and the dependency of the key is passed to the add function, the side-effect function collected during the track of the example has been acquired by the effects collection. When the trigger executes to the last line of code, the side-effect function will be passed as a parameter to the run function. Since there is no scheduler, this side-effect function will be executed directly: () => (foo = counter.num) . After the execution is completed, the value of foo is successfully updated to 7.

At this point, the process of collecting dependencies and distributing updates has been completed, and the examples in this article have also been run. I believe everyone has a deep understanding of this process. If you are still a bit confused, I suggest linking the functions of effect, track and trigger in this article, and the source code of the traps of get and set above, and then take a look. I believe you will suddenly become more enlightened.

to sum up

In this article, the author first explained in detail the generation process and execution timing of side effects and side effects functions. A simple example leads to the process of dependency collection and distribution of updates. When combining these two parts, combine the hanlders traps of the two proxy objects mentioned above, get and set, to string together the process completely. Follow the example The execution process tells everyone the entire process of dependency collection and distribution of updates.

This also answers the question mentioned at the beginning of the article: How does Vue track changes? Collect side-effect dependencies through track, and execute the corresponding side-effect function to complete the update when triggering.

Finally, if this article can help you understand the responsive side effects in Vue3 and the process of relying on the collection and distribution of updates, I hope to give this article a little like ❤️. If you want to continue to follow up the follow-up articles, you can also follow my account or follow my github , thank you again for the lovely masters.


Originalix
165 声望63 粉丝

前端工程师,欢迎来 github 互相 follow, originlaix