What is Glitching Avoidance?#
Today, there are many web front-end libraries based on the reactivity mechanism to listen to and handle data changes. In a reactive system, there is an issue that needs attention. Without further ado, let's take a look at the following piece of code:
const var1 = ref(1)
const var2 = computed(() => var1.value * 2)
const var3 = computed(() => var1.value + var2.value)
// Execute with two different effect functions and check the console output
effect(() => {
console.log('@vue/reactiviy effect: var3 =', var3.value)
})
watchEffect(() => {
console.log('@vue/runtime-core watchEffect: var3 = ', var3.value)
})
// Now let's change var1
var1.value = 2
I created an online Playground for the above code:
https://stackblitz.com/edit/typescript-y4kx5e?file=index.ts
Let's check the output:
@vue/reactiviy effect: var3 = 3
@vue/reactiviy effect: var3 = 4
@vue/reactiviy effect: var3 = 6
@vue/runtime-core watchEffect: var3 = 3
@vue/runtime-core watchEffect: var3 = 6
Since we know that both effect
and watchEffect
will execute once initially to get the relevant dependencies in the running function, we can conclude that effect
executed 2 times, while watchEffect
only executed 1 time.
Why did effect
execute one extra time?
This is because var3
has 2 dependencies: var1
and var2
. Whenever either of these dependencies changes, the callback of effect
will re-execute, and var2
will also update due to the change in var1
.
However, let's look at the actual values of var1
and var2
during these two executions:
var1
= 2,var2
= 2 (old value) this execution was triggered by the update of var1var1
= 2,var2
= 4 (new value) this execution was triggered by the update of var2
Clearly, the situation where var2
is still the old value is unexpected. This intermediate state process is referred to as "glitching."
Does it remind you of a situation where an element on the page is controlled by the initial state, and the state is updated immediately at the start, resulting in a flicker?
However, we see that if we use watchEffect
, this issue does not occur.
What does watchEffect
do differently compared to effect
?
Source Code Debugging Journey#
What you need to prepare before debugging...#
The best way to debug the source code of an excellent library is to find its unit test section, and once the environment is set up, you can happily enter the test area and play with breakpoints!
You can see that I opened the apiWatch.spec.ts
in the Vue repository and added a unit test.
How did the test panel on the left get there? It even supports searching based on the test case titles! Amazing! What you are using is actually the official Vitest plugin ZixuanChen.vitest-explorer
Since we need to debug the series of side effects triggered after the "value is updated" of var1.value = 2
, we need to set breakpoints in the appropriate locations (as shown in the image above).
After setting the breakpoints, you can click Start Debugging on the left:
Stop 1 · Triggering Side Effects#
As we enter the debugging process, we see that it is about to trigger the side effects subscribed to var1
:
At this point, let's pause and analyze:
We are triggering all the side effects that "subscribe to var1
," namely the computed functions var2
and var3
. So how do we determine which effect is in this round of the for loop? We can first take a look at the definition of ReactiveEffect
:
ReactiveEffect
has a public
property fn
, which is actually the corresponding side effect function. We can print effect.fn
in the debugging console to see the original string of this function, and based on the content, we can determine to whom it belongs:
We can see that this is the computed function of var3
, which means we are about to trigger this computed function to re-execute and update its value for var3
.
The analysis stops here for now! 🚄 Let's continue!
As we formally enter triggerEffect
, since we are triggering the computed var3
, the source code states: if an effect has a scheduler
, it should be called first.
Upon entering, we find that Computed has added such a scheduler
for its bound side effects during its creation:
What this scheduler does is: mark the current computed property as "dirty" 😜, and since computed properties are also reactive variables, they may be subscribed to by other places, thus the "re-computation" of this update process will also trigger its side effects (see the code in line 47 of the above image).
Undoubtedly, in our unit test code, the only place that subscribed to var3
is the function inside watchEffect
. So we continue to follow the scheduler to execute triggerRefValue
, which will go through many of the steps we just discussed, so we won't elaborate further.
A key "checkpoint" is at the two for loops in triggerEffects
, where you can see the array form of the side effect list.
This image contains a lot of information, so let's summarize:
- When debugging source code, you must learn to look at the function call stack frame on the left. Because in many complex library executions, some logic may be recursively or repeatedly traversed, and at this time, you need to know where you are and what the current execution is for, so as not to lose direction. Currently, we are still on the synchronous task of "set process triggering side effects" for
var1.value = 2
. - We printed
effect.fn
again to confirm whether it is the function we wrote in the unit testwatchEffect
, and the result turned out to be such a large chunk? What is this? Taking a rough look, it seems to be wrapped by acall...
with error handling. We need to continue exploring with this question in mind. - Entering the second for loop means that this
effect
is not computed. This indirectly proves that it is the function insidewatchEffect
.
Stop 2 · Understanding watchEffect#
Entering triggerEffect
, we find once again that this side effect has a scheduler, and upon entering the scheduler, we see a function called doWatch
:
At this point, we need to make the second stop of this journey! This function is quite complex, and it seems to have a lot of logic, but don't be afraid.
🫵 When reading the source code, we need to grasp the main thread. I have collapsed the parts that are not important for our current study in the above image, and the screenshot shows the line currently being executed in the debug, which we mainly need to focus on starting from line 310, this job
.
In lines 345 - 347, the source code comments clearly indicate that watchEffect
will follow this path.
In addition, to control the length of the image, I also need to expand line 230 in the above image. Because the if-else
statements from 209 - 252 judge that the source, as the name suggests, is the target of the watch, which is the function we passed into watchEffect
, so it must enter the isFunction
branch:
When expanded, you will be pleasantly surprised 👀🌟:
The code in this getter
seems very familiar? Isn't this the piece of code we printed when effect.fn
and didn't know where it came from?
In line 367 of the doWatch
function (just below the above "Overview of doWatch function" image), a side effect is created, and we can see that the two parameters of the constructor are the getter
we just mentioned and the scheduler
created in the highlighted line:
The two parameters of the ReactiveEffect
constructor represent:
- The side effect function itself
- The scheduler for the side effect
Is it still feeling a bit confusing at this point? We have obtained so much information, but it seems like we still can't understand the relationship with watchEffect
, and perhaps your questions are increasing... Don't worry, take a deep breath, and let's see how this complex doWatch
relates to watchEffect
:
It turns out that doWatch
is the implementation of watchEffect
! However, in the apiWatch.ts
file, doWatch
is not only used here, but the well-known watch
API is also implemented using it; the distinction is that watchEffect
does not require a callback cb
(see the above image, the second parameter is passed as null
).
So, it is not difficult to conclude that the reason why the effect.fn
we printed is not the function we passed in is that it is wrapped with some additional processing in doWatch
, and the question is resolved! We can continue our journey~ 🏄🏻♂️
Stop 3 · A Ray of Hope#
Now we can roughly guess that the side effect function passed into watchEffect
seems to be pushed into a queue by queueJob
? Let's go in and take a look:
After carefully reading this piece of source code, we can conclude that if the queue is empty or does not contain the current job to be added, it will be pushed into the queue.
Then we continue to look at the upcoming queueFlush()
:
There are a bunch of variables defined somewhere, and the above queue
, flipping through the file, we can see that they are all defined at the top level:
To keep the research process focused on the core goal, we don't need to explore the role of each variable one by one; as I said, we should follow the main thread, or we might derail 😱!
The current execution at line 105 is a very interesting operation that places a function named flushJobs
into a Promise.then
.
Familiar with the event loop principle should understand that this places the function into the microtask queue. After each macro task in the current tick is completed, the microtask queue tasks will begin to execute. If you have any questions about this part of the knowledge, you can first come here to supplement your basics: https://zh.javascript.info/event-loop. The purpose of Vue's design in this way may be to ensure that the side effects triggered by the watched object do not block the main rendering process.
You can check the content of flushJobs
in the Vue source code; there is nothing much to say about it, just executing the functions in the queue in order, so I won't elaborate further.
At this point, we can say: "The change of var1
triggers the need for var3
to be recalculated," and this side effect has been pushed into the queue, but it has not yet been executed during the current user's synchronous code process. Next, we should look at "the change of var1
triggers the need for var2
to be recalculated."
The subsequent process repeats the steps we just saw: Computed is marked as "dirty," triggering its own side effects. var2
is also dependent on var3
, so following down will again trigger the scheduler
of var3
:
However, since var3
has already been marked as "dirty," the related side effect function of var3
is not triggered again. Then, continuing the execution, you will see the function stack frame starting to unwind, indicating the end of the round of side effect triggering process caused by updating var1
to 2.
To simulate the browser event loop in the test, that is, the process between the end of the current tick macro task and the next tick macro task (which is to execute the microtask queue), Vue's unit tests use a lot of await nextTick()
, so here we will follow suit:
Upon entering the execution of the microtask queue, you can see that the function stack frame is separated by a Promise.then
, and you can no longer check the variable values in the previous synchronous tasks within each stack frame:
At this point, our queue only has one job
, so adding the initial execution, the side effect in watchEffect
executed a total of only 2 times instead of 3 times.
So the conclusion is: Vue avoids the flickering of values caused by side effect responses by adding a scheduler, collecting side effect functions into a queue, and executing them in the microtask phase.
Something more ...#
In fact, "glitch avoidance" is a problem that push-based reactive data systems face and need to solve. If you are interested in deeper knowledge about reactive data systems, here are a few references for you:
- A Survey on Reactive Programming - https://blog.acolyer.org/2015/12/08/a-survey-on-reactive-programming
- Introduction to Backpressure in Reactive Programming - Combining Pull and Push Models - https://juejin.cn/post/7138241196744736799
The combination of delayed Effect + Lazy Computed in computed properties achieves Glitch Avoidance. Why can the combination of the two achieve Glitch Avoidance?
This is because there are only 2 types of listening to reactive data in front-end reactive frameworks:
- Reactions, executing side effects.
- Derivations, deriving new reactive data.
Among them, Reactions cannot depend on each other, and when Reactions are executed lazily, all Derivations are marked as "needing updates." When recalculating Derivations values, you only need to traverse the dependency tree, compute the latest values of upstream first, and you will always get the latest upstream values, achieving Glitch Avoidance.
The lazy execution of computed properties is borrowed from the pull model, which can be understood as the values being "pulled" down when needed.
Returning to our example: In fact, var3 = 6
and var2 = 4
were recalculated during the second execution of the side effect function in watchEffect
due to self.effect.run()
in the image below. Let's take a look at the implementation of get value()
in ComputedRefImpl
:
That's all about Glitching Avoidance. Thank you for reading this far. If you found it helpful, feel free to give a thumbs up!