前端 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、setTimeoutasync/await 的执行顺序;
  • Vue 为什么异步更新 DOM;
  • scheduler 和组件更新队列;
  • 任务怎样去重、排序和刷新;
  • nextTick 到底等待什么;
  • watchflush: '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;
最长递增子序列解决了什么问题。

参考资料

相关推荐
晴天161 小时前
Chrome DevTools 深度调试指南
前端·chrome·chrome devtools
林冠宏_指尖下的幽灵1 小时前
AI发展下的后编程时代思考
前端·人工智能·后端
_山海2 小时前
Bun入门指南
前端·javascript·后端
想睡懒觉2 小时前
我用 CSS 3D 做了个星空隧道 Loading,没有 Three.js
前端
labixiong2 小时前
transition 写在 CSS 变量上为什么不生效?给变量补张「类型身份证」
前端·css·html
YHL2 小时前
🧠 Agent 记忆进阶:总结压缩与向量检索 —— 从截断到 Milvus 长期记忆
前端·人工智能
用户921080262862 小时前
前端 Vue 专栏 05:从 Object.defineProperty 到 Proxy,彻底理解 Vue 响应式原理
前端
Fluxart.ai3 小时前
GPT Image 2.5 有使用次数限制吗?额度、并发、频率限制说明
服务器·前端·gpt
程序员海军3 小时前
AI 越来越强,为什么打工人反而越来越累、越来越内耗了?
前端·程序员·aigc