调度器与 nextTick:批量更新的实现
🎯 引言
先思考一个现象:在事件处理函数里连续改十次 count.value,页面会重渲染十次吗?答案是只会更新一次。数据变了十次,视图却只更新一次,省掉的九次是怎么被「合并」掉的?
这篇我们就打开 Vue3 的调度器(scheduler),看看批量更新是怎么实现的。它先把「要更新哪个组件」记下来,等当前代码执行完再统一更新。学完之后,你能够:
- 说清楚 Vue3 为什么要把更新任务放进队列,而不是立刻重渲染。
- 理解
queueJob入队去重和flushJobs批量执行的配合关系。 - 知道
nextTick的本质,明白为什么它的回调一定在视图更新之后执行。 - 手写一个几十行的迷你调度器,复现「连续修改三次,只更新一次」的效果。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core monorepo),主要涉及 packages/runtime-core/src/scheduler.ts。示例代码可以直接用 Node.js v22 运行。
🧱 为什么需要调度
数据变了,Vue 为什么不能改一次就渲染一次?这一节先来回答这个问题。
前面几篇讲过:修改响应式数据会重新执行组件的渲染 effect(负责重新渲染页面的函数)。如果改一次就渲染一次,下面这段代码会重渲染十次:
function handleClick() {
// 循环改 10 次 count,每次都触发一次更新
for (let i = 0; i < 10; i++) {
count.value++;
}
}
十次渲染里,前九次用户根本看不到,只有最后一次会留在页面上。九次渲染是纯粹浪费,组件一多,卡顿就很明显。
Vue3 的做法是:修改数据时不立刻渲染,而是把「更新这个组件」当成一个任务放进队列,等当前代码都执行完,再把队列里的任务统一执行一遍。 这里的「队列」就是一个数组,任务先进先出。
这个「先记下来、再统一执行」的机制,就是调度器。
🧱 queueJob:任务入队
那任务具体怎么「攒」?这一节看入队。
每个组件的渲染 effect 都对应一个更新任务,Vue3 里叫它 job,本质上就是一个函数,调用它就更新一次组件。调度器维护一个全局队列 queue,数据变化时通过 queueJob 把 job 放进队列。先看调度器需要的几个变量:
// 简化自 packages/runtime-core/src/scheduler.ts
const queue = []; // 任务队列,待执行的更新任务都排在这里
let isFlushing = false; // 队列是否正在执行中
let isFlushPending = false; // 是否已经安排过一次批量执行
const resolvedPromise = Promise.resolve(); // 复用的 Promise,用来安排微任务
接着看入队函数:
function queueJob(job) {
// 去重:同一个 job 已经在队列里,就不再重复入队
if (!queue.includes(job)) {
queue.push(job); // 新任务排到队尾
queueFlush(); // 入队后,安排一次批量执行
}
}
function queueFlush() {
// 既不在执行中,也没安排过批量执行,才安排新的
if (!isFlushing && !isFlushPending) {
isFlushPending = true; // 先打个「已安排」的标记
// 把 flushJobs 放进微任务,下一节细讲微任务是什么
resolvedPromise.then(flushJobs);
}
}
这里有两个关键设计:
- 去重:同一个组件的更新 job 只入队一次。循环里改十次
count.value,触发的都是同一个 job,第一次入队后,后面九次都被includes拦住。 - 只安排一次批量执行:
isFlushPending是个「已安排」标记。第一个 job 入队时安排一次flushJobs,之后再入队的 job 发现标记已存在,就直接跳过,避免重复安排。
id(组件的 uid),查找时按 id 比对。简化版直接用 includes 比对函数引用,思想一致,了解即可。🧱 flushJobs:微任务中批量执行
任务攒好了,什么时候执行?这一节看 flushJobs。
flushJobs 的工作很朴素:把队列里的任务挨个执行完,然后重置状态,准备下一轮:
// 简化自 packages/runtime-core/src/scheduler.ts
function flushJobs() {
isFlushPending = false; // 批量执行已开始,清掉「已安排」标记
isFlushing = true; // 打上「执行中」标记
try {
let job;
// 挨个取出任务执行;执行期间新入队的任务也会被这个循环处理掉
while ((job = queue.shift())) {
job(); // 执行一个更新任务,也就是重新渲染对应组件
}
} finally {
// 无论是否出错,都重置状态,避免队列卡死
isFlushing = false;
queue.length = 0;
}
}
真正值得琢磨的是上一节 queueFlush 里的 resolvedPromise.then(flushJobs) 这一行。要理解它,先补一个 JS 基础概念:微任务。同步代码就是普通的一行行往下执行的代码;微任务(如 Promise.then 的回调)会等同步代码全部执行完后立刻执行;宏任务(如 setTimeout 的回调)排得更靠后。浏览器一轮工作的顺序是:同步代码 → 微任务 → 渲染页面 → 宏任务。
把 flushJobs 放进微任务,正好卡在「同步代码执行完」和「浏览器渲染」之间,带来两个好处:
- 同步修改全部合并:循环里的十次修改都在同步阶段完成,微任务阶段的
flushJobs看到的就是最终结果,只渲染一次。 - 用户看不到中间态:更新在渲染前完成,画面从旧值直接变到新值,不会闪烁。
如果改用 setTimeout(宏任务),更新会推迟到本轮渲染之后,用户可能先看到旧界面闪一下。
nextTick。🧱 nextTick 的实现
视图更新要等同步代码执行完,那想拿到更新后的新 DOM 该怎么办?这一节的 nextTick 就是干这个的。
nextTick 的作用一句话说清:等 Vue 把页面更新完,再执行你的回调。 既然视图更新发生在 flushJobs 里,那把回调挂在同一个 Promise 链的后面就行了:
// 简化自 packages/runtime-core/src/scheduler.ts
let currentFlushPromise = null; // 当前这轮批量执行的 Promise
function nextTick(fn) {
// 有正在进行的批量执行,就跟在它后面;
// 没有的话,就跟在一个已解决的 Promise 后面
const p = currentFlushPromise || resolvedPromise;
// 传了回调就把回调挂在 Promise 后面,没传就直接返回 Promise
return fn ? p.then(fn) : p;
}
它覆盖了两种情况:
- 有更新在排队:
currentFlushPromise存在,回调挂在它后面,保证在flushJobs之后执行。 - 没有更新在排队:挂在
resolvedPromise后面,下一个微任务就执行,不用空等。
所以「nextTick 的回调一定在视图更新之后执行」不是一句 API 约定,而是代码结构决定的:回调和 flushJobs 挂在同一条 Promise 链上,flushJobs 排在前面。
写段代码验证一下(Vue 3.5.x,组件里运行):
<script setup>
import { ref, nextTick } from 'vue';
const count = ref(0);
async function handleClick() {
count.value++;
// 此刻还在同步代码里,视图没更新,读到的是旧值 0
console.log('修改后立刻读 DOM:', document.querySelector('#count').textContent);
await nextTick(); // 等视图更新完
// nextTick 之后 DOM 已更新,读到新值 1
console.log('nextTick 后读 DOM:', document.querySelector('#count').textContent);
}
</script>
<template>
<div id="count">{{ count }}</div>
<button @click="handleClick">+1</button>
</template>
点击按钮后,第一行打印 0(旧值),第二行打印 1(新值),印证了上面的结论。
⚡ 与 Vue2 nextTick 对比
Vue3 的思路讲完了,那 Vue2 是怎么做的?Vue2 也讲过一次 异步更新与 nextTick,思想完全相同:都是把更新攒起来、用微任务批量执行。区别在实现细节上:
- Vue2:为了兼容老环境做了多级降级,优先
Promise,不支持就退到MutationObserver,再退到setTimeout。 - Vue3:现代浏览器都支持
Promise,于是统一用Promise微任务,删掉了降级逻辑。
可以这么记:思想一脉相承,Vue3 只是删掉了兼容老环境的代码。 降级细节了解即可,不用记。
🧾 小节总结
- Vue3 不在每次
set时立刻渲染,而是把组件更新 job 放进队列,同步代码执行完后统一执行,避免重复渲染。 queueJob负责入队:同一个 job 只入队一次(去重),并用isFlushPending保证只安排一次批量执行。flushJobs通过Promise.then放进微任务,微任务在同步代码之后、渲染之前执行,所以更新既合并又不闪。nextTick就是把回调挂在当前批量执行的 Promise 之后,所以它的回调一定在视图更新之后执行;修改数据后立刻读 DOM 拿到的是旧值。
❓ 知识问答
Q1:为什么 Vue3 用微任务而不是宏任务做批量更新?
A:微任务在本轮渲染前执行,更新完浏览器直接绘制新界面,用户看不到中间态;宏任务要等下一轮,画面可能先闪过旧内容。
Q2:修改数据后立刻读 DOM,拿到的是什么?
A:拿到的是更新前的旧 DOM。视图更新在微任务里,同步代码没执行完,flushJobs 还没跑。想拿新 DOM,等 await nextTick() 之后再读。
Q3:await nextTick() 和 nextTick(callback) 有什么区别?
A:本质相同,都是把后续逻辑挂在批量执行的 Promise 之后。await nextTick() 利用了 nextTick 不传参时返回 Promise 的特性,配合 async/await 代码更扁平,按需选择即可。
Q4:nextTick 只能在组件里用吗?
A:不是。从 vue 导出的 nextTick 是全局方法,任何地方都能调用,本质就是等待「下一个微任务」。选项式写法里的 this.$nextTick 底层也是它。
🧪 小练习
不查资料,补全下面的迷你调度器,实现「连续修改三次数据,只打印一次更新」。用 Node.js v22 保存为 .js 文件直接运行验证:
const queue = [];
let isFlushPending = false;
function queueJob(job) {
// 请在这里编写代码(提示:去重后入队,并用 Promise 安排一次 flush)
}
function flushJobs() {
// 请在这里编写代码(提示:执行队列中所有任务,最后清空队列、重置标记)
}
function nextTick(fn) {
// 请在这里编写代码(提示:把 fn 挂在 Promise 后面)
}
// ====== 下面是验证代码,不需要修改 ======
let data = { count: 0 };
function update() {
console.log('执行更新,count =', data.count);
}
// 模拟连续三次修改
data.count++;
queueJob(update);
data.count++;
queueJob(update);
data.count++;
queueJob(update);
nextTick(() => {
console.log('nextTick: 更新已完成');
});
// 期望输出(只更新一次):
// 执行更新,count = 3
// nextTick: 更新已完成
完成后给自己加个挑战:如果在 flushJobs 执行过程中又有新的 queueJob 被调用,你的实现能把新任务也在这轮处理掉吗?想想 while 循环和 for 循环在这里的区别。
🎉 恭喜你已经掌握 Vue3 调度器与 nextTick 的原理啦!到这里,响应式这条主线就完整了:数据变化触发 effect,effect 进队列,微任务里批量更新。下一篇我们换个视角,看看渲染出来的虚拟 DOM 长什么样,以及 patch 是如何对比新旧 vnode、把变化精确更新到真实 DOM 的。
