Appearance
nextTick:揭秘 Vue 的异步更新策略与事件循环
引言:为什么需要异步更新?
当我们执行以下代码时,DOM 会更新几次?
javascript
function update() {
state.count = 1 // 触发更新
state.count = 2 // 触发更新
state.count = 3 // 触发更新
}如果 Vue 是“同步”更新的,DOM 将会愚蠢地更新 3 次。这会造成巨大的性能浪费。
为了解决这个问题,Vue 实现了“异步批量更新”策略。它会收集同一个“tick”(事件循环周期)内的所有数据变更,将它们去重并合并为一次更新。
这个策略的核心就是微任务(Microtask)和调度器(Scheduler)。而 nextTick 则是 Vue 暴露给我们的、用于“插入”这个异步更新流程的 API。
核心:调度器(Scheduler)的“三部曲”
当 state.count++ 发生时,响应式系统的 trigger 函数会被调用。trigger 会找到所有依赖 count 的 effect(例如组件的 render 函数),但它不会立即执行它们,而是会将它们交给“调度器”(Scheduler)。
这个调度流程分为三步:queueJob -> queueFlush -> flushJobs。
1. queueJob:更新任务“入队”
queueJob (packages/runtime-core/src/scheduler.ts) 负责将一个“工作”(job,即组件的 render 函数)放入一个全局队列 queue 中。
typescript
// core/packages/runtime-core/src/scheduler.ts
const queue: SchedulerJob[] = []
export function queueJob(job: SchedulerJob): void {
// 1. 【去重】
// 检查 job 是否已在队列中,如果是,则跳过
if (!queue.includes(job)) {
// 2. 【排序】(核心优化)
// 为了保证“父组件先于子组件更新”(避免子组件重复渲染)
// 队列需要按组件 uid 排序。
// (findInsertionIndex 使用二分查找来高效插入)
queue.splice(findInsertionIndex(job.id), 0, job)
// 3. 【安排刷新】
// 安排一次“清空队列”的动作
queueFlush()
}
}
// 二分查找插入位置,确保队列有序
function findInsertionIndex(id: number) {
let start = flushIndex + 1
let end = queue.length
while (start < end) {
const middle = (start + end) >>> 1
// 通过 getId(job) 获取 job.id (即 instance.uid)
const middleJobId = getId(queue[middle])
if (middleJobId < id) {
start = middle + 1
} else {
end = middle
}
}
return start
}queueJob 的职责是:高效、有序、不重复地将任务放入 queue。
2. queueFlush:安排“微任务”
queueJob 在任务入队后,会调用 queueFlush 来“预约”一次刷新。
queueFlush 的实现极其简洁,它是 Vue 异步更新的**“发动机”**:
typescript
// core/packages/runtime-core/src/scheduler.ts
// 预先缓存一个已解析的 Promise,用于创建微任务
const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise<any>
// "当前刷新中的 Promise",这是 nextTick 的关键
let currentFlushPromise: Promise<void> | null = null
function queueFlush() {
// 检查是否“已经”预约了刷新
if (!currentFlushPromise) {
// 【核心】
// 1. 创建一个“微任务”,在同步代码执行完毕后,
// 调用 flushJobs 来清空队列。
// 2. 将这个 Promise 存起来,供 nextTick 使用。
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}为什么选择 Promise.resolve().then()? 因为它创建了一个微任务(Microtask)。微任务会在当前同步代码执行完毕后、浏览器下次重绘(Macrotask)之前立即执行。这保证了 DOM 更新是批量的,并且是及时的。
3. flushJobs:“清空”更新队列
当同步代码结束,微任务开始执行,flushJobs 被调用。它的职责是:遍历 queue 队列,并执行所有 job(即 render 函数)。
typescript
// core/packages/runtime-core/src/scheduler.ts
function flushJobs(seen?: CountMap) {
try {
// 遍历队列,执行所有 job
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
// ... (省略递归更新检查) ...
// 执行 job (即 effect.run())
callWithErrorHandling(job, ...)
}
}
} finally {
// 1. 清理
flushIndex = -1
queue.length = 0
// 2. 【关键】
// 清空“后置”回调队列 (如 mounted 钩子)
flushPostFlushCbs(seen)
// 3. 【关键】
// 将“当前刷新 Promise”设为 null,
// 表示本轮刷新已结束
currentFlushPromise = null
// 4. (递归处理) 如果在 flush 期间又产生了新任务,
// 则递归调用 flushJobs,开始新一轮刷新
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}nextTick:“刷新完成”的回调钩子
现在,我们终于可以揭秘 nextTick 的实现了。nextTick 允许我们在“DOM 更新完成之后”执行一段代码。
它是如何保证这一点的?
typescript
// core/packages/runtime-core/src/scheduler.ts
export function nextTick<T>(fn?: (this: T) => void): Promise<void> {
// 1. 获取“当前刷新 Promise”
const p = currentFlushPromise || resolvedPromise
// 2. 【核心】
// 使用 .then() 将我们的回调函数 fn,
// “挂载”到“当前刷新 Promise”的链上
return fn ? p.then(fn) : p
}nextTick 的真相: nextTick(fn) 的本质就是向“当前刷新周期的 Promise” (currentFlushPromise) 注册一个 .then(fn) 回调。
由于 currentFlushPromise 的 .then(flushJobs) 会先执行(DOM 更新),所以我们注册的 .then(fn) 必然会在 flushJobs 之后才执行。
场景模拟:
javascript
import { ref, nextTick } from 'vue'
const container = ref(null)
const message = ref('Hello')
async function update() {
message.value = 'Updated' // 1. queueJob(render) + queueFlush()
// 2. 此时 DOM 未更新
console.log(container.value.textContent) // "Hello"
// 3. nextTick “挂载”到 currentFlushPromise 之后
await nextTick()
// 4. (微任务开始)
// 5. flushJobs() 执行,DOM 更新
// 6. nextTick 的 .then() 执行
// 7. 此时 DOM 已更新
console.log(container.value.textContent) // "Updated"
}与 Vue 2 的对比
Vue 2 的 nextTick 为了兼容不支持 Promise 的旧浏览器(如 IE10),实现了一个极其复杂的降级策略: Promise (微任务) -> MutationObserver (微任务) -> setImmediate (宏任务) -> setTimeout(0) (宏任务)。
Vue 3 (及 Vue 2.6+) 彻底抛弃了这种降级。
- 它只使用
Promise。 - 优势:
- 代码简化:移除了所有兼容性代码。
- 行为一致:
nextTick在所有现代浏览器上永远表现为“微任务”,保证了更新的及时性。 - 不再需要
timerFunc:不再有MutationObserver或setTimeout的运行时嗅探和切换,性能更好。
总结
Vue 3 的异步更新策略是一个基于微任务的、有序的调度系统:
- 数据变更 (
state.count++) 触发trigger。 trigger将render函数(job)交给queueJob。queueJob有序地(按uid排序)、不重复地将job插入queue队列。queueFlush预约一个微任务 (Promise.resolve().then(flushJobs)) 来清空队列,并将这个Promise存为currentFlushPromise。flushJobs在微任务中被调用,遍历queue执行所有job,完成 DOM 更新。nextTick(fn)的实现就是currentFlushPromise.then(fn),它巧妙地利用Promise链,确保fn在flushJobs(DOM 更新)之后才被执行。
