Vue 基础体系 · 第 27/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 调度器与 nextTick:批量更新、Flush 时机和 DOM 可见性

在 Vue 3 中,修改响应式状态并不等于浏览器已经立刻修改了 DOM。状态变化首先会触发依赖它的响应式副作用,Vue 再通过调度器安排这些副作用的执行,最后由组件渲染函数生成新的虚拟 DOM 并完成真实 DOM 更新。

因此,下面三件事必须区分:

  1. 状态何时改变:例如 count.value++ 执行的时刻。
  2. Vue 何时完成组件更新:渲染副作用执行并 patch DOM 的时刻。
  3. 浏览器何时真正绘制到屏幕:渲染管线提交视觉结果的时刻。

nextTick() 主要解决第二个问题,但它不等价于“下一帧已经绘制完成”。


一、先建立模型:状态、渲染副作用和 DOM

一个组件可以抽象成如下关系:

D=P(R(S))D = P(R(S))

其中:

  • SS:组件当前的响应式状态;
  • RR:组件的渲染函数,根据状态生成虚拟 DOM;
  • PP:patch 过程,把新旧虚拟 DOM 的差异应用到真实 DOM;
  • DD:页面中的真实 DOM。

例如:

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

const count = ref(0)
</script>

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

初始状态为:

S0 = { count: 0 }
D0 = <button>0</button>

执行:

count.value++

后,状态立刻变为:

S1 = { count: 1 }

但这一步通常只完成了响应式依赖通知和更新任务入队,不能据此断言真实 DOM 已经变成:

<button>1</button>

实际过程更接近:

修改响应式状态
    ↓
触发依赖该状态的副作用
    ↓
组件更新任务进入调度队列
    ↓
当前同步代码执行完毕
    ↓
Vue 刷新队列
    ↓
组件重新渲染并 patch DOM

组件的渲染副作用不是简单地在每次状态修改时同步执行,而是由 Vue 的调度器统一安排。


二、为什么需要调度器:避免同一轮同步代码重复渲染

1. 没有批处理时会发生什么

假设组件依赖 firstNamelastName

firstName.value = 'Ada'
lastName.value = 'Lovelace'

如果每次赋值都同步触发组件渲染,理论上可能出现:

旧状态:{ firstName: 'Grace', lastName: 'Hopper' }

第一次赋值:
{ firstName: 'Ada', lastName: 'Hopper' }
→ 渲染一次

第二次赋值:
{ firstName: 'Ada', lastName: 'Lovelace' }
→ 再渲染一次

但第一次中间状态通常没有任何业务价值。用户并不需要看到一个短暂的:

Ada Hopper

Vue 更倾向于等待当前同步任务结束,只使用最终状态执行一次组件更新:

旧状态:{ firstName: 'Grace', lastName: 'Hopper' }

连续修改:
{ firstName: 'Ada', lastName: 'Hopper' }
{ firstName: 'Ada', lastName: 'Lovelace' }

本轮最终状态:
{ firstName: 'Ada', lastName: 'Lovelace' }

→ 组件更新一次

可以把这一过程形式化为:

S0m1S1m2S2mkSkS_0 \xrightarrow{m_1} S_1 \xrightarrow{m_2} S_2 \cdots \xrightarrow{m_k} S_k

调度器在同一个批处理窗口内只需要安排一次渲染:

Dnew=P(R(Sk))D_{new} = P(R(S_k))

而不是:

P(R(S1)),P(R(S2)),,P(R(Sk))P(R(S_1)), P(R(S_2)), \ldots, P(R(S_k))

这就是 Vue 更新批处理的核心直觉:中间状态可以存在于 JavaScript 内存中,但不必为每个中间状态都执行一次 DOM 更新。

2. 队列去重

Vue 的组件更新任务通常会被放入一个队列。对于同一个组件,在一次尚未完成的刷新过程中,重复触发通常不会导致同一个更新任务重复排队。

因此:

count.value++
count.value++
count.value++

在同一个同步调用栈中,组件通常只会根据最终值重新渲染一次:

count: 0 → 1 → 2 → 3
最终执行一次组件更新,渲染 count = 3

这并不意味着所有响应式副作用都绝对只运行一次。需要区分:

  • 组件更新任务通常会被调度并去重;
  • 普通 watch 默认也会进入异步调度流程,并通常在一轮中合并触发;
  • flush: 'sync' 的 watcher 不进入异步批处理;
  • 不同类型、不同来源的副作用有不同调度规则。

因此,“Vue 会批量更新”不是“任何回调都只执行一次”,而是“调度器会按照副作用类型和刷新阶段安排执行”。


三、Vue 3 调度器的基本刷新结构

Vue 的内部实现细节会随版本调整,但 Vue 3 的公开行为可以用以下阶段理解:

flowchart TD
    A[同步修改响应式状态] --> B[触发依赖]
    B --> C{副作用类型}
    C -->|flush sync| D[立即执行]
    C -->|默认 watcher| E[进入 pre 队列]
    C -->|组件更新| F[进入组件更新队列]
    C -->|flush post watcher| G[进入 post 队列]

    E --> H[微任务开始刷新]
    F --> H
    G --> H

    H --> I[执行 pre 回调]
    I --> J[执行组件更新与 DOM patch]
    J --> K[执行 post 回调]
    K --> L[当前 flush 完成]
    L --> M[nextTick 继续执行]

这里的“微任务”通常指由 Promise 机制安排的任务。Vue 使用微任务是常见且重要的实现方式,但应用代码应依赖公开时序语义,而不应依赖某个内部函数名或内部队列变量。

1. flush: 'pre'

默认 watcher 的刷新时机通常是 pre。它会在所属组件 DOM 更新之前执行。

watch(
  count,
  () => {
    console.log('watch callback')
  }
)

如果这个 watcher 属于某个组件,那么回调执行时,该组件因 count 变化而产生的 DOM 更新通常还没有完成。因此在回调中直接读取该组件的 DOM,可能读到旧值。

2. 组件更新队列

组件更新任务负责重新执行组件渲染逻辑,并将新旧虚拟 DOM 的差异 patch 到真实 DOM。

对于:

<template>
  <p>{{ count }}</p>
</template>

组件更新执行时会重新计算模板对应的渲染结果,发现文本节点从 0 变为 1,然后更新真实 DOM。

3. flush: 'post'

flush: 'post' 的 watcher 会安排在组件 DOM 更新之后执行:

watch(
  count,
  () => {
    const text = document.querySelector('#count')?.textContent
    console.log(text)
  },
  { flush: 'post' }
)

这适合需要读取因本次状态变更而更新的 DOM 的场景。

但“post”应理解为“Vue 的组件更新之后”,不能直接理解为“浏览器已经绘制完成”。这两个时刻仍然不同,后文会详细说明。

4. flush: 'sync'

flush: 'sync' 会同步执行 watcher:

watch(
  count,
  () => {
    console.log('同步执行')
  },
  { flush: 'sync' }
)

执行:

count.value++
console.log('after mutation')

典型顺序是:

同步执行
after mutation

而默认 watcher 通常更接近:

after mutation
watch callback

同步 watcher 的代价是失去批处理能力。例如:

for (let i = 0; i < 1000; i++) {
  count.value++
}

若 watcher 使用 flush: 'sync',回调可能同步执行很多次。它适合极少量、明确需要同步观察的状态,不适合高频数组修改、复杂计算或可能递归修改状态的逻辑。


四、nextTick() 到底等待什么

nextTick() 的目标是让代码在当前待处理的 Vue 更新刷新后继续执行。

最常见的用法是:

import { nextTick, ref } from 'vue'

const count = ref(0)

async function increment() {
  count.value++

  console.log(document.querySelector('#count')?.textContent)
  await nextTick()
  console.log(document.querySelector('#count')?.textContent)
}

模板:

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

典型输出:

0
1

原因是:

  1. count.value++ 只改变响应式状态;
  2. 当前函数继续执行,因此立即读取 DOM 时,Vue 还没有完成组件 patch;
  3. await nextTick() 等待当前调度刷新;
  4. 组件更新完成后,读取到新文本。

可以把 nextTick 看作一个“Vue 更新屏障”:

状态修改排队flushnextTick 之后的代码\text{状态修改} \rightarrow \text{排队} \rightarrow \text{flush} \rightarrow \text{nextTick 之后的代码}

nextTick 的两种形式

await nextTick()

或者:

nextTick(() => {
  // Vue 更新完成后的逻辑
})

它们表达的是相同的时序意图。实际项目中,await nextTick() 通常更容易与顺序逻辑结合:

async function openPanel() {
  visible.value = true
  await nextTick()

  panelRef.value?.focus()
}

这段代码的前提是:panelRef 对应的元素确实会因为 visible 变为 true 而挂载,或者至少变为可访问状态。


五、nextTick 与微任务的关系

Vue 3 的常见实现会为当前刷新过程创建一个 Promise,并让 nextTick 等待这个 Promise。可以用简化模型表示:

let currentFlushPromise: Promise<void> | null = null

function queueFlush() {
  if (!currentFlushPromise) {
    currentFlushPromise = Promise.resolve().then(flushJobs)
  }
}

这不是应用代码应复制的内部实现,而是帮助理解时序的模型。

假设执行:

count.value++
console.log('A')

await nextTick()
console.log('B')

大致顺序是:

修改状态
进入当前调用栈
输出 A
当前同步代码结束
Promise 微任务开始
执行 Vue flush
完成组件 DOM patch
nextTick 继续
输出 B

因此 nextTick 不是一个固定毫秒数的延迟:

await nextTick()

不表示:

  • 等待 16.67ms;
  • 等待浏览器下一帧;
  • 等待网络请求;
  • 等待 CSS、字体或图片加载;
  • 等待所有异步任务完成。

它只等待当前 Vue 更新批次相关的刷新完成。

如果此时没有待处理的 Vue 更新,nextTick() 也不会主动触发一次组件渲染或强制等待下一帧。它只是等待 Vue 当前可等待的调度点。


六、完整算例:一次同步任务中的状态变化

下面的组件可以直接用于 Vue 3 + TypeScript + Vite 项目。

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

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

watch(count, () => {
  console.log(
    'pre watcher:',
    countElement.value?.textContent
  )
})

watch(
  count,
  () => {
    console.log(
      'post watcher:',
      countElement.value?.textContent
    )
  },
  { flush: 'post' }
)

onUpdated(() => {
  console.log(
    'onUpdated:',
    countElement.value?.textContent
  )
})

async function updateThreeTimes() {
  console.log('before:', count.value)

  count.value++
  count.value++
  count.value++

  console.log(
    'after mutations:',
    count.value,
    countElement.value?.textContent
  )

  await nextTick()

  console.log(
    'after nextTick:',
    count.value,
    countElement.value?.textContent
  )
}
</script>

<template>
  <p ref="countElement">{{ count }}</p>
  <button type="button" @click="updateThreeTimes">
    连续增加三次
  </button>
</template>

一次点击中,状态变化是:

count: 0 → 1 → 2 → 3

关键结果通常是:

before: 0
after mutations: 3 0
pre watcher: 0
post watcher: 3
onUpdated: 3
after nextTick: 3 3

这里最需要注意的是:

  • after mutations 中,响应式状态已经是 3
  • 但 DOM 仍可能是 0
  • pre watcher 读取的是更新前 DOM;
  • post watcher 读取的是组件更新后的 DOM;
  • onUpdated 也发生在组件更新后;
  • nextTick 的继续执行位于当前 flush 完成之后。

具体日志的细节可能受组件层级、注册顺序和 Vue 版本实现影响,不能把日志顺序当作所有场景下的完整规范。但“pre 早于所属组件 DOM 更新、post 晚于所属组件 DOM 更新、nextTick 等待当前刷新完成”是应当掌握的公开时序模型。


七、nextTickflush: 'post'onUpdated 如何选择

这三个 API 都可能被用于“DOM 更新后执行逻辑”,但触发语义不同。

1. flush: 'post':关注某个响应式源

watch(
  items,
  () => {
    scrollToLastItem()
  },
  { flush: 'post' }
)

它表达的是:

items 的变化导致组件更新后,执行这个 watcher。

适合:

  • 依赖某个状态变化;
  • 需要读取该状态对应的新 DOM;
  • 只希望由指定响应式源触发。

2. onUpdated:关注组件更新生命周期

onUpdated(() => {
  measureComponent()
})

它表达的是:

该组件完成一次更新后执行。

它不专门绑定某一个状态。组件可能因为多个响应式依赖变化而更新,因此不应在其中无条件修改同一个会再次触发组件更新的状态:

onUpdated(() => {
  count.value++
})

这类逻辑可能形成更新循环。Vue 会对递归更新进行保护,但保护机制不是业务逻辑正确性的替代品。

3. nextTick:关注某次操作之后的 DOM

async function showDialog() {
  visible.value = true
  await nextTick()
  dialogElement.value?.focus()
}

它表达的是:

我刚刚修改了状态,现在等待这次修改引起的 Vue 更新完成。

因此,交互代码中常见的选择是:

  • 由某个状态长期驱动副作用:使用 watch(..., { flush: 'post' })
  • 组件每次更新都需要处理:使用 onUpdated
  • 某个事件处理函数修改状态后,需要继续操作新 DOM:使用 nextTick

八、父子组件和更新顺序

组件更新不是无序执行的。Vue 需要尽量保证父子组件之间的数据传递和更新关系成立。

例如:

<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const message = ref('old')
</script>

<template>
  <Child :message="message" />
  <button @click="message = 'new'">
    修改
  </button>
</template>

当父组件状态变化时,新的 prop 需要传给子组件,子组件才可能根据新 prop 更新。因此调度器会维护一定的父子更新顺序,常见实现中通常会让父组件更新任务优先于子组件更新任务,并跳过已经因父级更新而失效的子任务。

但这里应区分两层事实:

  • 公开行为层面:父子组件会按照 Vue 的组件更新模型协调更新;
  • 实现层面:任务排序、任务 ID、队列结构等属于 Vue 内部实现,不应在业务代码中依赖具体数字或内部函数。

当一个组件在更新过程中被卸载时,已经排队但不再有效的更新任务还可能被跳过。这也是调度器不能简单理解为“把所有回调依次执行一遍”的原因:队列中的任务可能在刷新过程中失效。


九、DOM 更新完成,不等于浏览器已经绘制

这是 nextTick 最容易被误解的边界。

1. Vue DOM 完成的时刻

await nextTick() 返回后,通常可以安全地读取 Vue 本次更新写入的 DOM:

visible.value = true
await nextTick()

const element = panelRef.value
console.log(element?.textContent)

这适合:

  • 获取刚刚挂载的元素;
  • 读取刚更新的文本、属性或 class;
  • 调用元素方法,例如 focus()
  • 读取布局信息,例如 getBoundingClientRect()

2. 浏览器绘制的时刻

浏览器通常会在 JavaScript 任务和微任务处理完成后,进入样式计算、布局、绘制等阶段。nextTick 处于 Vue 的微任务调度模型中,所以它并不保证代码执行时用户已经在屏幕上看到新像素。

需要等待浏览器下一次动画帧时,可以使用:

visible.value = true
await nextTick()

await new Promise<void>((resolve) => {
  requestAnimationFrame(() => resolve())
})

panelRef.value?.classList.add('entered')

这里的时序是:

修改状态
  ↓
Vue flush
  ↓
nextTick 继续
  ↓
浏览器进入动画帧回调
  ↓
执行依赖下一帧的逻辑

requestAnimationFrame 更接近“浏览器即将处理下一帧”的边界,但也不应被描述为绝对的“已经完成绘制”。浏览器可能因页面隐藏、主线程阻塞、刷新率变化或其他渲染条件而延迟帧处理。

3. 读取布局与强制同步布局

下面的代码通常可以在 nextTick 后读取新布局:

visible.value = true
await nextTick()

const rect = panelRef.value?.getBoundingClientRect()

getBoundingClientRect() 可能促使浏览器立即计算尚未提交的布局,这被称为强制同步布局的一类表现。若在循环中反复交替“写样式—读布局”,可能造成布局抖动:

for (const element of elements) {
  element.style.width = '100px'
  console.log(element.getBoundingClientRect().width)
}

这已经超出 Vue 调度器本身,进入浏览器布局性能问题。正确理解是:

  • nextTick 保证 Vue 更新已经处理到相应阶段;
  • 浏览器是否已完成样式计算、布局和绘制,仍由浏览器渲染管线决定;
  • 读取布局可能主动触发布局计算,不能把它等同于等待绘制。

十、一个可运行的 DOM 可见性示例

下面的组件演示三个不同边界:

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

const visible = ref(false)
const panel = ref<HTMLElement | null>(null)

async function showPanel() {
  visible.value = true

  console.log('同步读取:', panel.value)
  // 可能仍然是 null,因为 v-if 对应的 DOM 尚未挂载

  await nextTick()

  console.log('nextTick 读取:', panel.value)
  // 此时通常可以拿到元素

  const rect = panel.value?.getBoundingClientRect()
  console.log('布局尺寸:', rect?.width, rect?.height)

  await new Promise<void>((resolve) => {
    requestAnimationFrame(() => resolve())
  })

  console.log('动画帧回调:', panel.value)
}
</script>

<template>
  <button type="button" @click="showPanel">
    显示面板
  </button>

  <section v-if="visible" ref="panel">
    面板内容
  </section>
</template>

预期状态变化为:

点击按钮
visible: false → true
同步读取: null
Vue flush
nextTick 读取: HTMLElement
布局尺寸: ...
requestAnimationFrame 回调

如果使用的是 v-show 而不是 v-if,元素可能从一开始就存在,只是 display 或相关样式发生变化:

<section v-show="visible" ref="panel">
  面板内容
</section>

这时 panel.value 在状态修改前后都可能不是 null。因此,判断 DOM 是否存在不能只看 nextTick,还要看模板指令的语义:

  • v-if 控制挂载和卸载;
  • v-show 通常保留元素,只切换显示样式。

十一、异步边界会影响批处理范围

批处理通常以一次同步执行窗口为边界,而不是以整个函数或整个用户操作为边界。

例如:

async function update() {
  count.value = 1
  count.value = 2

  await fetch('/api/data')

  count.value = 3
  count.value = 4
}

第一段:

count.value = 1
count.value = 2

在第一次 await 之前,属于同一段同步执行代码,通常会合并为一次更新,最终渲染 2

网络请求完成后,函数从 await 之后恢复。第二段:

count.value = 3
count.value = 4

发生在另一个异步恢复阶段,通常形成后续的一轮批处理,最终渲染 4

可以表示为:

同步阶段:
1 → 2
→ 一次 Vue flush,渲染 2

异步恢复阶段:
3 → 4
→ 另一次 Vue flush,渲染 4

如果确实需要让中间状态先完成一次 DOM 更新,可以显式插入 nextTick

async function update() {
  count.value = 1
  await nextTick()

  count.value = 2
  await nextTick()
}

这会明确要求 Vue 至少完成两次更新阶段。但这不是为了“让 Vue 更快”,而是因为业务确实需要观察中间 DOM 状态,例如:

  • 先显示加载状态;
  • 等加载状态进入 DOM 后再启动耗时操作;
  • 先挂载过渡元素,再测量其尺寸。

十二、常见误解与失败表现

误解一:响应式状态改变后,DOM 立即同步改变

错误示例:

count.value++

if (document.querySelector('#count')?.textContent === '1') {
  // 不能依赖这里已经是 1
}

修正方式:

count.value++
await nextTick()

if (document.querySelector('#count')?.textContent === '1') {
  // 此时才是在 Vue 更新完成后读取
}

不过,如果业务逻辑只是判断状态,通常直接读取:

if (count.value === 1) {
  // 不需要通过 DOM 反向读取状态
}

只有在确实需要测量、聚焦、滚动或调用 DOM API 时,才应该等待 DOM。

误解二:nextTick 等待浏览器下一帧

错误理解:

await nextTick()
// 以为这里等同于 requestAnimationFrame

实际情况是:

nextTick:等待 Vue 当前刷新完成
requestAnimationFrame:等待浏览器动画帧调度点

如果问题是“元素为什么还没有在屏幕上出现”,需要诊断具体对象:

  • 是元素尚未挂载:检查 v-ifnextTick
  • 是元素已经挂载但样式隐藏:检查 CSS、v-show、过渡状态;
  • 是布局尺寸尚未稳定:检查字体、图片、异步内容和布局;
  • 是需要等待绘制时机:考虑 requestAnimationFrame
  • 是过渡动画尚未结束:使用过渡事件或动画完成回调,而不是盲目增加 nextTick

误解三:连续调用多个 nextTick 就会等待多帧

await nextTick()
await nextTick()

这不等价于等待两个浏览器帧。若中间没有产生新的待处理 Vue 更新,这两个调用可能都只对应当前可用的刷新 Promise。

若目标确实是等待新的状态更新,必须在两次等待之间产生新的状态变化:

state.value = 1
await nextTick()

state.value = 2
await nextTick()

若目标是等待浏览器帧,应使用 requestAnimationFrame,而不是堆叠 nextTick

误解四:flush: 'post' 可以取代所有 nextTick

flush: 'post' 适用于“某个响应式源变化后处理 DOM”。但事件处理函数中经常需要表达一个局部顺序:

async function open() {
  visible.value = true
  await nextTick()
  input.value?.focus()
}

此时用 nextTick 比创建一个额外 watcher 更直接,也更容易控制只执行一次。

相反,如果逻辑需要持续响应状态变化:

watch(
  results,
  () => {
    updateListHeight()
  },
  { flush: 'post' }
)

watcher 更符合数据流模型。


十三、调试时如何确定问题发生在哪一层

面对“状态变了但页面没变”或“DOM 读取还是旧值”,可以按以下顺序定位。

1. 先确认响应式状态是否真的改变

console.log('state:', count.value)

如果状态本身没有变,问题在赋值、类型转换、条件分支或异步流程,不在 nextTick

2. 再确认组件是否仍然挂载

检查:

  • v-if 是否为 false
  • 组件是否被 v-if、路由或动态组件卸载;
  • ref 是否指向当前组件实例;
  • 子组件是否仍在 DOM 树中。

3. 判断读取时机

临时加入:

console.log('before nextTick', element.value)
await nextTick()
console.log('after nextTick', element.value)

如果前者是旧 DOM、后者是新 DOM,说明问题只是读取早于 Vue flush。

4. 判断是否属于浏览器绘制或样式问题

如果 nextTick 后元素已经存在、文本和属性也正确,但视觉上仍不可见,应检查:

const style = element.value
  ? getComputedStyle(element.value)
  : null

console.log({
  display: style?.display,
  visibility: style?.visibility,
  opacity: style?.opacity,
  rect: element.value?.getBoundingClientRect()
})

还要检查:

  • 父元素是否隐藏;
  • z-index 和定位上下文;
  • CSS transition 是否处于开始阶段;
  • 图片和字体是否尚未加载;
  • 元素是否被遮挡或滚动到可视区之外。

5. 使用开发工具观察调用顺序

可以在以下位置分别记录:

console.log('mutation')
console.log('pre watcher')
console.log('post watcher')
console.log('onUpdated')
console.log('nextTick')
console.log('requestAnimationFrame')

不要只看最终页面截图。要把状态修改、Vue flush、DOM 读取和浏览器帧调度分开观察。


十四、更新循环和递归调度

调度器允许某些任务在刷新过程中再次触发其他任务,但这带来了递归更新风险。

例如:

const count = ref(0)

watch(
  count,
  () => {
    count.value++
  },
  { flush: 'sync' }
)

执行:

count.value++

会直接进入 watcher,watcher 又修改 count,进而再次进入 watcher。这个逻辑没有稳定终点。

即使使用默认异步 watcher,也不应把“异步执行”误认为“不会循环”:

watch(count, () => {
  count.value++
})

它仍可能在多个刷新周期中持续触发。Vue 会对某些递归更新进行检测并报告错误,但生产代码应通过业务条件保证收敛,例如:

watch(count, (value) => {
  if (value < 10) {
    count.value++
  }
})

更常见的正确做法是避免 watcher 既观察某个状态又无条件修改同一个状态,或者改用 computed 表达派生值:

const doubled = computed(() => count.value * 2)

computed 的职责是描述派生状态,不需要通过 watcher 手动同步:

// 不推荐:手动维护冗余状态
const doubled = ref(0)

watch(count, (value) => {
  doubled.value = value * 2
})

十五、错误处理与清理边界

nextTick 只负责等待调度时机,不负责等待或处理业务异步错误:

await nextTick()
await loadData()

这里:

  • nextTick() 等待 Vue 更新;
  • loadData() 可能抛出网络或业务错误;
  • 两者的错误来源不同,应分别处理。
async function openAndLoad() {
  visible.value = true
  await nextTick()

  try {
    await loadData()
  } catch (error) {
    console.error('加载失败:', error)
    errorMessage.value = '加载失败,请重试'
  }
}

如果组件可能在等待过程中被卸载,还需要避免对失效 DOM 或失效状态继续操作:

import { nextTick, onUnmounted, ref } from 'vue'

const disposed = ref(false)

onUnmounted(() => {
  disposed.value = true
})

async function focusInput() {
  visible.value = true
  await nextTick()

  if (disposed.value) {
    return
  }

  input.value?.focus()
}

实际项目中更推荐围绕组件生命周期取消异步请求、移除事件监听器和停止观察器,而不是只依赖一个布尔标记。nextTick 不会自动取消你创建的 fetch、定时器、ResizeObserver 或第三方库任务。


十六、SSR 和测试环境中的边界

在服务端渲染中,服务端没有浏览器 DOM。因此:

await nextTick()

可以参与 Vue 的异步调度,但不能在服务端据此读取 document、测量尺寸或调用 focus()

需要 DOM 的代码应放在客户端生命周期或客户端判断中,例如:

import { onMounted } from 'vue'

onMounted(() => {
  // 这里才可以安全访问浏览器 DOM
})

在组件测试中,等待响应式状态更新通常也需要:

await nextTick()

如果测试工具提供了 triggersetValue 等封装方法,它们可能已经内部等待了 Vue 更新;是否还需要显式调用 nextTick,取决于测试工具 API。不能仅凭“事件触发完成”就假设所有异步副作用都已结束:

Vue DOM 更新完成
≠ watcher 中的网络请求完成
≠ requestAnimationFrame 完成
≠ CSS transition 完成

测试应针对实际要验证的边界选择等待方式。


十七、规范保证、常见实现与经验判断

为了避免把实现细节误认为公共契约,需要分层理解。

Vue 的公共语义

可以依赖:

  • 响应式状态修改会触发相关组件和副作用更新;
  • 更新通常会被异步批处理;
  • nextTick 用于等待 Vue 更新完成;
  • watch 支持不同的 flush 时机;
  • flush: 'post' 用于在组件 DOM 更新后执行 watcher;
  • flush: 'sync' 会放弃该 watcher 的异步批处理。

常见但不应硬编码的实现细节

不应在业务逻辑中依赖:

  • 内部队列变量的名称;
  • 内部任务 ID 的具体分配方式;
  • 某个版本中所有回调的精确日志顺序;
  • Vue 内部使用的具体 Promise 变量;
  • 未公开的调度器函数。

需要结合浏览器环境判断的现象

不能只根据 Vue API 推断:

  • 用户何时看到像素变化;
  • 字体和图片何时加载完成;
  • 布局尺寸何时最终稳定;
  • CSS 动画何时结束;
  • 隐藏标签页何时执行动画帧。

这些问题由浏览器渲染管线、资源加载和 CSS 状态共同决定。


十八、形成一条可靠的判断规则

当代码需要操作 DOM 时,可以按以下因果关系判断等待边界:

只需要最新状态?
    → 直接读取响应式变量

需要因状态变化而新挂载或更新的 DOM?
    → await nextTick()

需要监听某个状态变化,并在其组件 DOM 更新后执行?
    → watch(..., { flush: 'post' })

需要响应组件的每次更新?
    → onUpdated()

需要浏览器动画帧附近的时机?
    → requestAnimationFrame()

需要等待过渡、动画、图片或字体完成?
    → 使用对应的完成事件或资源状态

最重要的区别是:

响应式状态已更新⇏Vue DOM 已更新⇏浏览器已完成绘制\text{响应式状态已更新} \not\Rightarrow \text{Vue DOM 已更新} \not\Rightarrow \text{浏览器已完成绘制}

Vue 调度器通过队列和批处理减少重复更新,flush 决定副作用位于组件更新流程的哪个阶段,nextTick 则为应用代码提供了一个等待当前 Vue 更新完成的入口。掌握这三个层次后,绝大多数“数据已经变了但 DOM 还是旧的”问题,都可以被准确归类为:读取时机过早、使用了错误的 flush 阶段,或把 Vue DOM 更新误认为浏览器绘制完成。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。