Vue performs asynchronous updates to the DOM. Whenever it detects a change in data, Vue will start a queue and buffer all data changes that occur in the same event loop. If the same watcher is triggered multiple times, it will only be added to the queue once. This removal of duplicate data during buffering is crucial for avoiding unnecessary computation and DOM operations. Then, in the next event loop tick, Vue refreshes the queue and performs the actual (deduplicated) work. Vue internally attempts to use native Promise.then, MutationObserver, and setImmediate for asynchronous queueing, and if the execution environment does not support these, it will use setTimeout(fn, 0) instead.
In simple terms, Vue uses asynchronous rendering to improve performance. Without asynchronous updates, the current component would be re-rendered every time the data is updated. For performance reasons, Vue updates the view asynchronously after the data has been updated in the current round. For example, let's say we repeatedly update a value within a method:
In reality, what we really want is just the final update, meaning the first three DOM updates can be avoided. We only need to render after all states have been modified, reducing some performance overhead.
The rendering issue is quite clear: rendering only once will definitely consume less performance than rendering immediately after each modification. Here, we also need to consider the optimization of the asynchronous update queue.
If it were a synchronous update queue, for this.msg=1, it would roughly involve: updating msg value -> triggering setter -> triggering watcher update -> re-calling render -> generating a new vdom -> dom-diff -> dom update. This dom update is not rendering (i.e., layout, drawing, composition, and a series of steps) but updating the DOM tree structure in memory. The same process would be repeated for the second and third updates. When it comes to rendering, only the latest 3 in the updated DOM tree would be present, indicating that the operations on msg and its handling within Vue for the first 2 times are ineffective and can be optimized.
With an asynchronous update queue, for this.msg=1, it wouldn't immediately go through the above process but instead hold Watchers with dependencies on msg in the queue, which might look like [Watcher1, Watcher2 ...]. After this.msg=2, the Watchers with dependencies on msg would again be added to the queue, and Vue internally performs a deduplication check. After this operation, it can be considered that the queue data hasn't changed. The third update follows the same process. Of course, it's possible that there are operations on another property within the component, such as this.otherMsg=othermessage. The Watcher with dependencies on otherMsg would also be added to the asynchronous update queue. With duplicate checks, this Watcher would also only exist once in the queue. After this asynchronous task is completed, it would enter the next task execution process, namely iterating through each Watcher in the asynchronous update queue, triggering its update, and then going through the process of re-calling render -> new vdom -> dom-diff -> dom update. However, compared to the synchronous update queue, regardless of how many times msg is operated on, Vue internally only performs the re-calling of the real update process once. Therefore, the asynchronous update queue not only saves rendering costs, but also saves Vue internal computation and DOM tree operation costs, ensuring that rendering only occurs once, regardless of the method used.
In addition, components internally use VirtualDOM for rendering. This means that the component doesn't actually care which state has changed; it only needs to calculate once to determine which nodes need to be updated. In other words, if N states are changed, only one signal needs to be sent to update the DOM to the latest, even if multiple values are updated.
Here we modified three different states three times, but actually Vue only renders once, because VirtualDOM only needs to update the entire component's DOM once to the latest version, it doesn't care which specific state the update signal comes from.
To achieve this, we need to defer the rendering operation until all states are modified. To do this, we just need to postpone the rendering operation to the end of the current event loop or to the next event loop. In other words, we just need to execute the rendering operation once at the end of the current event loop, after all the preceding state update statements have been executed. It will ignore all the preceding state update syntax and render only once at the end, regardless of how many state update statements were written before.
Delaying rendering to the end of the current event loop is much faster than deferring to the next loop, so Vue prioritizes deferring the rendering operation to the end of the current event loop. If the execution environment does not support it, it will be downgraded to the next loop. Vue's change detection mechanism (setter) ensures that it will always emit a rendering signal whenever the state changes, but Vue will check the queue after receiving the signal to ensure that there are no duplicates in the queue. If the operation does not exist in the queue, it will add the rendering operation to the queue, and then delay the execution of all rendering operations in the queue and clear the queue in an asynchronous manner. When modifying states repeatedly within the same event loop, the same rendering operation will not be repeatedly added to the queue. Therefore, when using Vue, updating the DOM after modifying the state is asynchronous.
When the data changes, the notify method is called, the watcher is traversed, and the update method is called to notify the watcher to update. At this point, the watcher does not immediately execute. In the update, the queueWatcher method is called to put the watcher into a queue. In queueWatcher, it will dedupe based on the watcher. If multiple attributes depend on a watcher and the queue does not have the watcher, it will be added to the queue. Then the flushSchedulerQueue method will be added to the execution queue maintained by the $nextTick method, which will trigger the execution of all the callbacks in the buffer queue, and then the callback of the $nextTick method will be added to the execution queue maintained by the $nextTick method. In flushSchedulerQueue, a before method will be triggered, which is actually beforeUpdate, and then watcher.run will finally execute the watcher, and when the execution is complete, the page is rendered, and the updated hook is called after the update is complete.
In the previous discussion, we talked about why Vue adopts asynchronous rendering. Suppose we have a requirement to obtain the DOM elements of the page after it has been rendered. Since rendering is asynchronous, we cannot directly and synchronously obtain this value in the defined method. This is where the vm.$nextTick method comes in. The $nextTick method in Vue delays the callback until after the next DOM update cycle, meaning it will execute the delayed callback after the next DOM update cycle ends. By using this method immediately after modifying the data, we can obtain the updated DOM. In simple terms, when the data is updated, the callback function in the DOM will be executed after the rendering is completed.
To demonstrate the effect of the $nextTick method through a simple example, we need to understand that Vue updates the DOM asynchronously. This means that the component does not render immediately when the data is updated. The old value can still be obtained after acquiring the DOM structure. The callback function defined in the $nextTick method will be executed after the component has finished rendering, and then the value acquired from the DOM structure will be the new value.
As explained in the official documentation, Vue executes the update of the DOM asynchronously. Whenever a data change is detected, Vue will start a queue and buffer all data changes that occur in the same event loop. If the same watcher is triggered multiple times, it will only be pushed into the queue once. Removing duplicate data during buffering is crucial for avoiding unnecessary calculations and DOM operations. Then, in the next event loop tick, Vue refreshes the queue and executes the actual work. Internally, Vue attempts to use native Promise.then, MutationObserver, and setImmediate for the asynchronous queue. If the execution environment does not support these, it will use setTimeout(fn, 0) instead.
JavaScript is single-threaded and introduces synchronous blocking and asynchronous non-blocking execution modes. In JavaScript's asynchronous mode, it maintains an Event Loop, which is an execution model with different implementations in different environments. Browsers and NodeJS have implemented their own Event Loops based on different technologies. The Event Loop in a browser consists of an Execution Stack, Background Threads, Macrotask Queue, and Microtask Queue.
When JavaScript is executed, it follows the following steps:
After understanding the execution queues of asynchronous tasks, let's go back to the $nextTick method. When user data updates, Vue will maintain a buffer queue and apply certain strategies to handle component rendering and DOM operations for all updated data before adding them to the buffer queue. Then, the $nextTick method will add a flushSchedulerQueue method to the execution queue (which triggers the execution of all callback functions in the buffer queue) and add the callback of the $nextTick method to the execution queue maintained by the $nextTick method. When the asynchronously scheduled execution queue is triggered, the flushSchedulerQueue method will first be executed to handle the DOM rendering tasks, and then the tasks built by the $nextTick method will be executed, allowing it to obtain the rendered DOM structure in the $nextTick method. During testing, an interesting phenomenon was observed: when two buttons were added in the example, clicking the updateMsg button resulted in 3 2 1, while clicking the updateMsgTest button resulted in 2 3 1.
Here, assuming that the Promise object is fully supported in the runtime environment, the use of setTimeout to execute the macro-task at the end is undisputed. However, there is an issue with the execution order when using the $nextTick method and the self-defined Promise instance. Although both are microtasks, the specific implementation in Vue may lead to different execution orders. First, let's take a look at the source code of the $nextTick method. Please note that this is the source code of version Vue2.4.2. The $nextTick method may have been changed in later versions.
Going back to the problem raised earlier, when updating the DOM, the callback of the $nextTick method is triggered first. The key to solving this problem lies in who mounts the asynchronous task to the Promise object first.
First, we debug the method triggered by the updateMsg button with data update, setting a breakpoint at line 715 of Vue.js version 2.4.2. By examining the call stack and the passed parameters, we can observe that the first execution of the $nextTick method is actually called due to the data update nextTick(flushSchedulerQueue); statement. In other words, when executing this.msg = "Update";, the first $nextTick method has already been triggered. At this point, the task queue in the $nextTick method will first add the flushSchedulerQueue method to the queue and mount the execution queue of the $nextTick method to the Promise object. Then, it will mount the custom Promise.resolve().then(() => console.log(2)) statement. When executing the tasks in the microtask queue, the first task mounted to the Promise will be executed. At this point, this task is to run the execution queue, which has two methods: first, to run the flushSchedulerQueue method to trigger the component's DOM rendering operation, and then to execute console.log(3). Then, it will execute the second micro task, () => console.log(2). When the microtask queue is cleared, the macro task queue will execute console.log(1).
Next, we debug the method triggered by the updateMsgTest button without data update, setting the breakpoint at the same location. At this time, the first trigger of the $nextTick method is the self-defined callback function because there's no data update. At this point, the execution queue of the $nextTick method will be mounted to the Promise object. It is evident that the self-defined output 2 of the Promise callback has been mounted before this. So, for the method bound to this button, the execution flow is to first execute console.log(2), then execute the closure's execution queue of the $nextTick method, which contains only one callback function console.log(3). When the microtask queue is cleared, the macro task queue will execute console.log(1).
In short, it's a matter of who mounts the Promise object first. When calling the $nextTick method, its internally maintained execution queue will be mounted to the Promise object. When updating data, Vue internals will first execute the $nextTick method and then mount the execution queue to the Promise object. Once you understand the JS event loop model and regard data updates as a $nextTick method call, and understand that the $nextTick method will execute all the pushed callbacks at once, you can understand the order of execution. Below represents a minimal demo of the $nextTick method.