preface

A few days ago, I wrote an article about the source code implementation of Vue 3.0 Reactive API, and found that people are quite interested in the source code. Although the number of readers is small, but more than 200 reading, is still huge! In addition, some students pointed out the shortcomings of the article, that is, it did not analyze how Proxy achieves the principle of responsiveness with Effect, that is, it relies on the process of collecting and distributing updates.

So, this time we’ll take a thorough look at how Vue 3.0 relies on the entire process of collecting and distributing updates.

It is worth noting that in Vue 3.0, the concept of Watcher was removed and replaced with Effect, so there will be a lot of functions related to effect

1. Prepare before starting

At the beginning of the article, we prepare such a simple case for subsequent analysis of the specific logic:

Main.js project entry

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
Copy the code

App. Vue components

<template>
  <button @click="inc">Clicked {{ count }} times.</button>
</template>

<script>
import { reactive, toRefs } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,})const inc = () = > {
      state.count++
    }

    return{ inc, ... toRefs(state) } } }</script>
Copy the code

Install render Effect

First, we all know that, in general, our pages use some of the properties, calculation properties, methods, and so on of the current instance. As a result, the dependency collection process occurs during component rendering. Therefore, we will start with the component rendering process.

Vue 3.0 creates a render effect when the template is compiled to determine whether the subscription data is present. This effect is defined as follows:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG) = > {
    // create reactive effect for rendering
    instance.update = effect(function componentEffect() {... instance.isMounted =true;
        }
        else{... } }, (process.env.NODE_ENV ! = ='production')? createDevEffectOptions(instance) : prodEffectOptions); };Copy the code

Let’s briefly analyze setupRenderEffect(). It passes several arguments, which are:

  • instanceThe currentvmThe instance
  • initialVNodeIt could be a componentVNodeOr ordinaryVNode
  • containerMounted templates, for examplediv#appCorresponding node
  • anchor.parentSuspense.isSVGUnder normal circumstancesnull

We then create the property update on the current instance and assign it to effect(), which takes two arguments:

  • componentEffect()Delta function, which will come after the logic, but we won’t talk about it here
  • createDevEffectOptions(instance)For subsequent distribution updates, it returns an object:
{
    scheduler: queueJob(job) {
                    if (!queue.includes(job)) {
                        queue.push(job);
                        queueFlush();
                    }
                },
    onTrack: instance.rtc ? e= > invokeHooks(instance.rtc, e) : void 0.onTrigger: instance.rtg ? e= > invokeHooks(instance.rtg, e) : void 0
}
Copy the code

Then, let’s look at the effect() function definition:

function effect(fn, options = EMPTY_OBJ) {
    if (isEffect(fn)) {
        fn = fn.raw;
    }
    const effect = createReactiveEffect(fn, options);
    if(! options.lazy) { effect(); }return effect;
}
Copy the code

The logic of effect() function is relatively simple. First, determine whether it is already effect, and if so, extract the previously defined function. If not, create an effect through ceateReactiveEffect(), and the logic of creatReactiveEffect() would look like this:

function createReactiveEffect(fn, options) {
    const effect = function reactiveEffect(. args) {
        return run(effect, fn, args);
    };
    effect._isEffect = true;
    effect.active = true;
    effect.raw = fn;
    effect.deps = [];
    effect.options = options;
    return effect;
}
Copy the code

You can see that in createReactiveEffect() a reactiveEffect() function is defined and assigned to effect, which in turn calls the run() method. The run() method takes three arguments, respectively:

  • effect, i.e.,reactiveEffect()The function itself
  • fnThat is, in the beginninginstance.updateIs to calleffectFunction when the function is passed incomponentEffect()
  • argsIs an empty array

Also, some initialization is done to Effect, such as the deps from Vue 2X, which is most familiar, on the effect object.

Next, let’s examine the logic of the run() function:

function run(effect, fn, args) {
    if(! effect.active) {returnfn(... args); }if(! effectStack.includes(effect)) { cleanup(effect);try {
            enableTracking();
            effectStack.push(effect);
            activeEffect = effect;
            returnfn(... args); }finally {
            effectStack.pop();
            resetTracking();
            activeEffect = effectStack[effectStack.length - 1]; }}}Copy the code

In this case, the first time we create effect, we hit the second branch, which is not currently in the effectStack. Cleanup (effect), which runs through effect.deps to cleanup previous dependencies.

The logic for cleanup() is also available in the Vue 2X source code to avoid repeated collections of dependencies. In addition, compared with Vue 2X, Track in Vue 3.0 is actually equivalent to Watcher, which will collect dependencies in track. We will talk about the specific implementation of Track later

Then, execute enableTracking() and effectStack.push(effect), the logic of the former is very simple, that is, can be traced, for subsequent trigger track judgment:

function enableTracking() {
    trackStack.push(shouldTrack);
    shouldTrack = true;
}
Copy the code

In the latter, the current effect is added to the effectStack. Finally, execute fn(), the componentEffect() we passed in when we first defined instance.update = effect() :

instance.update = effect(function componentEffect() {
    if(! instance.isMounted) {const subTree = (instance.subTree = renderComponentRoot(instance));
        // beforeMount hook
        if(instance.bm ! = =null) {
            invokeHooks(instance.bm);
        }
        if (initialVNode.el && hydrateNode) {
            // vnode has adopted host node - perform hydration instead of mount.
            hydrateNode(initialVNode.el, subTree, instance, parentSuspense);
        }
        else {
            patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);
            initialVNode.el = subTree.el;
        }
        // mounted hook
        if(instance.m ! = =null) {
            queuePostRenderEffect(instance.m, parentSuspense);
        }
        // activated hook for keep-alive roots.
        if(instance.a ! = =null &&
            instance.vnode.shapeFlag & 256 /* COMPONENT_SHOULD_KEEP_ALIVE */) {
            queuePostRenderEffect(instance.a, parentSuspense);
        }
        instance.isMounted = true;
    }
    else{... } }, (process.env.NODE_ENV ! = ='production')? createDevEffectOptions(instance) : prodEffectOptions);Copy the code

And then we will enter the rendering process of components, which involves renderComponnetRoot, Patch and so on. This time we will not analyze the specific details of component rendering.

The installation of Render Effect is a preliminary preparation for subsequent dependency collection. The effect() function defined in setupRenderEffect will be used later, and the run() function will be called. So, next, we will formally enter the dependency collection part of the analysis.

Rely on collection

get

Earlier, we talked about installing render effects during component rendering. Then, entered the stage of rendering component, namely renderComponentRoot (), and invokes the proxyToUse, namely will trigger runtimeCompiledRenderProxyHandlers get, namely:

get(target, key){...else if(renderContext ! == EMPTY_OBJ && hasOwn(renderContext, key)) { accessCache[key] =1 /* CONTEXT */;
        returnrenderContext[key]; }... }Copy the code

AccessCache [key] = 1 and renderContext[key] are hit. The former is used as a cache, while the latter is used to fetch the value of the key from the current render context (for this case, the key is count, which is 0).

So, I think people are going to react immediately, and they’re going to trigger the get of the Proxy for this count. However, in our case, toRefs() is used to export the reactive package, so the process of triggering get is divided into two stages:

The difference between the two phases is that the target of the first phase is an object (the object structure of the toRefs mentioned above), while the target of the second phase is a Proxy object {count: 0}. See my last post for more details

Proxy object toRefs() returns the structure of the object:

{
    value: 0
    _isRef: true
    get: function() {}
    set: ƒunction(newVal){}}Copy the code

Let’s first look at the logic of get() :

function createGetter(isReadonly = false, shallow = false) {
    return function get(target, key, receiver) {...const res = Reflect.get(target, key, receiver);
        if (isSymbol(key) && builtInSymbols.has(key)) {
            returnres; }...// ref unwrapping, only for Objects, not for Arrays.
        if(isRef(res) && ! isArray(target)) {return res.value;
        }
        track(target, "get" /* GET */, key);
        return isObject(res)
            ? isReadonly
                ? // need to lazy access readonly and reactive here to avoid
                    // circular dependency
                    readonly(res)
                : reactive(res)
            : res;
    };
}
Copy the code

Phase 1: Trigger get for ordinary objects

Since this is phase 1, we will hit the logic of isRef() and return res.value. This triggers the GET of the Proxy object defined by Reactive. It is important to note that toRefs() can only be used on objects, otherwise we cannot get the corresponding value even if we trigger get.

track

Phase 2: Trigger get of Proxy object

This is phase 2, so we hit the final logic of get:

track(target, "get" /* GET */, key);
return isObject(res)
    ? isReadonly
        ? // need to lazy access readonly and reactive here to avoid
            // circular dependency
            readonly(res)
        : reactive(res)
    : res;
Copy the code

As you can see, the track() function is first called for dependency collection, and the track() function is defined as follows:

function track(target, type, key) {
    if(! shouldTrack || activeEffect ===undefined) {
        return;
    }
    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(activeEffect)) { dep.add(activeEffect); activeEffect.deps.push(dep);if((process.env.NODE_ENV ! = ='production') && activeEffect.options.onTrack) {
            activeEffect.options.onTrack({
                effect: activeEffect, target, type, key }); }}}Copy the code

As you can see, the first branch logic will not hit because we defined ishouldTrack = true and activeEffect = effect when we analyzed run() earlier. Then, hit depsMap === void 0 logic and add an empty Map to targetMap with key {count: 0} :

if (depsMap === void 0) {
    targetMap.set(target, (depsMap = new Map()));
}
Copy the code

In this case, we can also compare Vue 2.x, this {count: 0} is actually equivalent to the data option. Therefore, it can also be interpreted as initializing a Map of data. Obviously, this Map stores the DEP corresponding to different attributes

Then initialize a Map on the count property and insert it into the data option:

let dep = depsMap.get(key);
if (dep === void 0) {
    depsMap.set(key, (dep = new Set()));
}
Copy the code

Therefore, the deP is the subject object corresponding to the count attribute. Next, it determines whether the current activeEffect exists in the topic of count, and if it does not, it adds the activeEffect to the topic DEP and the current topic DEP to the activeEffect DEPS array.

if(! dep.has(activeEffect)) { dep.add(activeEffect); activeEffect.deps.push(dep);// Last branch logic, we will not hit this time
}
Copy the code

Finally, go back to get(), which will return the value of res, which in our case is 0.

return isObject(res)
            ? isReadonly
                ? // need to lazy access readonly and reactive here to avoid
                    // circular dependency
                    readonly(res)
                : reactive(res)
            : res;
Copy the code

conclusion

All right, the dependency collection process of Reactive has been analyzed. To recall a few of the key points, first during the component rendering process, an Effect is created for the current VM instance, then the current activeEffect is assigned to effect and some properties are created on effect, such as the very important DEPS to hold dependencies.

Next, when the component uses a variable in data, it accesses the corresponding variable’s GET (). The first access to get() creates data’s depsMap, which is targetMap. Then add the Map of the corresponding attribute to the depMap of targetMap, that is, depsMap.

After the property’s depsMap is created, on the one hand the current activeEffect is added to the property’s depsMap, that is, subscribers are collected. On the other hand, add the depsMap of this property to the activeEffect DEPS array, that is, subscribe to the topic. Thus, the entire dependency collection process is formed.

Flowchart of the entire GET process

Iv. Distribute updates

set

Once the dependency collection process is analyzed, the entire process of distributing updates will follow. First of all, the corresponding update is distributed, which means that when a topic changes, in our case, when count changes, the set() of data will be triggered, that is, target is data and key is count.

function set(target, key, value, receiver) {...const oldValue = target[key];
        if(! shallow) { value = toRaw(value);if(! isArray(target) && isRef(oldValue) && ! isRef(value)) { oldValue.value = value;return true; }}const hadKey = 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) { trigger(target,"add" /* ADD */, key, value);
            }
            else if (hasChanged(value, oldValue)) {
                trigger(target, "set" /* SET */, key, value, oldValue); }}return result;
    };
Copy the code

As you can see, oldValue is 0, while our shallow is now false and value is 1. So, let’s look at the logic of the toRaw() function:

function toRaw(observed) {
    return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed;
}
Copy the code

There are two WeakMap variables reactiveToRaw and readonlyRaw in toRaw(). When reactive is initialized, the corresponding Proxy object is stored in the reactiveToRaw Map. The latter is to store the opposite key value pair. That is:

function createReactiveObject(target, toProxy, toRaw, baseHandlers, collectionHandlers) {... observed =new Proxy(target, handlers); toProxy.set(target, observed); toRaw.set(observed, target); . }Copy the code

Obviously for the toRaw() method, it returns observer 1. So, going back to the logic of set(), call reflect.set () to change the count value on data to 1. And then we hit the logic of target === toRaw(receiver).

The target === toRaw(receiver) logic handles two logics:

  • If the property does not exist on the current object, the corresponding Add of triger() is triggered.

  • Or the property changes, triggering the corresponding set of the triger() function

trigger

First, let’s look at the definition of the trigger() function:

function trigger(target, type, key, newValue, oldValue, oldTarget) {
    const depsMap = targetMap.get(target);
    if (depsMap === void 0) {
        // never been tracked
        return;
    }
    const effects = new Set(a);const computedRunners = new Set(a);if (type === "clear" /* CLEAR */) {... }else if (key === 'length' && isArray(target)) {
        ...
    }
    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 | Map.SET
        if (type === "add" /* ADD */ ||
            (type === "delete" /* DELETE */ && !isArray(target)) ||
            (type === "set" /* SET */ && target instanceof Map)) {
            const iterationKey = isArray(target) ? 'length': ITERATE_KEY; addRunners(effects, computedRunners, depsMap.get(iterationKey)); }}const run = (effect) = >{ scheduleRun(effect, target, type, key, (process.env.NODE_ENV ! = ='production')? { newValue, oldValue, oldTarget } :undefined);
    };
    // 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);
}
Copy the code

Also, you can see that there is a detail here, which is that the distributed update of the calculated property takes precedence over the normal property.

In the trigger() function, we first get the depsMap of the subject object corresponding to data in the current targetMap, which we defined in track when we relied on the collection.

Then, two sets of Effects and computedRunners are initialized to record the effects of common properties or calculated properties, and this process is performed in addRunners().

Next, we define a run() function that wraps the scheduleRun() function and passes different parameters between the development and production environments. In this case, since we are in the development environment, we pass in an object, namely:

{
    newValue: 1.oldValue: 0.oldTarget: undefined
}
Copy the code

It then iterates through effects, calling the run() function, which actually calls scheduleRun() :

function scheduleRun(effect, target, type, key, extraInfo) {
    if((process.env.NODE_ENV ! = ='production') && effect.options.onTrigger) {
        const event = {
            effect,
            target,
            key,
            type
        };
        effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event);
    }
    if(effect.options.scheduler ! = =void 0) {
        effect.options.scheduler(effect);
    }
    else{ effect(); }}Copy the code

At this point, we hit effect.options.scheduler! == logic of void 0. Next, we call the effect.options.scheduler() function, which calls queueJob() :

The scheduler property is created when setupRenderEffect calls the effect function.

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
        queueFlush();
    }
}
Copy the code

A queue is used to maintain all effect() functions, which is similar to Vue 2X because effect() is the equivalent of watcher, and Vue 2X calls to Watcher are also maintained through queues. Queues exist specifically to preserve the order in which watcher fires, such as parent watcher after child Watcher.

As you can see, we add the effect() function to the queue and then call queueFlush() to clear and call the queue:

function queueFlush() {
    if(! isFlushing && ! isFlushPending) { isFlushPending =true; nextTick(flushJobs); }}Copy the code

For those familiar with the Vue 2X source code, watcher in Vue 2X is also executed in the next tick, as is Vue 3.0. In flushJobs, effect() is executed in the queue:

function flushJobs(seen) {
    isFlushPending = false;
    isFlushing = true;
    let job;
    if((process.env.NODE_ENV ! = ='production')) {
        seen = seen || new Map(a); }while((job = queue.shift()) ! = =undefined) {
        if (job === null) {
            continue;
        }
        if((process.env.NODE_ENV ! = ='production')) {
            checkRecursiveUpdates(seen, job);
        }
        callWithErrorHandling(job, null.12 /* SCHEDULER */);
    }
    flushPostFlushCbs(seen);
    isFlushing = false;
    if(queue.length || postFlushCbs.length) { flushJobs(seen); }}Copy the code

FlushJob () does several things:

  • So let’s initialize oneMapA collection ofseenAnd then you recursequeueQueue procedure, callcheckRecursiveUpdates()Record thejobeffect()Number of times triggered. If more than100An error is thrown.
  • And then callcallWithErrorHandling(), the implementation ofjobeffect()And this is what we all knoweffectIs in thecreateReactiveEffect()Created whenreactiveEffect(), so, it will be executed eventuallyrun()Method that executes initially insetupRenderEffectectThe definition of theeffect():
    const setupRenderEffectect = (instance, initialVNode, container, anchor, parentSuspense, isSVG) = > {
        // create reactive effect for rendering
        instance.update = effect(function componentEffect() {
            if(! instance.isMounted) { ... }else{...const nextTree = renderComponentRoot(instance);
                const prevTree = instance.subTree;
                instance.subTree = nextTree;
                if(instance.bu ! = =null) {
                    invokeHooks(instance.bu);
                }
                if(instance.refs ! == EMPTY_OBJ) { instance.refs = {}; } patch(prevTree, nextTree, hostParentNode(prevTree.el), getNextHostNode(prevTree), instance, parentSuspense, isSVG); instance.vnode.el = nextTree.el;if (next === null) {
                    updateHOCHostEl(instance, nextTree.el);
                }
                if(instance.u ! = =null) {
                    queuePostRenderEffect(instance.u, parentSuspense);
                }
                if((process.env.NODE_ENV ! = ='production')) { popWarningContext(); } } }, (process.env.NODE_ENV ! = ='production')? createDevEffectOptions(instance) : prodEffectOptions); };Copy the code

RenderComponentRoot () is used to create VNode, and then patch() is used to complete the component rendering process. Thus, the view is updated.

conclusion

Similarly, let’s recall a few key points of the distribution process. First, the dependency’s set() is fired, which calls reflect.set () to modify the value of the dependency’s corresponding property. The trigger() function is then called to get the subject of the corresponding attribute in targetMap, which is depsMap(), and effect() from depsMap is stored in the Effect collection. Next, queue effects, clear and execute all effects on the next tick. Finally, as mentioned in initialization, we go through the component update process, renderComponent(), patch(), and so on.

The flow chart of the whole set process

conclusion

Although, the whole process of relying on collection took me 9 hours to summarize and analyze, and the content of the whole article reached 4K + words. But that doesn’t mean it’s complicated. The whole process of relying on collecting and distributing updates is pretty straightforward. First define global render effect(), then call track() in get() for dependency collection. Next, if the dependency changes, it goes through the process of issuing updates, first updating the value of the dependency, then calling trigger() to collect effect(), executing effect() in the next tick, and finally updating the component.

I am Wu Liu, like innovation, tamping source Code, focus on Vue3 source Code, Vite source Code, front-end engineering and other technology sharing, welcome to pay attention to my “wechat public number: Code Center”.