异步更新队列与 nextTick 原理
🎯 引言
你在写 Vue 代码时有没有遇到过这样的困惑:连续改了 10 次数据,页面却只更新了一次?或者刚改完数据马上去读 DOM,拿到的还是旧内容?
学完这篇文章,你能够解释 Vue2 为什么要异步更新 DOM,理解 watcher 队列是如何去重和调度的,并掌握 this.$nextTick 的原理和典型用法,知道修改数据后想立刻操作更新后的 DOM 该怎么办。
🧱 问题引入:为什么不能每次都同步渲染
先看一个例子:
<template>
<div>{{ count }}</div>
</template>
<script>
export default {
data() {
return { count: 0 };
},
mounted() {
for (let i = 0; i < 10; i++) {
this.count++;
}
},
};
</script>
循环里 count 变了 10 次。如果 Vue 每次数据变化都立刻重新渲染,就要执行 10 次"生成虚拟 DOM、Diff、打补丁",前 9 次的结果用户根本看不到,纯属浪费。
Vue 的做法是:数据变化时先不渲染,把需要更新的 Watcher 放进一个队列,等到当前事件循环快结束时,统一执行一次渲染。 这样 10 次修改合并成 1 次 DOM 更新,性能开销大幅降低。
这就像一个快递柜:每来一个包裹就派一个快递员跑一趟太浪费,不如攒一批,到点统一派送一次。
⚙️ queueWatcher:入队与去重
数据变化会触发 Watcher 的 update() 方法。同步 Watcher(如计算属性)会直接重新求值,而渲染 Watcher 走的是 queueWatcher,进入异步队列。
简化自 src/core/observer/scheduler.js:
const queue = [];
const has = {}; // 记录已入队的 watcher id
let waiting = false;
function queueWatcher(watcher) {
const id = watcher.id;
// 按 id 去重:同一个 watcher 一次事件循环只入队一次
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
// 一次事件循环只注册一次 flush
if (!waiting) {
waiting = true;
nextTick(flushSchedulerQueue);
}
}
}
这段代码有三个关键设计:
has对象去重:count改 10 次,触发的是同一个渲染 Watcher,has[id]保证它只入队一次。这就是"改 10 次只更新一次"的直接原因。waiting标志:保证一次事件循环里只调用一次nextTick(flushSchedulerQueue),避免重复注册刷新任务。nextTick延迟执行:把队列刷新推迟到当前同步代码执行完之后,让这一轮的所有数据修改都能攒进队列。
🛠 flushSchedulerQueue:统一刷新
下一次事件循环到来时,flushSchedulerQueue 开始工作。简化自 src/core/observer/scheduler.js:
function flushSchedulerQueue() {
// 按 id 从小到大排序
queue.sort((a, b) => a.id - b.id);
for (let i = 0; i < queue.length; i++) {
const watcher = queue[i];
has[watcher.id] = null;
watcher.run(); // 重新渲染并打补丁
}
// 重置状态,等待下一轮
queue.length = 0;
waiting = false;
}
两个细节值得注意:
- 为什么要排序:Watcher 的 id 是递增的,创建越早 id 越小。父组件的渲染 Watcher 先于子组件创建,排序保证父组件先更新、子组件后更新,避免子组件被重复刷新。
watcher.run():真正执行渲染的地方,内部会调用vm._update(vm._render()),走我们在 Snabbdom 课程 里学过的虚拟 DOM Diff 流程。
所有 Watcher 跑完之后,Vue 还会在这里统一调用各个组件的 updated 生命周期钩子。所以在 updated 里读到的 DOM 一定是最新的。
⚡ nextTick 实现:微任务优先
现在轮到最后一块拼图:nextTick 是怎么"推迟执行"的?简化自 src/core/util/next-tick.js:
const callbacks = [];
let pending = false;
let timerFunc;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
timerFunc();
}
}
function flushCallbacks() {
pending = false;
const cbs = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < cbs.length; i++) {
cbs[i]();
}
}
结构很简单:callbacks 数组收集所有回调,pending 保证一次事件循环只启动一次定时器,到点后由 flushCallbacks 逐个执行。
timerFunc 的选择是一条优雅降级链,Vue 按环境能力依次尝试:
// 1. 优先使用 Promise(微任务)
if (typeof Promise !== 'undefined') {
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
};
}
// 2. 退化为 MutationObserver(微任务)
else if (typeof MutationObserver !== 'undefined') {
let counter = 1;
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, { characterData: true });
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
}
// 3. 退化为 setImmediate(宏任务,仅 IE 和 Node 支持)
else if (typeof setImmediate !== 'undefined') {
timerFunc = () => {
setImmediate(flushCallbacks);
};
}
// 4. 兜底 setTimeout(宏任务)
else {
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}
为什么优先微任务? 微任务在当前调用栈结束后、浏览器渲染前执行;宏任务要等下一个事件循环,中间可能多经历一次渲染。用微任务执行刷新,能让 DOM 更新尽早发生,减少中间状态被用户看到的机会。在现代浏览器里,timerFunc 走的就是 Promise.then 这一档。
💡 this.$nextTick 的典型用法
this.$nextTick(cb) 就是把 cb 放进同一个 callbacks 数组,等队列刷新完成后执行。典型场景是:修改数据后,需要立刻读取更新后的 DOM。
<template>
<div>
<ul ref="list">
<li v-for="item in list" :key="item">{{ item }}</li>
</ul>
<button @click="addItem">添加一项</button>
</div>
</template>
<script>
export default {
data() {
return { list: ['苹果', '香蕉'] };
},
methods: {
addItem() {
this.list.push('橙子');
// 同步读:列表高度还没更新,拿到的是旧高度
console.log('同步读取:', this.$refs.list.offsetHeight);
this.$nextTick(() => {
// 异步读:DOM 已更新,拿到的是新高度
console.log('nextTick 读取:', this.$refs.list.offsetHeight);
});
},
},
};
</script>
本地运行环境:Node.js v22,安装 vue@2.7.16:
npm install vue@2.7.16
$nextTick 不是让代码"立刻执行",而是"等 DOM 更新完再执行"。如果你只是想延迟执行代码、不涉及读 DOM,用它没有意义。v-if / v-show,样式变化用 :class / :style 绑定。只有读取尺寸、对接第三方 DOM 库这类非碰 DOM 不可的场景,才需要 $nextTick。🧾 小节总结
- Vue2 数据变化不立即渲染,而是把 Watcher 放入队列,合并到下一次事件循环统一执行。
queueWatcher用has对象按 id 去重,同一个 Watcher 一轮只入队一次;waiting标志保证只注册一次刷新。flushSchedulerQueue按 id 排序执行(父组件先于子组件),逐个watcher.run(),随后调用updated钩子。nextTick用callbacks数组 +pending标志收集回调,定时器按 Promise → MutationObserver → setImmediate → setTimeout 降级。- 优先微任务是因为它在当前调用栈结束后、渲染前执行,能让更新尽早生效。
- 修改数据后要操作更新后的 DOM,用
this.$nextTick(cb);能不用 DOM 的场景尽量用数据绑定代替。
❓ 知识问答
Q1:nextTick 是微任务还是宏任务?
A:不固定,取决于运行环境。现代浏览器支持 Promise,nextTick 走微任务;老环境会按降级链退化为 MutationObserver(微任务)、setImmediate 或 setTimeout(宏任务)。Vue 优先选微任务,让回调在渲染前尽早执行。
Q2:为什么改 10 次数据,DOM 只更新一次?
A:10 次修改触发的是同一个渲染 Watcher,queueWatcher 里的 has 对象按 watcher.id 去重,它只入队一次,队列刷新时自然只渲染一次。
Q3:同步修改数据后立刻读 DOM,为什么拿到的是旧值?
A:渲染是异步的,数据修改只是把 Watcher 放进了队列,真正的 DOM 更新要等 flushSchedulerQueue 在下一次事件循环执行。同步代码里 DOM 还没更新,所以读到旧值。
Q4:flushSchedulerQueue 为什么要给队列排序?
A:排序按 Watcher id 从小到大执行,id 越小创建越早。父组件的渲染 Watcher 先于子组件创建,排序保证父先更新、子后更新,避免子组件被父组件更新牵连后再重复刷新一次。
Q5:需要直接操作 DOM 的场景有哪些替代方案?
A:多数场景可以用数据绑定替代:显隐用 v-if / v-show,样式用 :class / :style,文本用插值或 v-text。只有读取元素尺寸、初始化图表或地图这类第三方 DOM 库时,才真正需要在 $nextTick 里操作 DOM。
🧪 小练习
写一个计数器组件:点击按钮后 count 连续 +1 三次,分别在同步代码和 $nextTick 回调里打印 DOM 中的文本内容,观察两次打印的差异,并解释原因。
<template>
<div>
<p ref="text">{{ count }}</p>
<button @click="addThree">连加三次</button>
</div>
</template>
<script>
export default {
data() {
return { count: 0 };
},
methods: {
addThree() {
this.count++;
this.count++;
this.count++;
// 请在这里编写代码:同步打印 this.$refs.text.textContent
// 请在这里编写代码:在 $nextTick 回调里打印 this.$refs.text.textContent
},
},
};
</script>
🎉 恭喜你已经掌握 Vue2 异步更新队列与 nextTick 的原理啦!理解了"watcher 去重入队、微任务统一刷新"这套机制,再看 Vue 的各种更新行为就都讲得通了。
