技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

你真的理解 $nextTick 么?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/63-你真的理解$nextTick么
本文目录10 个章节
  1. 一、为什么叫 nextTick
  2. 二、浏览器事件循环的基础
  3. 三、用例:同步代码、Promise、MutationObserver 和定时器
  4. 四、Node.js 的 process.nextTick
  5. 五、Vue 为什么要异步批量更新
  6. 六、Vue 2.7 的 nextTick 实现思路
  7. 七、Vue 3.5 的 scheduler 与 nextTick
  8. 八、常见误区
  9. 九、面试版回答
  10. 参考资料

你真的理解 $nextTick 么?

原文从浏览器 Event Loop、微任务/宏任务和 Vue 2 源码分析 nextTick。这些内容仍然适合学习,但原文把浏览器线程、渲染时机和任务队列说得过于绝对,并混入了 Vue 2.5/2.6 的抓取代码。本文保留主线,补充 Vue 2.7、Vue 3.5 的用法和边界。

一、为什么叫 nextTick

Vue 的 nextTick 不是 Node.js 的 process.nextTick,只是名字相似。

它解决的问题是:响应式状态改变后,Vue 通常不会同步把每一次改变都立即写入 DOM,而是把组件更新任务放入调度队列,在当前同步代码结束后批量刷新。调用 nextTick 可以等待这次 Vue DOM 更新 flush 完成:

import { nextTick, ref } from 'vue'

const count = ref(0)

function update() {
  count.value++
  // 这里读取 DOM,通常还可能是旧内容
  return nextTick()
}

async function handleClick() {
  await update()
  // 这里可以读取 Vue 本轮更新后的 DOM
}

Vue 2 使用实例方法:

this.message = 'new value'
this.$nextTick(() => {
  // Vue 2 本轮 DOM 更新完成
})

Vue 3 推荐从 vue 导入:

message.value = 'new value'
await nextTick()

Vue 3 Options API 仍然可以使用 this.$nextTick();Composition API 通常使用 nextTick()

二、浏览器事件循环的基础

1. 不要把浏览器线程图当成规范

浏览器是多进程、多线程系统,但“JS 引擎线程、GUI 渲染线程、事件触发线程完全分离,二者互斥”的简化说法容易误导。

对前端代码更有用的模型是:

  • JavaScript 回调通常在页面的主线程上执行;
  • 浏览器从不同 task source 取出任务执行;
  • 一段任务执行期间,JavaScript 代码是串行的;
  • 当前任务结束后会进行 microtask checkpoint;
  • 浏览器是否在此后提供渲染机会由用户代理决定,不能保证每个 task 之后都立即绘制。

浏览器事件循环历史示意图(已本地化)

2. Task 与 microtask

常见的浏览器任务包括:

  • 初始脚本执行;
  • click、键盘、定时器、网络等事件回调;
  • setTimeoutsetInterval 回调。

常见 microtask 包括:

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver 回调。

一个简化顺序是:

取出一个 task
  ↓
执行同步 JavaScript
  ↓
清空当前 microtask 队列
  ↓
浏览器可能进行样式计算、布局和绘制
  ↓
取出下一个 task

microtask 通常会在当前 task 结束后、下一次渲染机会前执行,但浏览器渲染调度存在实现和负载差异。不要把“宏任务 > 渲染 > 宏任务”当成每一轮都严格成立的规范。

process.nextTick 是 Node.js 特有的高优先级队列,不是浏览器 microtask 的标准 API;setImmediate 也不是浏览器标准 API。Object.observe 已废弃,不能列为现代浏览器的 microtask 方案。

事件循环任务队列历史示意图(已本地化)

三、用例:同步代码、Promise、MutationObserver 和定时器

下面的代码可以帮助观察队列顺序:

<div class="outer">
  <div class="inner"></div>
</div>

<script>
  const inner = document.querySelector('.inner')
  const outer = document.querySelector('.outer')

  new MutationObserver(records => {
    console.log('mutation', records.length)
  }).observe(outer, { attributes: true })

  function onClick() {
    console.log('click')

    setTimeout(() => {
      console.log('timeout')
    }, 0)

    Promise.resolve().then(() => {
      console.log('promise')
    })

    outer.setAttribute('data-random', Math.random())
  }

  inner.addEventListener('click', onClick)
</script>

一般可以观察到 click 先打印,随后 Promise microtask 和 MutationObserver 回调在当前 task 结束时执行,setTimeout 留到后续 task。具体日志顺序和 MutationObserver 的合并方式应以当前浏览器实测为准,不应把一张任务队列对象图当作规范。

如果同时给 outer 添加 click 监听,事件冒泡会让两个监听器在同一个 click task 中同步执行,不会因此产生两个独立的 click task:

outer.addEventListener('click', onClick)

两个监听器产生的 Promise 会进入同一个 microtask 队列;MutationObserver 可能把同一轮多个 mutation records 合并到一次回调中。

四、Node.js 的 process.nextTick

下面代码中的 process.nextTick 只适用于 Node.js:

setTimeout(() => {
  console.log('timeout')
}, 0)

process.nextTick(() => {
  console.log('nextTick 1')
})

Promise.resolve().then(() => {
  console.log('promise')
})

process.nextTick(() => {
  console.log('nextTick 2')
})

在常见 Node.js 版本中,process.nextTick 队列会在当前操作结束后、Promise microtask 之前处理;Node 版本和具体阶段可能影响完整顺序。它与 Vue 的 nextTick 没有 API 继承关系,也不能用 Node 的执行顺序推导浏览器或 Vue 的 DOM 更新顺序。

五、Vue 为什么要异步批量更新

import { nextTick, ref } from 'vue'

const count = ref(0)

function updateManyTimes() {
  count.value++
  count.value++
  count.value++
}

同一个同步任务中连续修改状态时,Vue 通常会去重组件更新 job,只在这一轮刷新一次渲染结果。这样可以避免每个赋值都立即执行完整的组件树更新。

<script setup lang="ts">
import { nextTick, ref } from 'vue'

const count = ref(0)
const element = ref<HTMLElement | null>(null)

async function increment() {
  count.value++
  console.log(element.value?.textContent) // 可能仍是旧值
  await nextTick()
  console.log(element.value?.textContent) // 本轮 Vue 更新后的值
}
</script>

<template>
  <p ref="element">{{ count }}</p>
  <button @click="increment">增加</button>
</template>

nextTick 等待的是 Vue 的更新 flush,不是“等待一个固定的 0ms 定时器”,也不是“保证浏览器已经完成绘制”。如果需求是等待浏览器渲染机会,可以在 DOM 更新后再使用 requestAnimationFrame

await nextTick()
await new Promise<void>(resolve => {
  requestAnimationFrame(() => resolve())
})
// 更接近下一次渲染机会,但仍不应把它当作跨浏览器的绘制完成承诺

六、Vue 2.7 的 nextTick 实现思路

Vue 2.7 的源码延续了 callbacks 队列和 timer fallback 的设计。下面是去掉 Flow、错误拼接和兼容分支后的教学版:

const callbacks = []
let pending = false
let timerFunc

function flushCallbacks() {
  pending = false
  const copies = callbacks.slice()
  callbacks.length = 0

  for (const callback of copies) {
    callback()
  }
}

if (typeof Promise !== 'undefined') {
  const resolved = Promise.resolve()
  timerFunc = () => resolved.then(flushCallbacks)
} else if (typeof MutationObserver !== 'undefined') {
  const textNode = document.createTextNode('0')
  let counter = 0
  new MutationObserver(flushCallbacks).observe(textNode, {
    characterData: true,
  })
  timerFunc = () => {
    textNode.data = String(counter ^= 1)
  }
} else if (typeof setImmediate !== 'undefined') {
  timerFunc = () => setImmediate(flushCallbacks)
} else {
  timerFunc = () => setTimeout(flushCallbacks, 0)
}

function nextTick(callback, context) {
  let resolvePromise

  callbacks.push(() => {
    if (callback) {
      callback.call(context)
    } else if (resolvePromise) {
      resolvePromise(context)
    }
  })

  if (!pending) {
    pending = true
    timerFunc()
  }

  if (!callback && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      resolvePromise = resolve
    })
  }
}

这段代码是解释思路,不是建议复制进项目的 polyfill。Vue 2.5 在某些场景组合使用过 macro task 和 microtask;Vue 2.6/2.7 又主要回到 microtask,并保留兼容回退。不同 minor 版本的源码不能混写成一个“Vue nextTick 源码”。

Vue 2 $nextTick 的关键点是:

  1. 把回调放入 callbacks 队列;
  2. 同一轮只安排一次 flush;
  3. flush 时复制并清空 callbacks;
  4. 没有回调且环境支持 Promise 时返回 Promise;
  5. 更新 watcher 队列和 nextTick 队列配合完成 DOM 更新后的回调。

七、Vue 3.5 的 scheduler 与 nextTick

Vue 3 使用 scheduler 管理组件更新 job、前置 watcher、后置 watcher 和当前 flush Promise。实现细节会随版本变化,但可以用下面的模型理解:

const resolvedPromise = Promise.resolve()
let currentFlushPromise: Promise<void> | null = null

function nextTick(fn?: () => void) {
  const promise = currentFlushPromise || resolvedPromise
  return fn ? promise.then(fn) : promise
}

真实 runtime 还包含 job 去重、递归更新保护、pre/post flush callbacks 和异常处理。nextTick 的 Promise 会关联当前待处理的 Vue flush;如果当前没有待处理更新,它可能只是基于已 resolved 的 Promise 排队一个 microtask。

Vue 3 可以通过 watcher 的 flush 选择时机:

watch(source, callback, { flush: 'pre' })  // 默认语义附近
watch(source, callback, { flush: 'post' }) // 组件 DOM 更新后
watchSyncEffect(callback)                 // 谨慎:同步执行

如果只是等待一次响应式 DOM 更新,优先使用 await nextTick();如果是长期监听 DOM 更新后的副作用,可考虑 flush: 'post'

Vue nextTick 调度流程历史示意图(已本地化)

八、常见误区

误区 1:nextTick 等于 setTimeout(fn, 0)

不等于。现代 Vue 和现代浏览器通常优先使用 Promise microtask;Vue 2 会根据环境选择 Promise、MutationObserver、setImmediate 或 setTimeout 回退。

误区 2:调用 nextTick 就保证已经绘制到屏幕

不保证。它保证 Vue 本轮 DOM 更新 flush 完成,浏览器绘制时机仍由渲染调度决定。需要动画帧配合时使用 requestAnimationFrame

误区 3:每次赋值都必须 nextTick

不需要。只有在同一段代码中要读取更新后的 DOM、测量尺寸或调用依赖最新 DOM 的第三方库时才需要等待。

误区 4:Vue 的 nextTick 和 Node 的 process.nextTick 是一回事

不是。前者是 Vue 等待自身 DOM 更新的 API,后者是 Node.js 事件循环中的专用调度 API。

误区 5:microtask 一定比 task 更好

不一定。microtask 过多可能长时间占用主线程、推迟渲染;Vue 2 历史版本在事件冒泡和过渡时机上就曾在 microtask 与 macro task 之间权衡。应以用户交互和实际性能为依据。

九、面试版回答

Vue 的响应式更新通常会被放入异步调度队列,并在当前同步代码结束后批量刷新。nextTick 用来等待 Vue 下一轮 DOM 更新完成,所以修改响应式数据后,如果要读取更新后的 DOM,可以使用 Vue 2 的 this.$nextTick() 或 Vue 3 的 await nextTick()。它等待的是 Vue 的 flush,不等同于 Node 的 process.nextTicksetTimeout(0) 或浏览器已经绘制完成。Vue 2.7 使用 callbacks 队列和环境回退,Vue 3.5 使用 scheduler 与 Promise flush。

参考资料

原文作者:子弈。原文的 Event Loop、Vue 2.5/2.6 源码分析和示例主线予以保留,推广内容、抓取噪声及过度绝对化的线程/渲染表述已删除或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS