banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

❗Multiple Image Warning · How does Vue solve Glitching avoidance?

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 var1
  • var1 = 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

image

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:

image

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:

Figure 1: The value update of Ref is intercepted by set to trigger the side effects that depend on this ref

Figure 2: Read the dependencies dep from the data carried by ref to trigger these dependencies

Figure 3: The final position where side effects start to trigger

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:

image

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:

image

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!

image

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:

image

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.

image

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 test watchEffect, and the result turned out to be such a large chunk? What is this? Taking a rough look, it seems to be wrapped by a call... 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 inside watchEffect.

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:

Overview of doWatch function

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 👀🌟:

image

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:

image

The two parameters of the ReactiveEffect constructor represent:

  1. The side effect function itself
  2. 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:

image

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:

image

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():

image

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:

image

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."

Effect 2: 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:

image

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:

image

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:

image

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:

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:

  1. Reactions, executing side effects.
  2. 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:

image

That's all about Glitching Avoidance. Thank you for reading this far. If you found it helpful, feel free to give a thumbs up!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.