Demystifying Vue’s $nextTick: How the Event Loop Powers Asynchronous Updates
This article explains Vue's $nextTick by first detailing JavaScript's single‑threaded event loop, then showing how Vue batches DOM updates using macro‑ and micro‑tasks, and finally walks through a custom implementation with code examples and common pitfalls.
Vue developers frequently use $nextTick to defer a callback until after the next DOM update. To understand its behavior, the article first reviews JavaScript’s single‑threaded, event‑loop model.
JavaScript Event Loop Basics
All synchronous tasks run on the main thread in a call stack.
Asynchronous tasks such as Promise or timers are created synchronously, but their callbacks are placed into a task queue once they resolve.
After the call stack is empty, the engine processes the task queue, executing each callback.
When the task queue is drained, the UI is rendered.
The process repeats indefinitely.
Two important concepts arise from this flow:
A tick represents one full cycle of the four steps above.
There are two queues: a macro‑task queue and a micro‑task queue. After each synchronous batch, the micro‑task queue is flushed before the next macro‑task runs.
A simplified pseudo‑loop illustrates the mechanism:
while (true) {
// execute synchronous tasks
syncHandlerQueue();
// flush micro‑tasks
for (micro of microTask) {
micro();
}
// process macro‑tasks
for (macro of macroTask) {
macro();
// after each macro‑task, flush any newly added micro‑tasks
for (micro of microTask) {
micro();
}
}
}In Vue, when a reactive property changes, the watcher is queued only once, even if the property is assigned multiple times. The article shows a component where message is updated three times in mounted, yet the watcher logs change message only once because Vue batches the updates.
const has = {}, queue = [];
let waiting = false;
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
if (!waiting) {
waiting = true;
// Vue uses nextTick internally
nextTick(flushSchedulerQueue);
}
}
function flushSchedulerQueue() {
for (let watcher of queue) {
watcher.run();
}
waiting = false;
}When the last assignment finishes, Vue calls nextTick to perform the asynchronous update.
Understanding $nextTick
The implementation relies on a Promise‑based micro‑task. Vue maintains a callback queue and a pending lock. When the first callback is added, the lock is set and the micro‑task is scheduled; subsequent callbacks are simply pushed.
// 1. task queue and lock
const callbacks = [];
let pending = false;
// 2. flush function
function flushCallBacks() {
pending = false;
for (let cb of callbacks) {
cb();
}
}
// 3. async trigger (Promise if available)
const p = Promise.resolve();
const microTimerFunc = () => {
p.then(flushCallBacks);
};
// 4. nextTick implementation
function nextTick(cb) {
let _resolve;
callbacks.push(() => {
if (cb) {
cb();
} else if (_resolve) {
_resolve();
}
});
if (!pending) {
pending = true;
microTimerFunc();
}
if (!cb) {
return new Promise(resolve => _resolve = resolve);
}
}Using this custom nextTick, the article reproduces two ordering scenarios:
Scenario 1:
Promise.resolve().then(() => console.log(1)); nextTick(() => console.log(2));prints 1 then 2.
Scenario 2:
nextTick(() => console.log('render')); Promise.resolve().then(() => console.log(1)); nextTick(() => console.log(2));prints render, 2, 1.
A nested nextTick example demonstrates an infinite loop, which is solved by copying the callback queue before flushing:
function flushCallBacks() {
pending = false;
const copies = callbacks.slice();
callbacks.length = 0;
for (let cb of copies) {
cb();
}
}For environments without Promise, Vue falls back to setImmediate, MessageChannel, or setTimeout. The article shows the compatibility shim:
let timerFunc;
if (typeof Promise !== 'undefined') {
const p = Promise.resolve();
timerFunc = () => p.then(flushCallBacks);
} else if (typeof setImmediate !== 'undefined') {
timerFunc = () => setImmediate(flushCallBacks);
} else {
timerFunc = () => setTimeout(flushCallBacks, 0);
}
function nextTick(cb, ctx) {
let _resolve;
callbacks.push(() => {
if (cb) {
cb.call(ctx);
} else if (_resolve) {
_resolve();
}
});
if (!pending) {
pending = true;
timerFunc();
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => _resolve = resolve);
}
}By the end, readers should have a solid grasp of how Vue’s $nextTick works, how it leverages the JavaScript event loop, and how to implement a compatible version themselves.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xueersi Online School Tech Team
The Xueersi Online School Tech Team, dedicated to innovating and promoting internet education technology.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
