Skip to content

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 会找到所有依赖 counteffect(例如组件的 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
  • 优势
    1. 代码简化:移除了所有兼容性代码。
    2. 行为一致nextTick 在所有现代浏览器上永远表现为“微任务”,保证了更新的及时性。
    3. 不再需要 timerFunc:不再有 MutationObserversetTimeout 的运行时嗅探和切换,性能更好。

总结

Vue 3 的异步更新策略是一个基于微任务的、有序的调度系统:

  1. 数据变更 (state.count++) 触发 trigger
  2. triggerrender 函数(job)交给 queueJob
  3. queueJob 有序地(按 uid 排序)不重复地job 插入 queue 队列。
  4. queueFlush 预约一个微任务 (Promise.resolve().then(flushJobs)) 来清空队列,并将这个 Promise 存为 currentFlushPromise
  5. flushJobs 在微任务中被调用,遍历 queue 执行所有 job,完成 DOM 更新。
  6. nextTick(fn) 的实现就是 currentFlushPromise.then(fn),它巧妙地利用 Promise 链,确保 fnflushJobs(DOM 更新)之后才被执行。

微信公众号二维码

Last updated: