The introduction
A few days ago, I wrote an article about the implementation of the Reactive API source code for VUE 3.0. I found that there is a lot of interest in the source code. The number of readers is small, but it is still large! However, in the previous article, we did not analyze how Proxy works with Effect to achieve the principle of responsiveness, that is, relying on the process of collecting and distributing updates.
So, this time we’ll take a look at how Vue 3.0 relies on collecting and distributing updates.
It is worth mentioning that in
Vue 3.0
In no
watcher
“, and instead
effect
So we’re going to be dealing with a lot of sums
effect
Related function
First, prepare before starting
Before the beginning of the article, let’s prepare such a simple case to analyze the logic later:
Main.js project entry
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
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>
Install the rendering Effect
First of all, we all know that in general, our pages will use some properties, computed properties, methods, and so on of the current instance. As a result, dependency collection occurs during component rendering. Therefore, we start with the component rendering process.
During the rendering process of the component, a rendering effect is installed (created). Vue 3.0 determines whether there is subscription data when compiling Template and creates the corresponding rendering effect, which 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); };
Let’s take a rough look at setUprenderEffect (). It passes in several parameters, which are:
instance
The currentvm
The instanceinitialVNode
It could be a componentVNode
Or ordinaryVNode
container
Mounted templates, for examplediv#app
Corresponding nodeanchor
.parentSuspense
.isSVG
In general, bothnull
Then create an attribute update on the current instance that is assigned to effect(), which passes in two arguments:
componentEffect()
Function, which will come up after the actual logic, but we won’t get into it here-
CreateDevEffectOptions (instance) is used for subsequent dispatch updates, which 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 }
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; }
The logic of the effect() function is relatively simple. The first step is to determine if it is already an effect, which is defined before it is fetched. If not, create an Effect with ceateReactiveEffect(), whereas the logic for createReactiveEffect () 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; }
You can see in createReactiveEffect() that we define a ReactiveEffect() function to assign to Effect, which then calls the run() method. Three parameters are passed in the run() method, respectively:
effect
, i.e.,reactiveEffect()
The function itselffn
, that is, at the very beginninginstance.update
Is to calleffect
Function is passed in as a functioncomponentEffect()
args
For an empty array
Also, there are some initializations to effect, such as the deps in Vue 2X, which we are most familiar with, appearing on the effect object.
Then, let’s examine the logic of the run() function:
function run(effect, fn, args) { if (! effect.active) { return fn(... args); } if (! effectStack.includes(effect)) { cleanup(effect); try { enableTracking(); effectStack.push(effect); activeEffect = effect; return fn(... args); } finally { effectStack.pop(); resetTracking(); activeEffect = effectStack[effectStack.length - 1]; }}}
In this case, when we first create the effect, we hit the second branch logic, which means that the current effectStack stack does not contain the effect. Then, it first executes cleanup(effect), which iterates over effect.deps to clear the previous dependency.
cleanup()
The logic of
Vue 2x
Some of the source code, to avoid repeated collection of dependencies. And by contrast
Vue 2x
.
Vue 3.0
In the
track
It’s the same thing as
watcher
In the
track
We’ll talk about dependency collection in the following section
track
The concrete implementation of
Then, execute enableTracking() and effectStack.push(effect). The logic of the former is simple enough that it can be traced and used to trigger a subsequent track:
function enableTracking() {
trackStack.push(shouldTrack);
shouldTrack = true;
}
In the latter, the current effect is added to the effectStack stack. 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);
The next step is to enter the rendering process of the component, which involves
renderComponnetRoot
,
patch
Wait, this time we won’t be looking at component rendering details.
Rendering Effect is installed to prepare for subsequent dependency collection. That’s because you’ll use the effect() function defined in SetUprenderEffect later, and you’ll call the run() function. So, here we go, into the dependency collection section.
Third, dependent collection
get
Earlier, we talked about how rendering effects are installed 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 */;
return renderContext[key];
}
...
}
As you can see, this hits AccessCache [key] = 1 and RenderContext [key]. The former serves as a cache, while the latter retrieves the value of the key from the current rendering context (for this case, the key corresponds to count, which has a value of 0).
So, I think you’re going to react immediately at this point, which is going to trigger this get of the count for the Proxy. However, in our case, toRefs() is used to export reactive package, so the process of triggering GET can be divided into two stages:
The difference between the two phases is that the first phase
target
As a
object
(That is to say, above
toRefs
Object structure), while the second phase
target
为
Proxy
object
{count: 0}
. See me for details
article
Proxy object toRefs() to get the structure of the object:
{value: 0 _isRef: true get: function() {} set: LOGON {}}
Let’s look at the logic of get() first:
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)) {
return res;
}
...
// 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;
};
}
Stage 1: Trigger the normal object
get
Since this is the first stage, we will hit the isRef() logic and return res.value. This triggers the GET of the Reactive Proxy object. Also note that toRefs() can only be used on objects, otherwise we can’t get the corresponding value even if we trigger GET (this is actually some of the benefits of looking at the source code and understanding how the API is used).
track
Stage 2: Trigger
Proxy
The object’s
get
This is the second stage, 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;
As you can see, the first dependency collection is done by calling the track() function, which 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 }); }}}
As you can see, the first branch logic won’t hit because we already defined ishOldTrack = true and activeEffect = effect when we analyzed run() earlier. Then hit depsMap === void 0 logic and add an empty Map with the key {count: 0} to the targetMap:
if (depsMap === void 0) {
targetMap.set(target, (depsMap = new Map()));
}
And in this case, we can also compare
Vue 2.x
the
{count: 0}
It’s essentially the same thing as
data
Options (hereinafter collectively referred to as
data
). So, this can also be interpreted as first of all
data
Initialize a
Map
Obviously this one
Map
What’s stored in is the corresponding properties
dep
Then initialize a Map for the count attribute and insert it into the data option, i.e. :
let dep = depsMap.get(key);
if (dep === void 0) {
depsMap.set(key, (dep = new Set()));
}
So the dep is the subject object for the count property. Next, it determines whether the current ActiveEffect exists in the subject of Count. If it does not, it adds an ActiveEffect to the subject dep and adds the current subject dep to the ActiveEffect deps array.
if (! dep.has(activeEffect)) { dep.add(activeEffect); activeEffect.deps.push(dep); // The final branch logic, we will not hit}
Finally, if you go back to get(), it 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;
conclusion
Okay, we’ve analyzed the reactive dependency collection process. Let’s recall some 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 the Effect, and some properties are created on the Effect, such as the very important deps to hold the dependencies.
Next, when the component uses a variable in data, it accesses the get() of the corresponding variable. The first access to get() creates the depsMap, or targetMap, corresponding to the data. Then add the Map of the corresponding attribute to the depMap of the targetMap, i.e. DepSMAP.
After creating the DepSMAP for the attribute, on one hand, the current ActiveEffect is added to the DepSMAP for the attribute, that is, the subscriber is collected. On the other hand, add the DEPSMAP of this property to the DEPS array of ActiveEffect, that is, to subscribe to the topic. Thus, the entire dependency collection process is formed.
The whole
get
Process flow chart
IV. Distribution of updates
set
After analyzing the process of dependency collection, the entire process of distributing updates should follow. First of all, corresponding update is distributed, which means that when a topic changes, in our case, when count changes, the set() of data will be triggered at this time, that is, the target is data, and the 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; };
As you can see, oldValue is 0, and our shallow is now false with a value of 1. So, let’s look at the logic of the toRaw() function:
function toRaw(observed) {
return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed;
}
There are two variables of type WeakMap in toRaw(), reactiveToraw and readOnlyRaw. The former is to store the corresponding Proxy object in the Map of ReactiveToraw when reactive is initialized. 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); . }
Obviously for the toRaw() method, it returns observer as 1. So go back to the set() logic and call reflect.set () to change the value of count on the data to 1. And then we hit the target === toRaw(receiver) logic.
The target === toRaw(receiver) logic handles two pieces of logic:
- Is fired if the property does not exist on the current object
triger()
Function correspondingadd
. - Or the property changes, triggering
triger()
Function correspondingset
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(); const computedRunners = new Set(); 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); }
Also, as you can see, there is a detail here that the distributed updates of calculated attributes take precedence over regular attributes.
In the trigger() function, we first get the depsMap of the subject object corresponding to the data in the current targetMap, which we defined in the track when the dependency collection was done.
Then, two Set collections, Effects and ComputedRunners, are initialized to record the effects of ordinary properties or calculated properties, a process that takes place in Addrunners ().
Next, we define a run() function that wraps the scheduleRun() function and passes different arguments to 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
}
It then iterates over the Effects and calls 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(); }}
At this point, we’ll hit Effect.Options. Scheduler! == void 0 logic. Next, call the effect.options.scheduler() function, which calls queueJob() :
scheduler
This property is in
setupRenderEffect
call
effect
Function is created when.
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
queueFlush();
}
}
A queue is used to maintain all
effect()
The delta function, in fact, is the same thing
Vue 2x
Similar, because we
effect()
The equivalent of
watcher
And the
Vue 2x
In the
watcher
The calls to the Queues exist specifically to maintain
watcher
The sequence of triggers, such as the late father
watcher
After the child
watcher
.
You can see that we add the effect() function to the queue and call queueFlush() to flush and call the queue:
function queueFlush() { if (! isFlushing && ! isFlushPending) { isFlushPending = true; nextTick(flushJobs); }}
For those of you familiar with the Vue 2X source code, you should know that the watcher in Vue 2X is also executed on the next tick, as is Vue 3.0. FlushJobs performs effect() on the queue:
function flushJobs(seen) { isFlushPending = false; isFlushing = true; let job; if ((process.env.NODE_ENV ! == 'production')) { seen = seen || new Map(); } 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); }}
FlushJob () does several things:
- First we initialize one
Map
A collection ofseen
, and then recursequeue
Queue procedures, callscheckRecursiveUpdates()
Record thejob
即effect()
Number of triggers. If more than100
This will throw an error. -
It then calls CallWithErrorHandling (), which executes the Job effect(), and all we know is that this effect is the reactiveEffect() that was created when createReactiveEffect() was created, so, The end result is the run() method, which executes the effect() originally defined in SetUPrenderEffectect:
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); };
This is the last stage of dispatching updates. RenderComponentRoot () creates the component VNode, and then patch(), which goes through the process of rendering the component (of course, update is more appropriate at this time). Thus, the update of the view is completed.
conclusion
As such, let’s recall a few key points of the distribution process. First, the set() of the dependency is triggered, which calls reflect.set () to change the value of the property corresponding to the dependency. Then, the trigger() function is called to get the subject of the corresponding attribute in the targetMap, which is depsMap(), and the effect() in depsMap is stored in the effect collection. Next, you queue up the effects and clear and execute all the effects in the next tick. Finally, as mentioned during initialization, go through the component update process, namely renderComponent(), patch(), and so on.
The whole
set
Process flow chart
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 the global rendering effect(), and then call track() in get() for dependency collection. Next, if the dependency changes, it goes through the process of sending out 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.
Writing is not easy, if you feel there is a harvest of words, can be handsome triple hit!!