Vue3 responsive source code analysis – ref + ReactiveEffect articles

In Vue3, because the reactive object created by reactive is implemented through Proxy, the incoming data cannot be the basic type, so the ref object is a supplement to the data that reactive does not support.

Another important job in ref and reactive is to collect and trigger dependencies, so what are dependencies? How to collect triggers? Let’s take a look together:

Let’s take a look at the source code implementation of ref :

 export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

const toReactive = (value) => isObject(value) ? reactive(value) : value;

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果是ref则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T
  // 存放 raw 原始值
  private _rawValue: T

  // 存放依赖
  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // toRaw 拿到value的原始值
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 如果不是shallowRef,使用 reactive 转成响应式对象
    this._value = __v_isShallow ? value : toReactive(value)
  }

  // getter拦截器
  get value() {
    // 收集依赖
    trackRefValue(this)
    return this._value
  }

  // setter拦截器
  set value(newVal) {
    // 如果是需要深度响应的则获取 入参的raw
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    // 新值与旧值是否改变
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      // 更新value 如果是深入创建并且是对象的话 还需要转化为reactive代理
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      // 触发依赖
      triggerRefValue(this, newVal)
    }
  }
}

RefImpl ES6类的写法,包含get 、 set ,其实大家可以用webpack 等打包工具打包成ES5 的代码, 发现其实就是 Object.defineProperty .

It can be seen that shallowRef and ref both call createRef , but the incoming parameters are different. When shallowRef toReactive not be called to convert the object to responsive. It can be seen that the shallowRef object only supports responsiveness to the value, and the ref object supports the depth of the value. Responsive, modifications in ref.value.abc can be intercepted, for example 🌰:

 <template>
    <p>{{ refData.a }}</p>
    <p>{{ shallowRefData.a }}</p>
    <button @click="handleChange">change</button>
</template>


let refData = ref({
  a: 'ref'
})
let shallowRefData = shallowRef({
  a: 'shallowRef'
})

const handleChange = () => {
  refData.value.a = "ref1"
  shallowRefData.value.a = "shallowRef1"
}

When we click the button to modify the data, the value of refData.a on the interface will become ref1 , and shallowRefData.a should not change, but in this example In, shallowRefData.a will also change on the view 🐶, because the modification refData.a triggers the setter function, which will call triggerRefValue(this, newVal) -398 视图更新 , so the latest data of shallow will also be updated to the view (remove refData.value.a = "ref1" and it will not change).

The most critical ones in ref are trackRefValue and triggerRefValue , which are responsible for collecting trigger dependencies.

How to collect dependencies:

 function trackRefValue(ref) {
    // 判断是否需要收集依赖
    // shouldTrack 全局变量,代表当前是否需要 track 收集依赖
    // activeEffect 全局变量,代表当前的副作用对象 ReactiveEffect
    if (shouldTrack && activeEffect) {
        ref = toRaw(ref);
        {
            // 如果没有 dep 属性,则初始化 dep,dep 是一个 Set<ReactiveEffect>,存储副作用函数
            // trackEffects 收集依赖
            trackEffects(ref.dep || (ref.dep = createDep()), {
                target: ref,
                type: "get",
                key: 'value'
            });
        }
    }
}

Why judge shouldTrack and activeEffect , because in Vue3 sometimes it is not necessary to collect dependencies:

  • When there is no effect package, for example, a ref variable is defined but not used anywhere, then there is no dependency, and activeEffect is undefined, so there is no need to collect dependencies
  • For example, in some methods of the array that will change its own length, dependencies should not be collected, which is easy to cause an infinite loop. At this time, shouldTrack is false.

*What is the dependency?

ref.dep for storage 依赖 (side effect object), it will be triggered when ref is modified, so what is the dependency? The dependency is ReactiveEffect :

Why collect dependencies (side effect objects), because in Vue3, the change of a responsive variable often triggers some side effects, such as view updates, changes in computed properties, etc., and other side effect functions need to be triggered when the responsive variable changes .

In my opinion ReactiveEffect is actually similar to the Watcher in Vue2, as explained in the “Vue Source Learning – Responsive Principles” I wrote before:

 class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        // 传入一个副作用函数
        this.fn = fn;
        this.scheduler = scheduler;
        this.active = true;
        // 存储 Dep 对象,如上面的 ref.dep
        // 用于在触发依赖后, ref.dep.delete(effect),双向删除依赖)
        this.deps = [];
        this.parent = undefined;
        recordEffectScope(this, scope);
    }
    run() {
        // 如果当前effect已经被stop
        if (!this.active) {
            return this.fn();
        }
        let parent = activeEffect;
        let lastShouldTrack = shouldTrack;
        while (parent) {
            if (parent === this) {
                return;
            }
            parent = parent.parent;
        }
        try {
            // 保存上一个 activeEffect
            this.parent = activeEffect;
            activeEffect = this;
            shouldTrack = true;
            // trackOpBit: 根据深度生成 trackOpBit
            trackOpBit = 1 << ++effectTrackDepth;
            // 如果不超过最大嵌套深度,使用优化方案
            if (effectTrackDepth <= maxMarkerBits) {
                // 标记所有的 dep 为 was
                initDepMarkers(this);
            }
            // 否则使用降级方案
            else {
                cleanupEffect(this);
            }
            // 执行过程中重新收集依赖标记新的 dep 为 new
            return this.fn();
        }
        finally {
            if (effectTrackDepth <= maxMarkerBits) {
                // 优化方案:删除失效的依赖
                finalizeDepMarkers(this);
            }
            // 嵌套深度自 + 重置操作的位数
            trackOpBit = 1 << --effectTrackDepth;
            // 恢复上一个 activeEffect
            activeEffect = this.parent;
            shouldTrack = lastShouldTrack;
            this.parent = undefined;
            if (this.deferStop) {
                this.stop();
            }
        }
    }
}

ReactiveEffect is the side effect object, which is the actual object of the collected dependencies. A responsive variable can have multiple dependencies, the most important of which is the run method, which has two schemes. When effect the number of nesting does not exceed the maximum number of nesting, use the optimization scheme, otherwise use the degraded scheme.

Downgrade plan:
 function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            // 从 ref.dep 中删除 ReactiveEffect
            deps[i].delete(effect);
        }
        deps.length = 0;
    }
}

This is very simple, delete all dependencies, and then re-collect. In each dep, delete the ReactiveEffect object, and then execute this.fn() (side effect function), when the responsive variable is obtained and trigger getter , it will be collected again rely. The reason for deleting and then re-collecting is that the collected dependencies may not be the same before and after as the reactive variables change.

 const toggle = ref(false)
const visible = ref('show')
effect(() = {
  if (toggle.value) {
    console.log(visible.value)
  } else {
    console.log('xxxxxxxxxxx')
  }
})
toggle.value = true
  • When toggle is true, both toggle and visible can collect dependencies
  • When toggle is false, only visible can collect dependencies
Optimization:

Deleting all of them and collecting them again is obviously too performance-intensive. In fact, many dependencies do not need to be deleted. Therefore, the optimization scheme is as follows:

 // 响应式变量上都有一个 dep 用来保存依赖
const createDep = (effects) => {
    const dep = new Set(effects);
    dep.w = 0;
    dep.n = 0;
    return dep;
};
  1. Before executing the side effect function, give ReactiveEffect 依赖的响应式变量 and add the w(was的意思) flag.
  2. Execute this.fn(), when track recollects dependencies, add n(new的意思) to each dependency of ReactiveEffect.
  3. Finally, remove the dependencies that have w but not n .

In fact, it is a screening process. Let’s take the first step now, how to add the was tag:

 // 在 ReactiveEffect 的 run 方法里
if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this);
}

const initDepMarkers = ({ deps }) => {
    if (deps.length) {
        for (let i = 0; i < deps.length; i++) {
            deps[i].w |= trackOpBit;
        }
    }
};

Bit operations are used here, which is fast and efficient. What is trackOpBit? Represents the current nesting depth (effect可以嵌套) , there is a global variable in effectTrackDepth

 // 全局变量 嵌套深度 
let effectTrackDepth = 0;

// 在 ReactiveEffect 的 run 方法里
// 每次执行 effect 副作用函数前,全局变量嵌套深度会自增1
trackOpBit = 1 << ++effectTrackDepth

// 执行完副作用函数后会自减
trackOpBit = 1 << --effectTrackDepth;

When the depth is 1, the trackOpBit is 2 (binary: 00000010), so when executing deps[i].w |= trackOpBit , the operation is the second bit, so the first bit is not used.

Why is the maximum nesting depth 30 in Vue3?

 1 << 30
// 0100 0000 0000 0000 0000 0000 0000 0000
// 1073741824

1 << 31
// 1000 0000 0000 0000 0000 0000 0000 0000
// -2147483648 溢出

Because the js median operation is performed on a 32-bit signed integer, and the leftmost bit is the sign bit, the available positive numbers can only be up to 30 bits at most.

It can be seen that before executing the side effect function, use deps[i].w |= trackOpBit to mark whether the dependency is depended on at different depths ( w ), and then execute this.fn() , recollect the dependencies, as mentioned above Collection dependency calls trackRefValue method, which will call trackEffects :

 function trackEffects(dep, debuggerEventExtraInfo) {
    let shouldTrack = false;
    if (effectTrackDepth <= maxMarkerBits) {
        // 查看是否记录过当前依赖
        if (!newTracked(dep)) {
            dep.n |= trackOpBit;
            // 如果 w 在当前深度有值,说明effect之前已经收集过
            // 不是新增依赖,不需要再次收集
            shouldTrack = !wasTracked(dep);
        }
    }
    else {
        shouldTrack = !dep.has(activeEffect);
    }
    if (shouldTrack) {
        // dep添加当前正在使用的effect
        dep.add(activeEffect);
         // effect的deps也记录当前dep 双向引用
        activeEffect.deps.push(dep);
    }
}

It can be seen that when re-collecting the dependencies, use dep.n |= trackOpBit to mark whether the dependencies are depended on ( n ) at different depths. Two tool functions are also used here:

 const wasTracked = (dep) => (dep.w & trackOpBit) > 0;
const newTracked = (dep) => (dep.n & trackOpBit) > 0;

Use wasTracked and newTracked to determine whether dep is marked at the current depth. For example, to determine whether the dependency is marked at depth 1 (the second bit of trackOpBit is 1), use bitwise AND:

Finally, if the maximum depth has been exceeded, because the downgrade scheme is adopted, it is all deleted and then collected again, so it must be the latest, so you only need to restore trackOpBit and restore the last activeEffect:

 finally {
    if (effectTrackDepth <= maxMarkerBits) {
        // 优化方案:删除失效的依赖
        finalizeDepMarkers(this);
    }
    trackOpBit = 1 << --effectTrackDepth;
    // 恢复上一个 activeEffect
    activeEffect = this.parent;
    shouldTrack = lastShouldTrack;
    this.parent = undefined;
    if (this.deferStop) {
        this.stop();
    }
}

If the maximum depth is not exceeded, delete the invalid dependencies as mentioned before, and then update the order of deps:

 const finalizeDepMarkers = (effect) => {
    const { deps } = effect;
    if (deps.length) {
        let ptr = 0;
        for (let i = 0; i < deps.length; i++) {
            const dep = deps[i];
            // 把有 w 没有 n 的删除
            if (wasTracked(dep) && !newTracked(dep)) {
                dep.delete(effect);
            }
            else {
                // 更新deps,因为有的可能会被删掉
                // 所以要把前面空的补上,用 ptr 单独控制下标 
                deps[ptr++] = dep;
            }
            // 与非,恢复到进入时的状态
            dep.w &= ~trackOpBit;
            dep.n &= ~trackOpBit;
        }
        deps.length = ptr;
    }
};

For a simple 🌰, it may be easier to understand. There are two components, a parent component and a child component. The child component receives the toggle parameters passed by the parent component are displayed on the interface, toggle –It also controls the display of toggle visible , click the button to switch the value of toggle :

 // Parent
<script setup lang="ts">
const toggle = ref(true)
const visible = ref('show')

const handleChange = () => {
  toggle.value = false
}
</script>

<template>
  <div>
    <p v-if="toggle">{{ visible }}</p>
    <p v-else>xxxxxxxxxxx</p>
    <button @click="handleChange">change</button>
    <Child :toggle="toggle" />
  </div>
</template>
 // Child
<script setup lang="ts">
const props = defineProps({
  toggle: {
    type: Boolean,
  },
});
</script>

<template>
  <p>{{ toggle }}</p>
</template>

The first rendering, because toggle defaults to true, we can collect the dependencies of toggle and visible ,

  1. Parent , 执行run 方法中的initDepMarkers方法,首次进入,还未收集依赖, ReactiveEffectdeps 0, jump over.
  2. Execute this.fn in the run method, recollect dependencies, and trigger trackEffects:
    • toggle dep = {n: 2, w: 0} , shouldTrack to true to collect dependencies.
    • dep = {n: 2, w: 0} , shouldTrack is true, collecting dependencies.
  3. Enter the Child component, execute the initDepMarkers method in the run method, enter for the first time, and also collect dependencies, the deps length is 0, skip.
  4. Execute this.fn in the run method, re-collect dependencies, and trigger trackEffects:
    • toggle dep = {n: 4, w: 0} , shouldTrack to true to collect dependencies.

In this way, the collection dependency of entering the page for the first time is over, and then we click the button and change toggle to false:

  1. Parent : 执行run 方法中的initDepMarkers方法, Parent里收集到了两个变量的依赖, w :
    • toggle dep = {n: 0, w: 2}
    • visible dep = {n: 0, w: 2}
  2. Execute this.fn in the run method, re-collect dependencies, and trigger trackEffects:
    • toggle dep = {n: 2, w: 2} , shouldTrack to false, 不用 to collect dependencies.
    • visible 不显示了 , so it is not recollected, or {n: 0, w: 2} .
  3. 进入Child ,执行run 方法中的initDepMarkers方法,之前收集过toggle ,将toggle 的w 做标记,toggle 的dep = {n: 0, w: 4} .
  4. Execute this.fn in the run method, re-collect dependencies, and trigger trackEffects:
    • The toggle dep = {n: 4, w: 4} and shouldTrack are false, no need to collect dependencies.

Finally found visible with w without n , delete the invalid dependency in finalizeDepMarkers .

How to trigger dependencies:

In the source code of ref mentioned at the beginning, you can see that setter will be called when triggerRefValue trigger dependencies:

 function triggerRefValue(ref, newVal) {
    ref = toRaw(ref);
    if (ref.dep) {
        {
            triggerEffects(ref.dep, {
                target: ref,
                type: "set",
                key: 'value',
                newValue: newVal
            });
        }
    }
}

function triggerEffects(
  dep: Dep | ReactiveEffect[]
) {
  // 循环去取每个依赖的副作用对象 ReactiveEffect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // effect !== activeEffect 防止递归,造成死循环
    if (effect !== activeEffect || effect.allowRecurse) {
      // effect.scheduler可以先不管,ref 和 reactive 都没有
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        // 执行 effect 的副作用函数
        effect.run()
      }
    }
  }
}

The ultimate purpose of triggering dependency is actually to execute 依赖 每个的副作用对象 of 副作用函数 , and the side effect function here may be to execute update view, watch data monitoring, calculation attribute, etc. .


🤨🧐 I personally encountered a problem when I looked at the source code again. I don’t know if you have encountered it (the code version I read is relatively new v3.2.37). At first, I also read some source code analysis articles on the Internet and saw a lot of them. effect this function, let’s take a look at the source code of this method:

 function effect(fn, options) {
    if (fn.effect) {
        fn = fn.effect.fn;
    }
    const _effect = new ReactiveEffect(fn);
    if (options) {
        extend(_effect, options);
        if (options.scope)
            recordEffectScope(_effect, options.scope);
    }
    if (!options || !options.lazy) {
        _effect.run();
    }
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    // 返回一个包装后的函数,执行收集依赖
    return runner;
}

This function looks very simple, create a ReactiveEffect side effect object, append the parameters passed in by the user to the object, and then call the run method to collect dependencies, if there is lazy Configuration will not automatically collect dependencies. Users can actively execute the function packaged by the effect, and they can also collect dependencies correctly.

🤨🧐 But I looked around and found that there is no place in the source code called, so I wondered if I had used it before, and now I have removed it. I went to the commit record to find it, and I found it:

This update changes ReactiveEffect to a class to avoid creating effect runner when unnecessary, saving 17% of memory, etc.

The original effect method includes the current ReactiveEffect , which is directly referenced in view update rendering, watch and other places, but after the update, it is directly new ReactiveEffect , and then go to trigger the run method, do not go effect , it can be said that the current ReactiveEffect class is the former effect .

 export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
const effect = createReactiveEffect(fn, options)
return effect
}

let uid = 0

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)
activeEffect = effect
return fn()
} finally {
effectStack.pop()
resetTracking()
const n = effectStack.length
activeEffect = n > 0 ? effectStack[n - 1] : undefined
}
}
} 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
}