前端 Vue 专栏 06:异步更新机制、任务队列与 nextTick

前端 Vue 专栏 06:异步更新机制、任务队列与 nextTick

前言

上一篇文章讲完 Vue 响应式原理后,我们已经知道:

text 复制代码
读取响应式数据时,track 收集依赖;
修改响应式数据时,trigger 通知依赖。

但是还有一个问题没有解决。

假设组件中有:

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++

  console.log('状态:', count.value)
  console.log('DOM:', document.querySelector('#count').textContent)
}
</script>

<template>
  <button id="count" @click="increment">
    {{ count }}
  </button>
</template>

点击以后可能输出:

text 复制代码
状态:1
DOM:0

为什么响应式状态已经变成 1,DOM 还显示 0?

再看:

js 复制代码
count.value++
count.value++
count.value++

为什么状态最终变成 3,组件通常只更新一次,而不是连续操作三次 DOM?

这些问题都指向 Vue 响应式系统与渲染系统中间的一层:

text 复制代码
调度器 scheduler

这一篇将沿着下面的主线展开:

text 复制代码
响应式状态变化
    ↓
trigger 找到组件 effect
    ↓
scheduler 把更新任务放入队列
    ↓
同一任务去重和排序
    ↓
通过微任务刷新队列
    ↓
组件重新渲染并更新 DOM
    ↓
nextTick 等待这次刷新完成

本文将重点讲清楚:

  • JavaScript 同步代码、宏任务和微任务;
  • Promise、setTimeout 与 async/await 的执行顺序;
  • Vue 为什么异步更新 DOM;
  • scheduler 和组件更新队列;
  • 任务怎样去重、排序和刷新;
  • nextTick 到底等待什么;
  • watch 的 flush: 'pre' | 'post' | 'sync';
  • 父子组件更新与生命周期顺序;
  • Vue 2 和 Vue 3 异步更新机制的联系;
  • 高频面试题与项目场景。

一、先区分状态更新和 DOM 更新

执行:

js 复制代码
count.value++

这里包含两个不同的变化。

1. JavaScript 状态变化

js 复制代码
console.log(count.value)

会立即得到新值。

状态修改本身是同步的:

text 复制代码
0 → 1

2. 组件 DOM 更新

模板中虽然使用了:

vue 复制代码
{{ count }}

但 Vue 不会在 setter 执行的同一行代码中立刻完成组件重新渲染和 DOM 修改。

它通常会把组件更新任务放进队列,等待当前同步代码执行完后统一刷新。

因此:

text 复制代码
响应式状态:已经是新值;
页面 DOM:可能还是旧内容。

一定不要把它说成:

text 复制代码
Vue 的数据是异步修改的。

更准确的表达是:

Vue 响应式状态通常同步改变,但由状态触发的组件渲染和 DOM 更新会经过调度器批量处理。

二、Vue 为什么不立即更新 DOM

假设 Vue 每次状态变化都立刻更新组件:

js 复制代码
count.value++
count.value++
count.value++

可能会发生:

text 复制代码
count = 1 → 重新渲染并修改 DOM;
count = 2 → 再次重新渲染并修改 DOM;
count = 3 → 第三次重新渲染并修改 DOM。

但是前两个中间状态通常不会被用户看到。

真正需要提交到页面的往往只是:

text 复制代码
count = 3

所以 Vue 更合理的做法是:

text 复制代码
第一次变化 → 把组件更新任务加入队列;
第二次变化 → 发现同一个任务已经在队列中,不重复加入;
第三次变化 → 仍然不重复加入;
当前同步代码结束 → 执行一次组件更新,读取最终状态 3。

这样可以减少:

  • 重复执行组件渲染函数;
  • 重复创建和比较 VNode;
  • 重复操作真实 DOM;
  • 同一轮业务逻辑中不必要的中间更新。

因此异步更新并不是为了让语法显得复杂,而是为了批处理和去重。

三、理解 Vue 调度前,先理解 JavaScript 事件循环

JavaScript 在浏览器主线程上执行代码时,首先运行当前调用栈中的同步任务。

异步任务完成后,并不是随时插入正在执行的函数,而是进入相应队列,等待事件循环调度。

可以先建立一个简化模型:

text 复制代码
执行一个任务中的同步代码
    ↓
调用栈清空
    ↓
清空本轮微任务队列
    ↓
浏览器可能进行样式计算、布局和绘制
    ↓
开始下一个任务

注意"可能进行渲染"中的"可能"。浏览器不会机械地在每个任务后都绘制一次,还会结合刷新时机和页面状态决定是否渲染。

四、什么是同步代码

普通语句会按照调用栈顺序直接执行:

js 复制代码
console.log('A')

function run() {
  console.log('B')
}

run()
console.log('C')

输出:

text 复制代码
A
B
C

调用函数时会进入新的函数执行上下文,但它仍然属于当前同步任务。

new Promise() 的执行器也是同步执行的:

js 复制代码
new Promise(resolve => {
  console.log('executor')
  resolve()
})

其中:

js 复制代码
resolve => {
  console.log('executor')
  resolve()
}

会在创建 Promise 时立即执行。

五、什么是宏任务

"宏任务"是前端面试中的常见叫法,更贴近规范的表达通常是 task。

常见任务来源包括:

text 复制代码
初始脚本执行;
setTimeout、setInterval 回调;
部分用户交互事件回调;
消息事件;
部分 I/O 回调。

例如:

js 复制代码
console.log('start')

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

console.log('end')

输出:

text 复制代码
start
end
timer

setTimeout(..., 0) 不是立即执行,而是达到最短等待条件后,其回调才有资格进入后续任务调度。

当前同步脚本必须先执行完。

六、什么是微任务

常见微任务包括:

text 复制代码
Promise.then、catch、finally 回调;
queueMicrotask() 回调;
MutationObserver 回调;
async 函数在 await 之后的继续执行部分。

例如:

js 复制代码
console.log('A')

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

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

console.log('D')

输出:

text 复制代码
A
D
B
C

原因是:

text 复制代码
A、D 是当前同步代码;
B 是当前任务结束后的微任务;
C 是后续定时器任务。

简化优先级可以记成:

text 复制代码
当前同步代码
    ↓
清空微任务队列
    ↓
下一个宏任务

七、分析上一篇提到的 Promise 题

代码:

js 复制代码
const promise = new Promise(resolve => {
  console.log('executor')

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

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

console.log('after')

输出:

text 复制代码
executor
after
timer
then

分析:

text 复制代码
new Promise 的 executor 同步执行 → executor;
setTimeout 注册后续任务;
then 此时只是登记回调,因为 Promise 仍为 pending;
继续执行同步代码 → after;
执行定时器任务 → timer;
resolve 使 Promise 完成,并安排 then 微任务;
定时器回调结束后清空微任务 → then。

关键点是:

text 复制代码
resolve() 不会在当前这一行直接同步执行 then 回调。

它会使相关 Promise reaction 在微任务中执行。

八、async 和 await 怎样进入微任务

看下面的代码:

js 复制代码
async function run() {
  console.log('A')
  await Promise.resolve()
  console.log('B')
}

console.log('C')
run()
console.log('D')

输出:

text 复制代码
C
A
D
B

可以先理解为:

text 复制代码
await 之前的代码同步执行;
await 之后的继续执行部分进入 Promise 相关的微任务流程。

因此:

js 复制代码
await nextTick()

不会阻塞整个浏览器线程。

它会暂停当前 async 函数后面的部分,等 nextTick() 返回的 Promise 完成后,再通过微任务继续执行。

九、浏览器渲染和微任务是什么关系

前端经常背:

text 复制代码
同步代码 → 微任务 → 浏览器渲染 → 宏任务

这是一种便于做题的简化模型,但不要把它理解为浏览器每清空一次微任务就必然绘制一帧。

更准确地说:

text 复制代码
当前任务执行结束;
微任务检查点清空微任务;
浏览器在合适的渲染机会更新页面;
继续处理后续任务。

这带来两个重要结论:

  1. 微任务太多、持续产生新微任务,也会阻塞渲染;
  2. Vue 完成 DOM 节点修改,不等于浏览器已经把新像素绘制到屏幕上。

后面讲 nextTick 时还会回到这个区别。

十、专栏 05 的同步 effect 有什么问题

上一篇为了展示原理,写过类似代码:

js 复制代码
function trigger(target, key) {
  const dep = getDep(target, key)

  dep.forEach(effectFn => {
    effectFn()
  })
}

这会让 effect 同步重新执行。

假设组件更新函数是:

js 复制代码
effect(() => {
  const vnode = render()
  patch(vnode)
})

连续修改三次数据,就可能同步执行三次渲染。

所以 effect 需要允许外部决定:

text 复制代码
依赖变化后是立即执行,还是放进队列。

这就是 scheduler 的作用。

十一、scheduler 是什么

可以给 effect 增加调度器:

js 复制代码
effect(componentUpdate, {
  scheduler(effectRunner) {
    queueJob(effectRunner)
  }
})

当依赖变化时,trigger 不再只能写:

js 复制代码
effectRunner()

而是判断:

js 复制代码
if (effectRunner.scheduler) {
  effectRunner.scheduler(effectRunner)
} else {
  effectRunner()
}

因此:

text 复制代码
trigger:说明这个 effect 已经需要更新;
scheduler:决定它何时以及以什么方式更新。

不同响应式订阅可以采用不同策略:

text 复制代码
组件 effect → 加入组件更新队列;
computed → 标记缓存失效;
默认 watch → 加入刷新前任务;
post watch → 加入刷新后任务;
sync watch → 立即执行。

十二、先手写一个最小任务队列

使用 Set 保存任务,可以天然去重:

js 复制代码
const jobQueue = new Set()
const resolvedPromise = Promise.resolve()

let isFlushPending = false

function queueJob(job) {
  jobQueue.add(job)

  if (!isFlushPending) {
    isFlushPending = true

    resolvedPromise.then(flushJobs)
  }
}

function flushJobs() {
  try {
    jobQueue.forEach(job => job())
  } finally {
    jobQueue.clear()
    isFlushPending = false
  }
}

执行:

js 复制代码
function updateComponent() {
  console.log('组件更新')
}

queueJob(updateComponent)
queueJob(updateComponent)
queueJob(updateComponent)

console.log('同步代码结束')

输出:

text 复制代码
同步代码结束
组件更新

同一个函数加入 Set 三次,最终只保留一份。

同时 Promise.resolve().then(flushJobs) 把刷新安排到微任务中,所以会先完成当前同步代码。

十三、为什么只安排一个刷新微任务

如果每次 queueJob 都执行:

js 复制代码
Promise.resolve().then(flushJobs)

连续入队三次,就会创建三个刷新微任务。

所以需要:

js 复制代码
let isFlushPending = false

第一次入队:

text 复制代码
isFlushPending 是 false;
创建刷新微任务;
改成 true。

后续继续入队:

text 复制代码
发现刷新已经被安排;
只加入任务,不再创建新的刷新微任务。

刷新结束后再恢复:

js 复制代码
isFlushPending = false

这样下一轮任务才能重新安排刷新。

十四、组件连续修改三次的完整过程

假设:

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)

function changeCount() {
  count.value++
  count.value++
  count.value++
}
</script>

<template>
  <button @click="changeCount">
    {{ count }}
  </button>
</template>

过程可以理解为:

text 复制代码
第一次 count++
→ trigger 组件 effect
→ scheduler 将组件任务入队
→ 安排刷新微任务

第二次 count++
→ 再次 trigger 同一组件 effect
→ 任务已经入队,不重复加入

第三次 count++
→ 仍然是同一个任务
→ 不重复加入

同步代码结束
→ 执行刷新微任务
→ 组件任务执行一次
→ render 读取 count 最终值 3
→ patch 更新 DOM

这里要区分两个次数:

text 复制代码
响应式属性确实被修改了三次;
组件 DOM 更新任务通常只执行一次。

十五、为什么任务队列还需要排序

只用 Set 去重,还没有处理父子组件顺序。

假设父组件创建在前,子组件创建在后。父组件更新时可能:

text 复制代码
给子组件传递新的 props;
根据 v-if 卸载子组件;
改变子组件是否需要继续更新。

因此组件更新通常需要满足:

text 复制代码
父组件更新任务先于子组件更新任务。

Vue 会为组件更新任务关联可以排序的标识。父组件先创建,通常拥有更小的组件 uid,调度队列按任务 id 保持合适顺序。

这样做还有一个重要作用:

text 复制代码
如果父组件更新时卸载了子组件,队列中对应的子组件更新可以被跳过。

当前 Vue 3 调度器会在入队时根据任务 id 寻找插入位置,而不是把"所有任务最后统一 sort"当作唯一实现方式。源码细节可能继续优化,但父先子后和可跳过失效任务是理解重点。

十六、真实 Vue 3 调度器包含什么

当前 Vue 3 的运行时调度器包含的核心概念包括:

text 复制代码
主任务队列 queue;
当前刷新位置 flushIndex;
后置回调队列 pendingPostFlushCbs;
当前刷新 Promise currentFlushPromise;
任务状态标记,例如 QUEUED、PRE、DISPOSED;
任务排序、去重和递归更新保护。

可以抽象成:

text 复制代码
queueJob(job)
    ↓
检查是否已经入队
    ↓
根据 id 放入合适位置
    ↓
queueFlush()
    ↓
Promise.resolve().then(flushJobs)
    ↓
执行刷新前任务和组件任务
    ↓
执行刷新后回调

真实源码比教学版复杂,是因为它还要处理:

  • 刷新过程中又产生新任务;
  • 父子组件顺序;
  • 组件已经卸载;
  • watch 回调允许的递归更新;
  • 生命周期钩子;
  • Suspense 缓冲的副作用;
  • 错误处理;
  • 防止无穷递归更新。

不要把"任务队列就是一个 Set"当成 Vue 当前源码的精确实现。Set 版本只是为了先理解批处理和去重。

十七、一份包含 nextTick 的简化调度器

我们可以在最小队列基础上保存当前刷新 Promise:

js 复制代码
const jobQueue = new Set()
const resolvedPromise = Promise.resolve()

let isFlushPending = false
let currentFlushPromise = null

function queueJob(job) {
  jobQueue.add(job)

  if (!isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

function flushJobs() {
  try {
    jobQueue.forEach(job => job())
  } finally {
    jobQueue.clear()
    isFlushPending = false
    currentFlushPromise = null
  }
}

function nextTick(callback) {
  const promise = currentFlushPromise || resolvedPromise

  return callback
    ? promise.then(callback)
    : promise
}

这里最关键的是:

js 复制代码
const promise = currentFlushPromise || resolvedPromise

如果当前已经安排了 Vue 队列刷新:

text 复制代码
nextTick 等待 currentFlushPromise;
也就是等待这轮 flushJobs 完成。

如果当前没有待刷新的 Vue 任务:

text 复制代码
nextTick 使用已经完成的 Promise;
传入回调时,回调仍会作为 Promise 微任务执行。

十八、nextTick 到底是什么

Vue 官方对 nextTick 的定位是:

text 复制代码
等待下一次 DOM 更新刷新完成的工具方法。

使用方式有两种。

1. 使用 await

js 复制代码
import { nextTick } from 'vue'

async function increment() {
  count.value++

  console.log(element.textContent) // 旧 DOM

  await nextTick()

  console.log(element.textContent) // 更新后的 DOM
}

2. 传入回调

js 复制代码
count.value++

nextTick(() => {
  console.log(element.textContent)
})

现代项目中,涉及多步异步流程时,await nextTick() 通常更加清晰。

十九、nextTick 不是简单的延时器

很多人把它理解成:

js 复制代码
setTimeout(callback, 0)

这是不准确的。

setTimeout 的含义是:

text 复制代码
安排一个后续计时器任务。

nextTick 的语义是:

text 复制代码
等待 Vue 当前已经安排的更新刷新完成。

在当前 Vue 3 调度器中,可以看到近似关系:

js 复制代码
function nextTick(fn) {
  const promise = currentFlushPromise || resolvedPromise
  return fn ? promise.then(fn) : promise
}

所以 nextTick 和 Vue 自己的刷新 Promise 发生关联,而不是随便等一个固定时间。

二十、nextTick 和 Promise.resolve 一样吗

不能简单画等号。

两者都可能利用 Promise 微任务,但等待目标不同。

js 复制代码
Promise.resolve().then(callback)

表示把 callback 接到一个已完成 Promise 后面。

而:

js 复制代码
nextTick(callback)

如果 Vue 当前正在等待刷新,它会把回调接到 currentFlushPromise 后面。

因此它表达的是:

text 复制代码
先等 Vue 当前刷新完成,再执行 callback。

不要依靠"它们都是微任务"就认为任意 Promise.resolve() 都能稳定替代 nextTick()。

二十一、nextTick 等待的是状态变化吗

不是。

状态在赋值时已经变化:

js 复制代码
count.value++

console.log(count.value) // 已经是新值

nextTick 主要等待的是:

text 复制代码
由这些状态变化触发的 Vue 更新队列完成刷新。

因此:

js 复制代码
await nextTick()

并不是让 count.value 过一会儿才变化,而是等待组件把新状态反映到 DOM。

二十二、nextTick 会等待网络请求吗

不会。

js 复制代码
async function loadUser() {
  fetchUser()
  await nextTick()
}

这里没有 await fetchUser(),nextTick 不会替你等待网络响应。

正确关系通常是:

js 复制代码
async function loadUser() {
  const user = await fetchUser()

  currentUser.value = user

  await nextTick()

  // 此时等待的是 currentUser 引发的 Vue DOM 更新
}

需要等待什么,就应该等待对应的 Promise:

text 复制代码
等待请求 → await fetch...;
等待 Vue DOM 更新 → await nextTick();
等待动画下一帧 → requestAnimationFrame();
等待一段时间 → setTimeout()。

二十三、nextTick 能保证浏览器已经绘制吗

不能把它绝对理解为"新像素已经出现在屏幕上"。

nextTick 主要保证 Vue 已经完成当前更新队列中的 DOM 提交。

而浏览器真正显示一帧还可能经历:

text 复制代码
样式计算;
布局;
绘制;
合成。

如果业务只需要读取 Vue 更新后的 DOM 内容或节点,一般使用 nextTick。

如果明确需要等待浏览器进入后续绘制帧,例如启动某些依赖帧边界的动画,可以结合:

js 复制代码
await nextTick()

requestAnimationFrame(() => {
  // 浏览器下一帧相关操作
})

所以两者解决的问题不同:

text 复制代码
nextTick:等待 Vue 更新队列;
requestAnimationFrame:把回调安排到浏览器下一次重绘前。

二十四、nextTick 的常见使用场景

1. v-if 创建元素后聚焦

vue 复制代码
<script setup>
import { nextTick, ref } from 'vue'

const editing = ref(false)
const inputRef = ref()

async function startEdit() {
  editing.value = true

  await nextTick()

  inputRef.value.focus()
}
</script>

<template>
  <button @click="startEdit">编辑</button>
  <input v-if="editing" ref="inputRef" />
</template>

赋值后立即访问:

js 复制代码
inputRef.value.focus()

可能失败,因为 v-if 对应的输入框还没有挂载。

2. 列表更新后滚动到底部

js 复制代码
async function addMessage(message) {
  messages.value.push(message)

  await nextTick()

  container.value.scrollTop = container.value.scrollHeight
}

必须先让新消息节点进入 DOM,才能读取新的 scrollHeight。

3. DOM 更新后测量尺寸

js 复制代码
expanded.value = true

await nextTick()

const height = panel.value.getBoundingClientRect().height

4. 与依赖 DOM 的第三方库同步

js 复制代码
chartData.value = nextData

await nextTick()

chart.resize()

但如果第三方库本身有明确的更新 API,应该优先理解它的数据流,不要把所有同步问题都用 nextTick 掩盖。

二十五、什么时候不需要 nextTick

1. 只读取新的状态值

js 复制代码
count.value++
console.log(count.value)

不需要等待,因为状态已经同步变化。

2. 普通业务计算

js 复制代码
price.value = 100
quantity.value = 2

const total = price.value * quantity.value

这和 DOM 无关,不需要 nextTick。

3. 等待网络请求

应该:

js 复制代码
const result = await fetchData()

而不是:

js 复制代码
await nextTick()

4. 仅仅觉得执行顺序"不对"

不要先到处添加:

js 复制代码
await nextTick()

应该先确认:

text 复制代码
等待的是 Vue DOM 更新、网络、计时器,还是浏览器绘制?

二十六、watch 为什么也需要调度

状态变化时,可能同时触发:

text 复制代码
组件更新 effect;
用户 watch 回调;
watchEffect;
computed 失效。

如果所有回调都立刻同步执行,会产生重复调用,也难以控制回调读取的是更新前还是更新后的 DOM。

因此 watch 提供:

js 复制代码
flush: 'pre'
flush: 'post'
flush: 'sync'

它决定侦听器回调相对组件更新的执行时机。

二十七、flush: 'pre'

默认配置是:

js 复制代码
watch(source, callback, {
  flush: 'pre'
})

也可以省略:

js 复制代码
watch(source, callback)

官方文档对默认侦听器时机的描述是:

text 复制代码
如果存在父组件更新,在父组件更新之后;
在当前侦听器所属组件的 DOM 更新之前。

因此在默认 watch 回调中读取当前组件 DOM,通常还是更新前的内容。

js 复制代码
watch(count, () => {
  console.log(element.value.textContent)
})

如果目标是根据状态变化继续修改其他状态,默认 pre 通常很合适。

例如:

js 复制代码
watch(keyword, () => {
  page.value = 1
})

这些修改有机会在当前组件本轮渲染前被纳入更新结果。

二十八、flush: 'post'

如果侦听器需要读取所属组件更新后的 DOM,可以使用:

js 复制代码
watch(count, () => {
  console.log(element.value.textContent)
}, {
  flush: 'post'
})

或者:

js 复制代码
watchEffect(() => {
  console.log(element.value?.textContent)
}, {
  flush: 'post'
})

watchEffect 还有别名:

js 复制代码
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  console.log(element.value?.textContent)
})

可以把 post 理解为:

text 复制代码
把侦听器回调放到组件 DOM 更新之后的后置队列。

适合:

  • 读取更新后的 DOM;
  • 测量元素;
  • 操作依赖最新节点结构的第三方库;
  • 某些需要在视图提交后执行的副作用。

二十九、flush: 'sync'

同步侦听:

js 复制代码
watch(count, value => {
  console.log('同步:', value)
}, {
  flush: 'sync'
})

依赖发生变化时,回调会同步触发,不等待 Vue 的批处理队列。

例如:

js 复制代码
count.value++
console.log('修改结束')

可能先输出:

text 复制代码
同步:1
修改结束

watchEffect 对应别名是:

js 复制代码
import { watchSyncEffect } from 'vue'

同步侦听器要谨慎使用,因为它不会进行常规批处理。

例如:

js 复制代码
for (let i = 0; i < 1000; i++) {
  list.value.push(i)
}

如果同步侦听这类频繁变化的数据,回调可能执行很多次。

适合 sync 的通常是简单、更新频率低并且确实要求同步响应的状态,不应该把它当成"更快的 watch"。

三十、三种 flush 怎样选择

配置 执行时机 是否通常批处理 常见用途
pre 所属组件 DOM 更新前 是 根据变化执行逻辑、继续调整状态
post 所属组件 DOM 更新后 是 读取或操作更新后的 DOM
sync 响应式依赖变化时同步执行 否 少量必须同步处理的简单状态

选择时先问:

text 复制代码
回调是否需要访问更新后的 DOM?

如果不需要,通常保持默认 pre。

如果需要,使用 post 或在明确操作点 await nextTick()。

只有必须同步响应时才考虑 sync。

三十一、post watch 和 nextTick 有什么区别

两者都可能在组件 DOM 更新后执行,但使用语义不同。

post watch

js 复制代码
watch(source, callback, {
  flush: 'post'
})

表达:

text 复制代码
以后每次 source 变化并完成组件更新后,都执行 callback。

nextTick

js 复制代码
source.value = nextValue
await nextTick()

表达:

text 复制代码
我在当前这次操作之后,等待本轮 Vue DOM 更新完成,再继续执行。

所以:

text 复制代码
持续监听某个来源并在 DOM 后处理 → post watch;
某个具体方法中只等待这一次更新 → nextTick。

在常见刷新流程中,post-flush 回调属于 Vue 刷新过程的一部分,而 nextTick 回调通常接在当前刷新 Promise 完成之后,因此 post 回调通常早于随后等待的 nextTick 继续部分。

三十二、同一组件常见的更新阶段

对一个组件进行简化,可以先记成:

text 复制代码
同步修改响应式状态
    ↓
sync watcher 同步触发
    ↓
当前同步代码执行完毕
    ↓
pre watcher
    ↓
组件重新渲染并更新 DOM
    ↓
post watcher / onUpdated
    ↓
nextTick 后续代码

这是一张帮助理解的阶段图,不应当脱离父子组件、任务创建顺序、嵌套更新等上下文,用来死猜所有复杂日志题。

真正分析代码时仍要确认:

text 复制代码
是谁先入队;
属于哪个组件;
回调是哪种 flush;
是否在刷新过程中产生了新任务;
Promise 回调在哪一刻被注册。

三十三、父子组件为什么通常父组件先更新

假设父组件向子组件传递:

vue 复制代码
<Child :count="count" />

父组件修改 count 后:

text 复制代码
父组件需要重新渲染;
新的 count 作为 props 传给子组件;
子组件再根据新 props 更新。

所以组件更新任务通常按父到子执行。

当前 Vue 调度器的任务顺序设计也明确保障父组件任务优先,因为父组件通常先创建、任务 id 更小。

简化顺序:

text 复制代码
父组件更新任务
    ↓
子组件更新任务

这不等于所有生命周期钩子都是父先子后。

三十四、为什么 updated 钩子通常子组件先执行

Vue 官方文档明确说明:

text 复制代码
父组件的 updated 钩子在子组件 updated 钩子之后调用。

可以理解为:

text 复制代码
先完成父组件更新过程;
处理子组件更新;
子组件确认更新完成;
最后父组件确认自己的整棵相关子树更新完成。

常见简化顺序是:

text 复制代码
父 beforeUpdate
子 beforeUpdate
子 updated
父 updated

但实际是否触发某个组件更新,仍取决于它的依赖、props 和渲染结果,不能只靠背固定口诀分析所有情况。

三十五、为什么不要在 updated 中修改状态

js 复制代码
onUpdated(() => {
  count.value++
})

可能形成:

text 复制代码
count 变化
→ 组件更新
→ onUpdated
→ 再修改 count
→ 再次组件更新
→ 再次 onUpdated
→ 无限循环

如果确实需要根据某个数据变化执行逻辑,通常应该明确使用:

text 复制代码
computed;
watch;
事件处理函数;
一次性的初始化逻辑。

而不是在每次组件更新后无条件修改组件状态。

三十六、任务去重是不是会丢失数据变化

不会。

js 复制代码
count.value = 1
count.value = 2
count.value = 3

每次赋值都真实修改了响应式状态,也都可能触发依赖通知。

去重的是:

text 复制代码
同一个组件的更新任务。

任务最终执行时会读取最新状态:

js 复制代码
render() {
  return count.value // 3
}

因此中间值不会逐个反映到 DOM,但最终状态不会丢失。

三十七、刷新过程中又产生任务怎么办

组件更新期间可能继续触发响应式变化:

text 复制代码
父组件更新 props;
子组件侦听 props 后修改自己的状态;
某个 watch 回调触发另一个响应式修改。

真实调度器需要允许新任务在刷新期间进入合适位置,并在当前刷新或后续刷新中继续处理。

因此不能简单实现成:

js 复制代码
const jobs = [...queue]
queue.clear()
jobs.forEach(job => job())

然后完全忽略执行过程中出现的新任务。

Vue 的调度器会维护当前刷新索引、任务标记和不同阶段队列,确保新增任务被合理处理,同时避免同一任务无意义地递归触发自己。

三十八、为什么需要防止递归更新

下面的 watch 会不断修改自己的数据源:

js 复制代码
watch(count, () => {
  count.value++
})

过程是:

text 复制代码
count 变化
→ watch 回调
→ count 再变化
→ watch 回调再次执行
→ 持续循环

框架可以检测异常的递归更新并给出警告,但根本问题仍然是业务逻辑没有收敛条件。

正确代码必须保证最终停止,例如:

js 复制代码
watch(count, value => {
  if (value > 10) {
    count.value = 10
  }
})

即使有防护,也不应该依赖框架的递归限制来设计业务流程。

三十九、Vue 2 也有异步更新队列吗

有。

Vue 2 使用 Object.defineProperty、Dep 和 Watcher 完成响应式,但 Watcher 收到通知后,同样通常不会每次都立即更新 DOM。

Vue 2 的整体链路可以概括为:

text 复制代码
响应式 setter
    ↓
dep.notify()
    ↓
watcher.update()
    ↓
queueWatcher(watcher)
    ↓
同一 Watcher 去重
    ↓
nextTick(flushSchedulerQueue)
    ↓
统一运行 Watcher

Vue 2 官方文档同样强调:如果一个 Watcher 在同一事件循环中被多次触发,只会进入队列一次,以避免不必要的计算和 DOM 操作。

因此:

text 复制代码
响应式拦截方式不同,不代表 Vue 2 没有异步批处理。

四十、Vue 2 和 Vue 3 调度机制怎样对照

环节 Vue 2 Vue 3
响应式订阅者 Watcher ReactiveEffect / scheduler job
收到更新 watcher.update() effect 的 scheduler
加入队列 queueWatcher() queueJob() 等调度入口
去重目标 同一个 Watcher 同一个 job 的已入队状态
队列刷新 flushSchedulerQueue() flushJobs()
等待更新 Vue.nextTick / vm.$nextTick nextTick / this.$nextTick

两者核心目的相同:

text 复制代码
把同一轮状态变化合并为尽量少的组件更新。

Vue 3 对任务阶段、组件 effect、pre/post 回调和调度标记进行了新的组织,但"通知后先入队,再统一刷新"的核心思想延续了下来。

四十一、一道 Vue 更新顺序题

假设组件更新已经被 count 修改触发:

js 复制代码
console.log('start')

count.value++

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

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

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

console.log('end')

在当前常见 Vue 3 调度路径下,仅观察这些日志,通常得到:

text 复制代码
start
end
promise
nextTick
timer

为什么 promise 可能在 nextTick 前面?

count.value++ 先安排 Vue 的刷新 Promise。

nextTick 不是立即把自己的回调作为独立微任务塞入队列,而是把回调接到当前刷新 Promise 后面:

text 复制代码
先完成 Vue flushJobs;
当前刷新 Promise 完成;
再安排 nextTick 回调。

而后面的:

js 复制代码
Promise.resolve().then(() => {
  console.log('promise')
})

会直接登记自己的微任务。

所以不能只背:

text 复制代码
nextTick 是微任务,Promise.then 也是微任务,谁写前面谁先执行。

还要看回调连接的是哪个 Promise,以及这个 Promise什么时候完成。

如果把 Promise 和 nextTick 的注册顺序、状态修改位置改变,结果也可能改变。做题时应画出真实的 Promise 依赖关系。

四十二、加入 onUpdated 后怎样分析

js 复制代码
onUpdated(() => {
  console.log('updated')
})

async function increment() {
  console.log('start')

  count.value++

  await nextTick()

  console.log('after nextTick')
}

常见顺序:

text 复制代码
start
updated
after nextTick

因为:

text 复制代码
onUpdated 属于组件更新后的 Vue 刷新阶段;
await nextTick() 的后续代码等待本次刷新 Promise 完成。

但 onUpdated 会在组件的任意 DOM 更新后运行,而 nextTick 更适合等待某一次明确状态修改引起的 DOM 更新。

四十三、常见错误

1. 认为响应式数据异步赋值

错误理解:

text 复制代码
count.value++ 后,count 还没有变。

正确理解:

text 复制代码
count 已经同步变化,只是 DOM 更新被调度。

2. 用 setTimeout 替代 nextTick

js 复制代码
setTimeout(() => {
  // 猜测 Vue 应该更新完了
}, 0)

这种写法表达的是等待定时器任务,不是等待 Vue 当前刷新。

需要等待 Vue DOM 更新时应该使用:

js 复制代码
await nextTick()

3. 到处使用 nextTick

如果大量业务代码都依赖 nextTick 才能工作,可能说明:

text 复制代码
过度直接操作 DOM;
状态职责不清晰;
组件通信方向混乱;
把请求、动画和 Vue 更新混成了一件事。

4. 使用 sync watch 追踪频繁变化的大数组

同步侦听器不会进行常规批处理,可能导致大量重复回调。

5. 在 onUpdated 中无条件修改状态

这很容易造成递归更新。

6. 认为 nextTick 一定等待浏览器绘制完成

它等待的是 Vue 更新队列,不等同于显示器已经呈现新像素。

7. 认为任务去重会丢失最终数据

去重的是更新任务,组件执行时读取的是最新响应式状态。

8. 只背宏任务和微任务优先级

复杂题还要分析:

text 复制代码
Promise 在什么时候完成;
then 在什么时候注册;
微任务执行中是否创建了新微任务;
Vue 更新任务在什么时候入队。

四十四、项目中怎样选择正确等待方式

需求 推荐方式
等待网络响应 await fetch() 或请求库 Promise
等待 Vue 更新 DOM await nextTick()
持续在某来源变化且 DOM 更新后处理 watch(..., { flush: 'post' })
在浏览器下一帧执行 requestAnimationFrame()
延迟固定时间 setTimeout()
等待组件挂载 onMounted()
等待任意组件更新完成 onUpdated(),但要谨慎控制范围

代码是否正确,往往取决于你是否明确自己在等待什么。

四十五、面试高频问题

1. Vue 的数据更新是异步的吗

可以这样回答:

响应式状态本身通常是同步修改的,异步的是由状态变化触发的组件渲染和 DOM 更新。Vue 会通过 scheduler 将组件更新任务放入队列,在当前同步代码结束后批量刷新,并对同一组件任务去重。

2. Vue 为什么采用异步更新队列

可以这样回答:

同一轮同步代码中可能连续修改多次状态。如果每次都立即渲染,会产生大量无意义的中间 VNode 计算和 DOM 操作。Vue 将更新任务缓存、去重并统一刷新,使同一组件通常只根据最终状态更新一次。

3. nextTick 是什么

可以这样回答:

nextTick 是等待 Vue 当前 DOM 更新刷新完成的工具。Vue 3 调度器会保存当前刷新 Promise,nextTick 在有待处理更新时等待这个 Promise,之后再执行回调或恢复 await 后的代码;没有待处理刷新时则基于已完成 Promise 安排后续微任务。

4. nextTick 是 setTimeout 吗

可以这样回答:

不是。setTimeout 安排的是后续计时器任务,nextTick 的语义是等待 Vue 当前更新队列完成。在现代 Vue 3 中它与当前调度刷新 Promise 关联,通常利用 Promise 微任务,而不是简单固定延迟。

5. nextTick 和 Promise.resolve 有什么区别

可以这样回答:

两者都可能涉及 Promise 微任务,但 Promise.resolve().then 只是在已完成 Promise 后登记回调;nextTick 在存在 Vue 待刷新任务时,会等待当前 flush Promise,表达的是"Vue 本轮 DOM 更新完成后继续"。因此不能把任意 Promise.resolve 当成 nextTick 的稳定替代品。

6. 连续修改三次状态,组件更新几次

可以这样回答:

状态会同步修改三次,相关依赖也可能被触发三次,但同一个组件更新任务在同一批次中通常只入队一次。队列刷新时组件读取最终状态并更新一次。具体还要考虑 sync watcher、不同任务边界以及更新过程中产生的新任务。

7. Vue 怎样对任务去重

可以这样回答:

教学模型可以用 Set 对相同函数去重。当前 Vue 3 调度器使用任务的入队状态标记并维护有序队列,避免同一任务在不允许重复入队时被反复添加,同时还要处理刷新期间新增任务和允许递归的特定回调。

8. 为什么父组件更新任务通常先执行

可以这样回答:

父组件先创建,通常拥有更小的组件 uid。调度队列根据任务 id 保持父先子后的顺序,这样父组件可以先更新子组件 props;如果父组件在更新中卸载子组件,子组件队列任务还可以被跳过。

9. 父子组件 updated 顺序是什么

可以这样回答:

组件更新任务通常父先子后,但更新完成钩子通常子先父后。Vue 官方明确说明父组件 updated 会在子组件 updated 之后执行。常见简化顺序是父 beforeUpdate、子 beforeUpdate、子 updated、父 updated,但仍要判断哪些组件实际发生了更新。

10. watch 默认什么时候执行

可以这样回答:

默认 flush: 'pre'。如果存在父组件更新,回调在父组件更新之后,并在侦听器所属组件的 DOM 更新之前执行,因此默认 watch 中读取当前组件 DOM 通常得到旧内容。

11. flush: post 有什么用

可以这样回答:

post watch 会在所属组件 DOM 更新后执行,适合读取或操作最新 DOM。watchPostEffect 是 post 模式 watchEffect 的便捷别名。

12. flush: sync 有什么风险

可以这样回答:

sync watcher 在依赖变化时同步触发,不经过常规批处理。频繁修改数组或循环赋值时可能执行大量回调,还容易形成递归更新,因此只适合少量、简单且确实要求同步响应的数据。

13. post watch 和 nextTick 怎样选择

可以这样回答:

如果需要持续监听某个来源,并在每次所属组件 DOM 更新后处理,使用 post watch;如果是在某个方法中完成一次明确状态修改后,只需要等待本轮 DOM 更新,使用 await nextTick 更直接。

14. nextTick 会等待请求和浏览器绘制吗

可以这样回答:

不会等待网络请求,网络 Promise 要单独 await。nextTick 主要等待 Vue 更新队列和 DOM 提交,也不应绝对等同于浏览器已经绘制到屏幕;需要帧边界时应结合 requestAnimationFrame。

四十六、一张完整流程图

text 复制代码
用户点击按钮
    ↓
事件处理函数同步执行
    ↓
count.value++
    ↓
ref setter / reactive Proxy set
    ↓
trigger 通知组件 ReactiveEffect
    ↓
effect.scheduler()
    ↓
queueJob(componentUpdateJob)
    ↓
任务去重并按 id 放入队列
    ↓
安排 Promise 微任务 flushJobs
    ↓
继续执行当前同步代码
    ↓
当前调用栈清空
    ↓
刷新 Vue 队列
    ↓
执行 pre watcher
    ↓
父组件、子组件按调度顺序更新
    ↓
render 生成新 VNode
    ↓
patch 更新真实 DOM
    ↓
执行 post watcher 和 updated 钩子
    ↓
当前刷新 Promise 完成
    ↓
nextTick 后续代码执行
    ↓
浏览器在合适机会渲染页面

四十七、总结

本篇最重要的第一句话是:

text 复制代码
Vue 的状态通常同步改变,DOM 更新经过异步调度。

状态变化后:

text 复制代码
trigger 找到组件 effect;
scheduler 不立即渲染,而是把组件任务加入队列;
队列对同一任务去重,并维持必要的父子顺序;
当前同步代码结束后,通过微任务刷新队列;
组件读取最终状态,完成一次渲染和 DOM 更新。

nextTick 的重点不是"延迟一下",而是:

text 复制代码
等待 Vue 当前更新队列刷新完成。

watch 的三种时机可以记成:

text 复制代码
pre:所属组件 DOM 更新前,默认值;
post:所属组件 DOM 更新后;
sync:响应式变化时同步执行,不进行常规批处理。

最后再把几个概念放在正确位置:

text 复制代码
Promise.then:微任务回调;
setTimeout:后续计时器任务;
nextTick:等待 Vue 更新刷新;
requestAnimationFrame:在浏览器下一次重绘前执行;
onUpdated:组件 DOM 更新后的生命周期钩子。

理解调度器后,专栏 05 的响应式链路才真正完整:

text 复制代码
Proxy / ref 拦截变化
    ↓
track / trigger 管理依赖
    ↓
scheduler 调度组件任务
    ↓
render 重新生成虚拟 DOM
    ↓
patch 更新真实 DOM

下一篇将进入专栏 07:模板编译、虚拟 DOM、Diff 与 key,重点讲解:

text 复制代码
Vue 模板怎样编译成 render 函数;
VNode 和真实 DOM 有什么区别;
响应式系统为什么要和虚拟 DOM 配合;
Vue 2 双端 Diff 怎样工作;
Vue 3 Diff 做了哪些优化;
key 为什么不能随便使用 index;
最长递增子序列解决了什么问题。

参考资料

相关推荐
deli00733 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js